From 17f44144a5372ec80eefd318c31a8def35986b27 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 00:50:39 +0000 Subject: [PATCH 001/141] Show her working: static page of the lowering chain from the real lowerer and scorer Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + crates/weksa/examples/show_working.rs | 373 ++++++++++++++++++++++++++ scripts/publish-show-working.sh | 16 ++ tools/show-working/README.md | 9 + tools/show-working/build.py | 203 ++++++++++++++ 5 files changed, 602 insertions(+) create mode 100644 crates/weksa/examples/show_working.rs create mode 100755 scripts/publish-show-working.sh create mode 100644 tools/show-working/README.md create mode 100755 tools/show-working/build.py diff --git a/.gitignore b/.gitignore index 1428a57..e00572b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ build/ .venv/ venv/ target/ +tools/show-working/out/ diff --git a/crates/weksa/examples/show_working.rs b/crates/weksa/examples/show_working.rs new file mode 100644 index 0000000..cf24b64 --- /dev/null +++ b/crates/weksa/examples/show_working.rs @@ -0,0 +1,373 @@ +//! Build-time generator for the "Show her working" page. Runs the real +//! admission door, lowerer and IPA reader over the committed proof packet and +//! variants of it, and prints one JSON document on stdout: the typed slots, the +//! inventory entries the packet chose, and the outcome, which is lowered text or +//! a named refusal. Scoring is not here: `tools/show-working/build.py` calls the +//! `weksa score` CLI on the text this emits. +//! +//! Run from the repository root: `cargo run --release --example show_working`. +//! Every variant is a mutation of `proof_packet()`, spelled in its `source`. + +use std::path::Path; + +use serde_json::{Value, json}; +use weksa::packet::{ + EntityId, FeatValue, FeatureValue, Grade, NodeId, ProvenanceSource, RoleFill, proof_packet, +}; +use weksa::{ + CorpusStore, EntityStore, InvId, Inventory, InventoryStore, LowerError, Packet, PacketStore, + StoreError, lower, pronounce, +}; + +struct Fixture { + id: &'static str, + title: &'static str, + note: &'static str, + source: &'static str, + packet: Packet, +} + +fn id(s: &str) -> InvId { + s.parse().expect("inventory id") +} + +fn node(s: &str) -> NodeId { + NodeId::parse(s).expect("node id") +} + +fn grade(feature: &str, v: u16) -> FeatureValue { + FeatureValue { + feature: id(feature), + value: FeatValue::Grade(Grade::new(v).expect("grade")), + } +} + +fn fixtures() -> Vec { + let proof = proof_packet(); + let mut out = vec![Fixture { + id: "proof", + title: "Nibu warns the player off the control", + note: "The committed proof packet, unchanged.", + source: "proof_packet() in crates/weksa/src/packet.rs", + packet: proof.clone(), + }]; + let mut add = |id, title, note, source, f: &dyn Fn(&mut Packet)| { + let mut packet = proof.clone(); + f(&mut packet); + out.push(Fixture { id, title, note, source, packet }); + }; + add( + "no-addressee", + "Same meaning, nobody addressed", + "With the addressee unset the player is a third party: no \"you\", no reflexive.", + "proof_packet() with discourse.addressee = None", + &|p| p.discourse.addressee = None, + ); + add( + "stance-held-back", + "Anger held back, restraint high", + "Stance is the speaker's felt affect. The lowerer has no row that reads it, so the text is the proof's text.", + "proof_packet() with stance anger=300, restraint=700", + &|p| p.stance.features = vec![grade("feature:9", 300), grade("feature:18", 700)], + ); + add( + "hedged", + "Hedged and uncertain warning", + "Hedge and certainty are meaning the lowerer cannot yet say. It refuses rather than drop them.", + "proof_packet() with discourse hedge=600, certainty=400", + &|p| p.discourse.features = vec![grade("feature:6", 600), grade("feature:7", 400)], + ); + add( + "negated", + "Negated intervention (polarity: negative)", + "Dropping the negation would say the opposite, so the lowerer refuses. This is the shape of a negated Dao line awaiting polarity.", + "proof_packet() with predication n5 polarity=negative", + &|p| { + p.predications[1].features = vec![FeatureValue { + feature: id("feature:1"), + value: FeatValue::Option("negative".parse().expect("label")), + }] + }, + ); + add( + "consent-authored", + "Addressee consent, authored source", + "Someone who saw the request says the addressee asked for it. No lowering speaks for that request yet.", + "proof_packet() with discourse.addressee_consent = n4 (source Authored)", + &|p| p.discourse.addressee_consent = Some(node("n4")), + ); + add( + "consent-imported", + "Addressee consent, imported-corpus source", + "A reader of wording cannot have seen a request, so the packet is refused at admission (V15), before any lowering.", + "proof_packet() with addressee_consent = n4 and provenance.source = ImportedCorpus", + &|p| { + p.discourse.addressee_consent = Some(node("n4")); + p.provenance.source = ProvenanceSource::ImportedCorpus; + }, + ); + add( + "relation", + "A causal relation between the predications", + "Relations are not rendered; the lowerer refuses instead of asserting a different meaning.", + "proof_packet() with relation:8 (causes) n4 -> n5", + &|p| { + p.relations = vec![weksa::packet::Relation { + relation: id("relation:8"), + from: node("n4"), + to: node("n5"), + features: vec![], + }] + }, + ); + add( + "dangling-filler", + "A role filled by a node that does not exist", + "Refused at admission (V4): the packet is not a packet.", + "proof_packet() with predication n5 agent filler n9", + &|p| { + p.predications[1].roles[0] = RoleFill { role: id("role:1"), filler: node("n9") } + }, + ); + add( + "unknown-entity", + "A referent naming an entity nobody holds", + "The entity store is the only owner of names; a packet cannot invent one.", + "proof_packet() with referent n3 entity = entity:nobody", + &|p| p.referents[2].entity = Some(EntityId::parse("entity:nobody").expect("entity")), + ); + add( + "common-noun-agent", + "The ship mind as a common noun", + "No entity: the referent lowers as \"the ship mind\", and there is no name to pronounce.", + "proof_packet() with referent n3 entity = None", + &|p| p.referents[2].entity = None, + ); + out +} + +fn gloss(inv: &Inventory, rev: u32, i: &InvId) -> Value { + match inv.resolve(i, rev) { + Some(e) => json!({ "id": i.to_string(), "gloss": e.gloss }), + None => json!({ "id": i.to_string(), "gloss": null }), + } +} + +fn feature_json(inv: &Inventory, rev: u32, slot: &str, at: &str, f: &FeatureValue) -> Value { + let mut g = gloss(inv, rev, &f.feature); + g["slot"] = json!(slot); + g["at"] = json!(at); + g["value"] = serde_json::to_value(&f.value).expect("value"); + g +} + +/// Every feature the packet carries, with where it sits. +fn features(p: &Packet, inv: &Inventory) -> Vec { + let rev = p.inventory_rev; + let mut v = Vec::new(); + for f in &p.discourse.features { + v.push(feature_json(inv, rev, "discourse", "discourse", f)); + } + for f in &p.stance.features { + v.push(feature_json(inv, rev, "stance", "stance", f)); + } + for r in &p.referents { + for f in &r.features { + v.push(feature_json(inv, rev, "referent", r.id.as_str(), f)); + } + } + for pr in &p.predications { + for f in &pr.features { + v.push(feature_json(inv, rev, "predication", pr.id.as_str(), f)); + } + } + v +} + +/// Ids the packet chose, once each, with the inventory's gloss at its revision. +fn chosen(p: &Packet, inv: &Inventory) -> Vec { + let rev = p.inventory_rev; + let mut ids: Vec = Vec::new(); + let mut push = |i: &InvId| { + if !ids.contains(i) { + ids.push(*i) + } + }; + p.context.setting.iter().for_each(&mut push); + p.context.medium.iter().for_each(&mut push); + p.discourse.speech_act.iter().for_each(&mut push); + for r in &p.referents { + push(&r.kind); + push(&r.concept); + } + for pr in &p.predications { + push(&pr.concept); + push(&pr.frame); + pr.roles.iter().for_each(|f| push(&f.role)); + } + p.relations.iter().for_each(|r| push(&r.relation)); + features(p, inv) + .iter() + .filter_map(|f| f["id"].as_str().and_then(|s| s.parse().ok())) + .for_each(|i: InvId| push(&i)); + ids.iter().map(|i| gloss(inv, rev, i)).collect() +} + +fn slots(p: &Packet) -> Value { + let j = |v: &dyn erased::Ser| v.to_value(); + json!({ + "meaning": { + "referents": p.referents.iter().map(|r| j(r)).collect::>(), + "predications": p.predications.iter().map(|r| j(r)).collect::>(), + "relations": p.relations.iter().map(|r| j(r)).collect::>(), + }, + "stance": j(&p.stance), + "discourse": j(&p.discourse), + "context": j(&p.context), + "extensions": p.extensions.iter().map(|r| j(r)).collect::>(), + "constraints": j(&p.constraints), + "provenance": { "source": j(&p.provenance.source), "corpus_item": p.provenance.corpus_item.to_string(), "confidence": p.provenance.confidence.get() }, + "kind": j(&p.kind), + "inventory_rev": p.inventory_rev, + }) +} + +mod erased { + pub trait Ser { + fn to_value(&self) -> serde_json::Value; + } + impl Ser for T { + fn to_value(&self) -> serde_json::Value { + serde_json::to_value(self).expect("serialize") + } + } +} + +fn lower_error(e: &LowerError) -> Value { + let (variant, reason) = match e { + LowerError::Unrendered { what } => ("Unrendered", format!("the packet carries {what}, which the lowerer does not render")), + LowerError::NoRow { id } => ("NoRow", format!("no table row for {id}")), + LowerError::GlossMismatch { id, row } => ("GlossMismatch", format!("{id} is not the {row} the row speaks for")), + LowerError::UnknownRole { frame, role } => ("UnknownRole", format!("{frame} names role {role}, which the inventory lacks")), + LowerError::MissingRole { predication, role } => ("MissingRole", format!("{predication} fills no {role} role")), + LowerError::UnknownEntity { entity } => ("UnknownEntity", format!("the entity store does not hold {entity}")), + LowerError::NoForm { entity } => ("NoForm", format!("{entity} has no en-US form")), + LowerError::NoPronunciation { entity } => ("NoPronunciation", format!("{entity} has no ruled en-US pronunciation")), + LowerError::NoEntityReferent => ("NoEntityReferent", "the packet cites no entity".to_string()), + LowerError::EntityRead { entity, why } => ("EntityRead", format!("reading {entity}: {why}")), + }; + json!({ "variant": variant, "reason": reason, "debug": format!("{e:?}") }) +} + +struct World { + inventory: InventoryStore, + corpus: CorpusStore, + entities: EntityStore, + inv: Inventory, +} + +/// Admit `packet` through a fresh packet store and lower it. `Ok` carries the +/// text and the IPA outcome; `Err` is the refusal, as JSON. +fn run(w: &World, dir: &Path, packet: &Packet) -> Result<(String, Value), Value> { + let mut store = PacketStore::open(&dir.join("p.cc")).expect("packet store"); + let view = match store.admit(packet.clone(), &w.inventory, &w.corpus, &w.entities) { + Ok(v) => v, + Err(StoreError::Invalid(rs)) => { + return Err(json!({ + "kind": "admission_refused", + "refusals": rs.iter().map(|r| json!({ "rule": r.rule(), "detail": format!("{r:?}") })).collect::>(), + })); + } + Err(e) => { + return Err(json!({ "kind": "admission_refused", "refusals": [{ "rule": "store", "detail": format!("{e:?}") }] })); + } + }; + let text = lower(&view, &w.inv, &w.entities).map_err(|e| { + json!({ "kind": "lower_error", "error": lower_error(&e) }) + })?; + let ipa = match pronounce(&view, &w.entities) { + Ok(v) => json!({ "ok": v }), + Err(e) => json!({ "error": lower_error(&e) }), + }; + Ok((text, ipa)) +} + +fn main() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let inventory = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).expect("inventory"); + let inv = inventory.inventory().expect("inventory view"); + let w = World { + corpus: CorpusStore::open(&root.join("data/corpus/corpus.cc")).expect("corpus"), + entities: EntityStore::open(&root.join("data/entity/entity.cc")).expect("entities"), + inventory, + inv, + }; + let dir = std::env::temp_dir().join(format!("show-working-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + let mut out = Vec::new(); + for fx in fixtures() { + let p = &fx.packet; + let first = run(&w, &dir, p); + let outcome = match first { + Err(e) if e["kind"] == "admission_refused" => e, + first => { + // Ablation: run again with each feature removed, against the + // baseline outcome. It is how the page says whether the lowerer + // read a feature, instead of the page asserting it. + let base = first.as_ref().ok().map(|(t, _)| t.clone()); + let n_disc = p.discourse.features.len(); + let n_stance = p.stance.features.len(); + let total = features(p, &w.inv).len(); + let mut effects = Vec::new(); + for k in 0..total { + let mut q = p.clone(); + if k < n_disc { + q.discourse.features.remove(k); + } else if k < n_disc + n_stance { + q.stance.features.remove(k - n_disc); + } else { + let mut at = k - n_disc - n_stance; + 'o: { + for r in &mut q.referents { + if at < r.features.len() { r.features.remove(at); break 'o; } + at -= r.features.len(); + } + for pr in &mut q.predications { + if at < pr.features.len() { pr.features.remove(at); break 'o; } + at -= pr.features.len(); + } + } + } + let _ = std::fs::remove_file(dir.join("p.cc")); + effects.push(match (&base, run(&w, &dir, &q)) { + (Some(t), Ok((u, _))) if *t == u => "Without it the text is identical: the lowerer did not read it.", + (Some(_), Ok(_)) => "Without it the text changes: the lowerer read it.", + (Some(_), Err(_)) => "Without it the packet is refused.", + (None, Ok(_)) => "Without it the packet lowers: this feature is what the lowerer refuses.", + (None, Err(_)) => "Still refused without this feature alone.", + }); + } + match first { + Ok((text, ipa)) => json!({ "kind": "lowered", "text": text, "ipa": ipa, "feature_effects": effects }), + Err(e) => { let mut e = e; e["feature_effects"] = json!(effects); e } + } + } + }; + let _ = std::fs::remove_file(dir.join("p.cc")); + let mut slots = slots(p); + slots["features"] = json!(features(p, &w.inv)); + out.push(json!({ + "id": fx.id, + "title": fx.title, + "note": fx.note, + "source": fx.source, + "packet_id": p.id.to_string(), + "corpus_item": p.provenance.corpus_item.to_string(), + "slots": slots, + "chosen": chosen(p, &w.inv), + "outcome": outcome, + })); + } + let _ = std::fs::remove_dir_all(&dir); + println!("{}", serde_json::to_string_pretty(&json!({ "fixtures": out })).expect("json")); +} diff --git a/scripts/publish-show-working.sh b/scripts/publish-show-working.sh new file mode 100755 index 0000000..3af3194 --- /dev/null +++ b/scripts/publish-show-working.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build the "Show her working" page from the real lowerer and scorer, then publish it as a Mind preview. +# Run from the repository root, in a Mind's Self run (it writes only /srv/minds-web//). +# +# scripts/publish-show-working.sh [--no-publish] +# +# Needs cargo, python3 and an Ollama embedder at 127.0.0.1:11434 (qwen3-embedding:0.6b): the scorer is not stubbed. +# publish-mind-preview.sh lives in the gamecult-ops checkout: set GAMECULT_OPS to it (default ../gamecult-ops). +set -euo pipefail +[ -f Cargo.toml ] && [ -d tools/show-working ] || { echo "run from the Weksa repository root" >&2; exit 2; } +out=${SHOW_WORKING_OUT:-tools/show-working/out} +rm -rf "$out" +python3 -I tools/show-working/build.py "$out" +[ "${1:-}" = --no-publish ] && { echo "built $out"; exit 0; } +ops=${GAMECULT_OPS:-../gamecult-ops} +exec "$ops/scripts/publish-mind-preview.sh" "$out" weksa-show-working diff --git a/tools/show-working/README.md b/tools/show-working/README.md new file mode 100644 index 0000000..5e2a320 --- /dev/null +++ b/tools/show-working/README.md @@ -0,0 +1,9 @@ +# Show her working + +A static page that shows the lowering chain for committed fixture packets: typed slots, inventory entries chosen, lowered English, scorer closeness to the corpus target, with hedge, certainty and restraint annotated. Refusals (admission rules, `LowerError`, addressee consent, negation awaiting polarity) are results, not omissions. + +- `crates/weksa/examples/show_working.rs`: runs the real admission door, `lower` and `pronounce` over `proof_packet()` and its variants and prints JSON. Each feature is removed in turn and the packet re-run, so "the lowerer read this" is measured. +- `build.py`: runs that, scores each lowered text with the `weksa score` CLI (needs Ollama with `qwen3-embedding:0.6b`), writes `out/data.json` and one dependency-free `out/index.html`. Fails if anything cannot run; there is no fallback text. +- `scripts/publish-show-working.sh`: build, then publish through gamecult-ops `publish-mind-preview.sh`. + +Adding a fixture: add an entry in `fixtures()` in the example. A packet from another store needs a loader there; none exists yet. diff --git a/tools/show-working/build.py b/tools/show-working/build.py new file mode 100755 index 0000000..269032e --- /dev/null +++ b/tools/show-working/build.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Build the "Show her working" page from the real lowerer and scorer. + +usage: build.py [out_dir] (run from the repository root) + +1. `cargo run --example show_working` admits each fixture packet through the + packet store and lowers it with the English lowerer (crates/weksa/examples). +2. `weksa score` scores each lowered text against its corpus item (and the + other committed Nibu reference) through the Ollama embedder. +3. out_dir/data.json holds the joined result; out_dir/index.html is one file + with no scripts to fetch and no network calls. + +Nothing on the page is typed by hand except the layout and the reading notes. +If cargo or the scorer cannot run, the build fails: there is no fallback text. +Standard library only. +""" +import json +import pathlib +import re +import subprocess +import sys +import time +from html import escape as e + +root = pathlib.Path.cwd() +out_dir = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "tools/show-working/out") +weksa_bin = None + + +def run(cmd, **kw): + r = subprocess.run(cmd, cwd=root, capture_output=True, text=True, **kw) + if r.returncode != 0: + sys.exit(f"{' '.join(map(str, cmd))} failed:\n{r.stderr}") + return r.stdout + + +def cargo_target(): + meta = json.loads(run(["cargo", "metadata", "--format-version", "1", "--no-deps"])) + return pathlib.Path(meta["target_directory"]) + + +# -- 1. the real lowerer ------------------------------------------------------ +run(["cargo", "build", "--release", "--bin", "weksa", "--example", "show_working"]) +target = cargo_target() / "release" +weksa_bin = target / "weksa" +data = json.loads(run([str(target / "examples" / "show_working")])) + +# -- 2. the real scorer ------------------------------------------------------- +corpus = {} +for m in re.finditer(r'^(item:\S+) \[(.*?)\]\n "(.*)"$', run([str(weksa_bin), "corpus", "dump"]), re.M): + corpus[m.group(1)] = {"text": m.group(3), "meta": m.group(2)} +REFS_EXTRA = ["item:nibu:2"] +model = "qwen3-embedding:0.6b" + + +def score(text, items): + out = run([str(weksa_bin), "score", *items], input=text) + got = {} + for m in re.finditer(r"^(item:\S+) chrf=([\d.]+) meaning=(-?[\d.]+)", out, re.M): + got[m.group(1)] = {"chrf": float(m.group(2)), "meaning": float(m.group(3))} + if set(got) != set(items): + sys.exit(f"scorer answered {sorted(got)} for {items}:\n{out}") + return got + + +for fx in data["fixtures"]: + o = fx["outcome"] + if o["kind"] == "lowered": + refs = [fx["corpus_item"]] + [r for r in REFS_EXTRA if r != fx["corpus_item"]] + o["scores"] = [{"item": k, "target": k == fx["corpus_item"], **v} for k, v in score(o["text"], refs).items()] + +data["corpus"] = corpus +data["build"] = { + "commit": run(["git", "rev-parse", "HEAD"]).strip(), + "embedder": model, + "built_unix": int(time.time()), +} +out_dir.mkdir(parents=True, exist_ok=True) +(out_dir / "data.json").write_text(json.dumps(data, indent=1, ensure_ascii=False), encoding="utf-8") + +# -- 3. the page -------------------------------------------------------------- +CSS = """ +:root{--ink:#1c1b19;--mute:#5d5a54;--line:#cfc9bd;--bg:#f6f3ec;--card:#fff;--ok:#0b6b4b;--warn:#9a4a00;--bad:#9b1c2c;--bar:#365f9c} +*{box-sizing:border-box}body{font:16px/1.55 system-ui,sans-serif;max-width:62rem;margin:2rem auto;padding:0 1rem;color:var(--ink);background:var(--bg)} +h1{margin:0 0 .2rem}h2{margin:0 0 .4rem}h3{margin:1.2rem 0 .3rem;font-size:.85rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mute)} +code,.mono{font:13px/1.4 ui-monospace,Menlo,monospace}code{background:#ece8dd;padding:0 .25rem;border-radius:3px} +.lede{max-width:46rem}.meta{color:var(--mute);font-size:.88rem} +nav.pick{display:flex;flex-wrap:wrap;gap:.4rem;margin:1.2rem 0} +nav.pick a{border:1px solid var(--line);background:var(--card);padding:.25rem .6rem;border-radius:999px;text-decoration:none;color:var(--ink);font-size:.9rem} +nav.pick a.refused{border-color:var(--bad)}nav.pick a.lowered{border-color:var(--ok)} +article{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:1rem 1.3rem;margin:1.4rem 0} +.chain{display:grid;grid-template-columns:repeat(4,1fr);gap:.6rem;margin:.6rem 0 1rem} +.chain div{border:1px solid var(--line);border-radius:6px;padding:.3rem .6rem;font-size:.82rem;color:var(--mute)} +.chain b{display:block;color:var(--ink)} +table{border-collapse:collapse;width:100%;font-size:.9rem}th,td{text-align:left;padding:.25rem .5rem;border-bottom:1px solid #e6e1d5;vertical-align:top}th{color:var(--mute);font-weight:600} +.out{font-size:1.15rem;padding:.6rem .9rem;border-left:4px solid var(--ok);background:#eef6f1} +.refusal{padding:.6rem .9rem;border-left:4px solid var(--bad);background:#faeef0}.refusal b{color:var(--bad)} +.tag{display:inline-block;font-size:.75rem;padding:0 .4rem;border-radius:3px;border:1px solid var(--line);color:var(--mute)} +.tag.read{border-color:var(--ok);color:var(--ok)}.tag.unread{border-color:var(--warn);color:var(--warn)}.tag.refuse{border-color:var(--bad);color:var(--bad)} +.bar{height:.7rem;background:#e4dfd2;border-radius:3px;min-width:8rem;position:relative}.bar i{display:block;height:100%;background:var(--bar);border-radius:3px} +.ref{font-size:.88rem;color:var(--mute);margin:.2rem 0 .6rem}.ref q{color:var(--ink)} +details{margin:.5rem 0}summary{cursor:pointer;color:var(--mute);font-size:.88rem} +section.gaps li{margin:.3rem 0} +@media(max-width:700px){.chain{grid-template-columns:1fr 1fr}} +""" + +PICK_JS = """(function(){var a=[].slice.call(document.querySelectorAll('article[data-fx]'));function show(){var h=location.hash.slice(1);var any=a.some(function(x){return x.id===h});a.forEach(function(x){x.hidden=any&&x.id!==h})} +window.addEventListener('hashchange',show);show()})()""" + + +def g(chosen, i): + return next((c["gloss"] for c in chosen if c["id"] == i), "?") + + +def val(v): + (k, x), = v.items() + return f"{x}‰" if k == "Grade" else str(x) + + +def slots_html(fx): + s, ch = fx["slots"], fx["chosen"] + rows = [] + for r in s["meaning"]["referents"]: + ent = f" · entity {e(r['entity'])}" if r["entity"] else "" + rows.append(f"{e(r['id'])}referent{e(g(ch, r['kind']))} / {e(g(ch, r['concept']))}{ent}") + for p in s["meaning"]["predications"]: + roles = ", ".join(f"{e(g(ch, x['role']))}={e(x['filler'])}" for x in p["roles"]) + rows.append(f"{e(p['id'])}predication{e(g(ch, p['concept']))} in frame {e(g(ch, p['frame']))}: {roles}") + for r in s["meaning"]["relations"]: + rows.append(f"relation{e(g(ch, r['relation']))}: {e(r['from'])} → {e(r['to'])}") + meaning = "" + "".join(rows) + "
nodekindcontent
" + d = s["discourse"] + drows = [] + if d["speech_act"]: + drows.append(f"speech act{e(g(ch, d['speech_act']))}") + if d["addressee"]: + drows.append(f"addressee{e(d['addressee'])}") + if d["addressee_consent"]: + drows.append(f"addressee consentpredication {e(d['addressee_consent'])} (what the addressee asked for)") + drows.append(f"source{e(s['provenance']['source'])}, kind {e(s['kind'])}") + return (f"

Slot: meaning

{meaning}

Slot: discourse

{''.join(drows)}
" + + stance_register(fx)) + + +def stance_register(fx): + s = fx["slots"] + eff = fx["outcome"].get("feature_effects") or [] + rows = [] + for i, f in enumerate(s["features"]): + note = eff[i] if i < len(eff) else "" + cls = "read" if "read it" in note else "unread" if "did not read" in note else "refuse" if "refus" in note else "" + rows.append(f"{e(f['gloss'])} {e(f['id'])}{e(f['slot'])} @ {e(f['at'])}{e(val(f['value']))}{e(note)}") + feats = ("" + "".join(rows) + "
featureslotvaluemeasured effect on the lowerer
") if rows else "

No features: no hedge, certainty, polarity, irony or stance set.

" + return ("

Slot: stance, hedge, certainty, restraint (features)

" + feats + + "

Register: the packet has no register slot. Register and address belong to a target-language profile, not to the packet; this lowerer reads none.

") + + +def outcome_html(fx): + o = fx["outcome"] + if o["kind"] == "lowered": + ipa = o["ipa"] + ipa_s = ("IPA of cited entities: " + e(" ".join(ipa["ok"])) + "") if "ok" in ipa else f"IPA refused: {e(ipa['error']['reason'])} {e(ipa['error']['variant'])}" + sc = [] + for s in o["scores"]: + ref = corpus[s["item"]]["text"] + sc.append(f"
{'Target' if s['target'] else 'Other reference'} {e(s['item'])}: {e(ref)}
" + f"" + f"
chrF {s['chrf']:.1f}
meaning cosine {s['meaning']:.3f}
") + return (f"

Lowered English

{e(o['text'])}

{ipa_s}

" + f"

Closeness to the corpus ({e(data['build']['embedder'])})

{''.join(sc)}") + if o["kind"] == "lower_error": + er = o["error"] + return f"

Refused by the lowerer

LowerError::{e(er['variant'])}: {e(er['reason'])}
{e(er['debug'])}

No text, so no score: nothing was invented to score.

" + items = "".join(f"
  • {e(r['rule'])} {e(r['detail'])}
  • " for r in o["refusals"]) + return f"

    Refused at admission

      {items}

    The packet never became a lowering input, so the lowerer never saw it.

    " + + +def card(fx): + o = fx["outcome"] + ch = "".join(f"{e(c['id'])}{e(c['gloss'] or '(unresolved)')}" for c in fx["chosen"]) + steps = [("1 Typed slots", "meaning, stance, discourse"), ("2 Inventory entries", f"{len(fx['chosen'])} ids, rev {fx['slots']['inventory_rev']}"), + ("3 Lowered English", {"lowered": "text", "lower_error": "refused", "admission_refused": "never reached"}[o["kind"]]), + ("4 Closeness", "scored" if o["kind"] == "lowered" else "no text, no score")] + chain = "".join(f"
    {e(a)}{e(b)}
    " for a, b in steps) + return (f"

    {e(fx['title'])}

    {e(fx['note'])}

    " + f"

    Packet {e(fx['packet_id'])} for {e(fx['corpus_item'])}. Made from: {e(fx['source'])}.

    " + f"
    {chain}
    {slots_html(fx)}" + f"
    Inventory entries chosen ({len(fx['chosen'])}){ch}
    idgloss at this revision
    " + f"{outcome_html(fx)}
    ") + + +nav = "".join(f"{e(f['title'])}" for f in data["fixtures"]) +b = data["build"] +page = f""" +Weksa: show her working +

    Show her working

    +

    A meaning packet goes in. The English lowerer picks inventory entries, writes a sentence, and a scorer measures how close that sentence is to a human-written target. Every number and sentence below was produced by running the real Rust lowerer and scorer when this page was built. The refusals are results too: when the lowerer cannot say a meaning without changing it, it says so instead of dropping the part it cannot say.

    +

    Built from {e(b['commit'][:10])}, embedder {e(b['embedder'])}. English only. Fixtures are the committed nibu-proof-001 packet and variants of it, each variant described in its own card. Scores are against the committed corpus: chrF (0-100) and embedding cosine. Raw data: data.json.

    + +{''.join(card(f) for f in data['fixtures'])} +""" +(out_dir / "index.html").write_text(page, encoding="utf-8") +print(f"wrote {out_dir}/index.html and data.json ({len(data['fixtures'])} fixtures)") From 0b62d2f3b422ca2d3e77df0e9c4d91f1c6340e34 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 02:29:51 +0000 Subject: [PATCH 002/141] Retire V13 and with_scene's raw door; Stance and Scene features sit in one slot with_scene is removed until it takes a Scene document (next commit); V13 goes with it. Slot exclusivity becomes a rule of InventoryEntry::well_formed, so FEAT_ALL leaves Stance and its stance duplicate case goes (ANGER pins V11 in Stance). tests/scene.rs is deleted and rewritten on Scene documents later. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/inventory.rs | 15 ++- crates/weksa/src/validate.rs | 47 +--------- crates/weksa/tests/common/mod.rs | 4 +- crates/weksa/tests/display.rs | 1 - crates/weksa/tests/scene.rs | 156 ------------------------------- crates/weksa/tests/stores.rs | 40 ++++++++ crates/weksa/tests/validate.rs | 3 - 7 files changed, 58 insertions(+), 208 deletions(-) delete mode 100644 crates/weksa/tests/scene.rs diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index c9d69a2..d5f2e81 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -228,10 +228,21 @@ impl InventoryEntry { } /// Whether the entry's shape matches its family: features carry a value kind - /// and slots, extensions a value kind and no slots, nothing else either. + /// and slots, extensions a value kind and no slots, nothing else either. A + /// feature of slot `Stance` or `Scene` sits in that slot alone: each + /// speaker-state layer has one writer and one lifetime, so its features sit + /// nowhere else. pub fn well_formed(&self) -> bool { match self.family() { - Family::Feature => self.value_kind.is_some() && !self.applies_to.is_empty(), + Family::Feature => { + self.value_kind.is_some() + && !self.applies_to.is_empty() + && (self.applies_to.len() == 1 + || !self + .applies_to + .iter() + .any(|s| matches!(s, Slot::Stance | Slot::Scene))) + } Family::Extension => self.value_kind.is_some() && self.applies_to.is_empty(), _ => self.value_kind.is_none() && self.applies_to.is_empty(), } diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index 6a0075f..e5a917e 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -3,10 +3,9 @@ //! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a //! lowerer sees, comes from `PacketStore::admit` alone, which builds it through //! the crate-private `admissible`. The stance is checked like every feature -//! list, in slot `Stance`. The rules are V1..V8 and V10..V15: the shape of an id -//! or a grade is its type's parse, not a rule, so V9 is retired and not -//! renumbered. V13 guards the one other way features reach a lowering view: -//! [`LoweringInput::with_scene`]. +//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V15: the +//! shape of an id or a grade is its type's parse, not a rule, so V9 is retired +//! and not renumbered; V13 is retired too. //! //! `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 @@ -95,10 +94,6 @@ pub enum Refusal { count: usize, max: usize, }, - /// V13: scene features were given for a packet with no addressee. They - /// state the speaker's relation to the addressee, so with none they - /// describe nobody. - V13SceneWithoutAddressee, /// V14: an addressee consent that no addressee can have given, or that /// names a node that is not a predication. It is what the addressee asked /// for or agreed to, an act, so a referent cannot be its object. @@ -130,7 +125,6 @@ impl Refusal { | Refusal::V11DuplicateNodeRef { .. } | Refusal::V11DuplicateRole { .. } => "V11", Refusal::V12TooManyNodes { .. } => "V12", - Refusal::V13SceneWithoutAddressee => "V13", Refusal::V14ConsentWithoutAddressee | Refusal::V14ConsentNotAPredication { .. } => { "V14" } @@ -157,7 +151,6 @@ impl fmt::Display for Refusal { Refusal::V11DuplicateNodeRef { .. } => "a node id repeats in one list", Refusal::V11DuplicateRole { .. } => "a role id repeats in one predication", Refusal::V12TooManyNodes { .. } => "too many nodes", - Refusal::V13SceneWithoutAddressee => "scene features given without an addressee", Refusal::V14ConsentWithoutAddressee => "consent given without an addressee", Refusal::V14ConsentNotAPredication { .. } => "consent names no predication", Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording", @@ -594,40 +587,6 @@ impl LoweringInput { } } - /// Adds the speaker's relation to the addressee: feature values of slot - /// `Scene`, checked like every feature list (V5 to V8, V11) at this view's - /// inventory revision. They state a relation, so the packet must name an - /// addressee (V13). Typed ids and grades only, never wording, so a scene - /// carries no text to the lowerer. The scene is not part of the packet: a - /// scene document supplies it, and replacing it replaces the earlier one. - pub fn with_scene( - mut self, - inventory: &Inventory, - scene: Vec, - ) -> Result> { - let mut out = Vec::new(); - if !inventory.has_revision(self.inventory_rev) { - out.push(Refusal::V10UnknownRevision { - rev: self.inventory_rev, - }); - } else { - Inventoried { - inv: inventory, - rev: self.inventory_rev, - out: &mut out, - } - .features(&scene, Slot::Scene); - } - if !scene.is_empty() && self.discourse.addressee.is_none() { - out.push(Refusal::V13SceneWithoutAddressee); - } - if !out.is_empty() { - return Err(out); - } - self.scene = scene; - Ok(self) - } - pub fn inventory_rev(&self) -> u32 { self.inventory_rev } diff --git a/crates/weksa/tests/common/mod.rs b/crates/weksa/tests/common/mod.rs index 60ba544..9368227 100644 --- a/crates/weksa/tests/common/mod.rs +++ b/crates/weksa/tests/common/mod.rs @@ -34,7 +34,8 @@ pub const CONCEPT_LATE: &str = "concept:4"; pub const FRAME: &str = "frame:1"; /// An extension entry of value kind Grade, introduced at rev 2. pub const EXT: &str = "extension:1"; -/// A Grade feature introduced at rev 2 that sits in every slot. +/// A Grade feature introduced at rev 2 in every content slot (Discourse, +/// Referent, Predication, Relation). pub const FEAT_ALL: &str = "feature:50"; pub const PACKET_ID: &str = "packet:0123456789abcdef"; @@ -218,7 +219,6 @@ pub fn world() -> World { Slot::Referent, Slot::Predication, Slot::Relation, - Slot::Stance, ], ) }, diff --git a/crates/weksa/tests/display.rs b/crates/weksa/tests/display.rs index 3c1ba63..44063fa 100644 --- a/crates/weksa/tests/display.rs +++ b/crates/weksa/tests/display.rs @@ -117,7 +117,6 @@ fn refusal_display_names_the_rule_and_no_identifier() { count: 4242, max: 4242, }, - Refusal::V13SceneWithoutAddressee, Refusal::V14ConsentWithoutAddressee, Refusal::V14ConsentNotAPredication { id: node() }, Refusal::V15ConsentSource { diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs deleted file mode 100644 index 0fc4168..0000000 --- a/crates/weksa/tests/scene.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! The speaker's relation to the addressee as a lowering input: typed feature -//! values of slot `Scene`, checked against the inventory at the view's revision, -//! never wording, and only for a packet that names an addressee. - -mod common; -use std::path::Path; - -use common::{grade, iv, lab}; -use weksa::inventory::{proof_revision, scene_revision, seed}; -use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet}; -use weksa::{ - CorpusStore, 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(), 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() -} - -fn at_rev(rev: u32) -> Packet { - let mut p = proof_packet(); - p.inventory_rev = rev; - p -} - -fn feat(id: &str, value: FeatValue) -> FeatureValue { - FeatureValue { - feature: iv(id), - value, - } -} - -fn status(inv: &Inventory) -> String { - inv.entries() - .find(|e| e.gloss == "relative_status") - .unwrap() - .id - .to_string() -} - -fn id_of(inv: &Inventory, gloss: &str) -> String { - inv.entries() - .find(|e| e.gloss == gloss) - .unwrap() - .id - .to_string() -} - -fn rules(r: Result>) -> Vec<&'static str> { - r.unwrap_err().iter().map(Refusal::rule).collect() -} - -#[test] -fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { - let (store, inv, _dir) = store(); - let plain = admitted(&at_rev(3), &store); - assert!(plain.scene().is_empty()); - let scene = vec![ - feat(&status(&inv), FeatValue::Option(lab("above"))), - feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(200))), - feat(&id_of(&inv, "in_group"), FeatValue::Flag(false)), - ]; - let with = plain.clone().with_scene(&inv, scene.clone()).unwrap(); - assert_eq!(with.scene(), scene.as_slice()); - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap(); - assert_eq!( - lower(&with, &inv, &entities).unwrap(), - lower(&plain, &inv, &entities).unwrap() - ); -} - -#[test] -fn a_relation_needs_an_addressee() { - let (store, inv, _dir) = store(); - let mut p = at_rev(3); - p.discourse.addressee = None; - let view = admitted(&p, &store); - let scene = vec![feat(&id_of(&inv, "in_group"), FeatValue::Flag(true))]; - assert_eq!(rules(view.clone().with_scene(&inv, scene)), ["V13"]); - assert!(view.with_scene(&inv, vec![]).unwrap().scene().is_empty()); -} - -#[test] -fn a_relation_value_is_checked_like_any_feature() { - let (store, inv, _dir) = store(); - let view = admitted(&at_rev(3), &store); - let status = status(&inv); - let intimacy = id_of(&inv, "intimacy"); - let take = |scene| view.clone().with_scene(&inv, scene); - // A label the feature does not declare. - assert_eq!( - rules(take(vec![feat(&status, FeatValue::Option(lab("lordly")))])), - ["V7"] - ); - // A value of the wrong kind. - assert_eq!( - rules(take(vec![feat(&intimacy, FeatValue::Flag(true))])), - ["V7"] - ); - // A feature declared for another slot: seed feature 1 sits in Predication. - assert_eq!( - rules(take(vec![feat( - "feature:1", - FeatValue::Option(lab("negative")) - )])), - ["V8"] - ); - // An id that is not a feature at all. - assert_eq!( - rules(take(vec![feat("concept:3", FeatValue::Flag(true))])), - ["V6"] - ); - // Twice in one list. - let twice = vec![ - feat(&intimacy, FeatValue::Grade(grade(1))), - feat(&intimacy, FeatValue::Grade(grade(2))), - ]; - assert_eq!(rules(take(twice)), ["V11"]); -} - -#[test] -fn a_view_of_a_revision_before_the_scene_vocabulary_cannot_carry_one() { - let (store, inv, _dir) = store(); - // Revision 2 predates the Scene features, so their ids do not resolve there. - let view = admitted(&at_rev(2), &store); - let scene = vec![feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(1)))]; - assert_eq!(rules(view.with_scene(&inv, scene)), ["V5"]); -} - -#[test] -fn a_stance_feature_is_not_a_scene_feature() { - let (store, inv, _dir) = store(); - let view = admitted(&at_rev(3), &store); - let anger = id_of(&inv, "anger"); - assert_eq!( - rules(view.with_scene(&inv, vec![feat(&anger, FeatValue::Grade(grade(1)))])), - ["V8"] - ); -} diff --git a/crates/weksa/tests/stores.rs b/crates/weksa/tests/stores.rs index 22ecb83..fbcce9a 100644 --- a/crates/weksa/tests/stores.rs +++ b/crates/weksa/tests/stores.rs @@ -1093,6 +1093,46 @@ fn revision_change_rules() { assert!(inv.entry(&iv("role:1")).unwrap().retired_rev.is_none()); } +#[test] +fn speaker_state_slots_are_exclusive() { + let shape = "entry shape does not match its family"; + let d = dir(); + let mut store = seeded(&d.path().join("i.cc")); + for slots in [ + &[Slot::Stance, Slot::Discourse][..], + &[Slot::Scene, Slot::Relation], + &[Slot::Discourse, Slot::Scene], + &[Slot::Scene, Slot::Scene], + &[Slot::Stance, Slot::Stance], + ] { + let rev = rev_with(vec![Change::Add(iv("feature:50"))], 2, Some(1)); + let entries = vec![InventoryEntry { + introduced_rev: 2, + ..feature("feature:50", ValueKind::Flag, slots) + }]; + assert_eq!( + refusal(store.admit_revision(rev, entries)), + malformed("feature:50", shape), + "{slots:?}" + ); + } + assert_eq!(store.inventory().unwrap().latest().unwrap().n, 1); + // One slot alone, and several content slots, are admitted. + for (n, slots) in [ + (2, &[Slot::Scene][..]), + (3, &[Slot::Stance]), + (4, &[Slot::Discourse, Slot::Predication]), + ] { + let id = format!("feature:{}", 50 + n); + let rev = rev_with(vec![Change::Add(iv(&id))], n, Some(n - 1)); + let entries = vec![InventoryEntry { + introduced_rev: n, + ..feature(&id, ValueKind::Flag, slots) + }]; + store.admit_revision(rev, entries).unwrap(); + } +} + #[test] fn a_well_formed_extension_entry_is_admitted() { let d = dir(); diff --git a/crates/weksa/tests/validate.rs b/crates/weksa/tests/validate.rs index aaa87bd..c5bc172 100644 --- a/crates/weksa/tests/validate.rs +++ b/crates/weksa/tests/validate.rs @@ -520,9 +520,6 @@ fn v11_duplicate_feature_each_slot() { refusals(&relation), vec![v11(Some(Slot::Relation), FEAT_ALL)] ); - let mut stance = good(); - stance.stance.features = vec![grade_feature(1), grade_feature(2)]; - assert_eq!(refusals(&stance), vec![v11(Some(Slot::Stance), FEAT_ALL)]); let mut felt = good(); felt.stance .features From 6ef1b7e0347600a2430ff778ad4d40bbe9a0b871 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 02:34:10 +0000 Subject: [PATCH 003/141] 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 --- crates/weksa/src/inventory.rs | 65 ++- crates/weksa/src/lib.rs | 8 +- crates/weksa/src/schema.rs | 3 + crates/weksa/src/speaker.rs | 115 +++++ crates/weksa/src/store.rs | 229 ++++++++- crates/weksa/src/validate.rs | 93 +++- crates/weksa/tests/cli.rs | 4 +- crates/weksa/tests/scene.rs | 244 +++++++++ crates/weksa/tests/speaker_state.rs | 478 ++++++++++++++++++ crates/weksa/tests/stores.rs | 7 +- docs/interlingua-standard.md | 19 + docs/targets/dao-de-jing-test-bed.md | 2 +- docs/targets/interlingua-lowering-map.md | 7 +- .../cultnet/weksa.inventory_entry.schema.json | 7 +- schemas/cultnet/weksa.scene.schema.json | 224 ++++++++ schemas/cultnet/weksa.speaker.schema.json | 171 +++++++ 16 files changed, 1638 insertions(+), 38 deletions(-) create mode 100644 crates/weksa/src/speaker.rs create mode 100644 crates/weksa/tests/scene.rs create mode 100644 crates/weksa/tests/speaker_state.rs create mode 100644 schemas/cultnet/weksa.scene.schema.json create mode 100644 schemas/cultnet/weksa.speaker.schema.json diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index d5f2e81..6c8b984 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -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) { ) } -/// 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) { +/// 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) { 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) { 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, }); diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index bb42311..e147524 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -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}; diff --git a/crates/weksa/src/schema.rs b/crates/weksa/src/schema.rs index 72c27e4..883f44a 100644 --- a/crates/weksa/src/schema.rs +++ b/crates/weksa/src/schema.rs @@ -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::(), published_for::(), published_for::(), + published_for::(), + published_for::(), ] } diff --git a/crates/weksa/src/speaker.rs b/crates/weksa/src/speaker.rs new file mode 100644 index 0000000..51367e7 --- /dev/null +++ b/crates/weksa/src/speaker.rs @@ -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:@`; 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, + #[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, +} + +/// A scene: its participants and the relations between them. Key +/// `scene:@`; 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, + #[cultcache(key = 4)] + pub relations: Vec, + #[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:@`. +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:@`. +pub fn scene_key(group: &str, n: u32) -> String { + format!("scene:{group}@{n}") +} diff --git a/crates/weksa/src/store.rs b/crates/weksa/src/store.rs index 25909c0..0263712 100644 --- a/crates/weksa/src/store.rs +++ b/crates/weksa/src/store.rs @@ -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 { + Ok(Self { + cache: open_cache(path, |c| { + c.register_entry_type::()?; + c.register_entry_type::() + })?, + }) + } + + /// 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 { + 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 { + 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 = 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> { + 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> { + 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> { + self.cache.get::(key) + } + + pub fn get_scene(&self, key: &str) -> anyhow::Result> { + self.cache.get::(key) + } + + /// Every speaker revision, by entity then revision number. + pub fn all_speakers(&self) -> anyhow::Result> { + let mut all = self.cache.get_all::()?; + 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> { + let mut all = self.cache.get_all::()?; + 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(()) +} diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index e5a917e..9641062 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -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 { + 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> { + 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 } diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 18edb84..4b439b0 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -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" ] ); } diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs new file mode 100644 index 0000000..bc64acc --- /dev/null +++ b/crates/weksa/tests/scene.rs @@ -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>) -> 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) -> 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 { + 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); +} diff --git a/crates/weksa/tests/speaker_state.rs b/crates/weksa/tests/speaker_state.rs new file mode 100644 index 0000000..864d65a --- /dev/null +++ b/crates/weksa/tests/speaker_state.rs @@ -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 { + w.state.put_speaker(s, &w.inventory, &w.entities) +} + +fn put_scene(w: &mut W, s: Scene) -> Result { + w.state.put_scene(s, &w.inventory, &w.corpus, &w.entities) +} + +fn refused(r: Result) -> StoreRefusal { + match r.unwrap_err() { + StoreError::Refused(r) => r, + other => panic!("not a store refusal: {other}"), + } +} + +fn malformed(r: Result, 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) -> 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 = w.state.all_scenes().unwrap().iter().map(|s| s.n).collect(); + assert_eq!(listed, (1..=10).collect::>()); +} + +#[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, 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"); +} diff --git a/crates/weksa/tests/stores.rs b/crates/weksa/tests/stores.rs index fbcce9a..fa51e47 100644 --- a/crates/weksa/tests/stores.rs +++ b/crates/weksa/tests/stores.rs @@ -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)); diff --git a/docs/interlingua-standard.md b/docs/interlingua-standard.md index e349a90..830a2f7 100644 --- a/docs/interlingua-standard.md +++ b/docs/interlingua-standard.md @@ -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:@`) +holds one entity's stable axes as feature values of slot `Speaker`. +`weksa.scene` (key `scene:@`) 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 diff --git a/docs/targets/dao-de-jing-test-bed.md b/docs/targets/dao-de-jing-test-bed.md index 4f4de2e..516e2e3 100644 --- a/docs/targets/dao-de-jing-test-bed.md +++ b/docs/targets/dao-de-jing-test-bed.md @@ -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`, diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index d2917de..2b1959c 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -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:@`) and `weksa.scene` (`scene:@`, 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:@`) and `weksa.scene` (`scene:@`, 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) diff --git a/schemas/cultnet/weksa.inventory_entry.schema.json b/schemas/cultnet/weksa.inventory_entry.schema.json index ba3fe5d..e23ecc0 100644 --- a/schemas/cultnet/weksa.inventory_entry.schema.json +++ b/schemas/cultnet/weksa.inventory_entry.schema.json @@ -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" } ] }, diff --git a/schemas/cultnet/weksa.scene.schema.json b/schemas/cultnet/weksa.scene.schema.json new file mode 100644 index 0000000..90e240b --- /dev/null +++ b/schemas/cultnet/weksa.scene.schema.json @@ -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:@`; 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" + ] + } + ] + } + } +} diff --git a/schemas/cultnet/weksa.speaker.schema.json b/schemas/cultnet/weksa.speaker.schema.json new file mode 100644 index 0000000..4113537 --- /dev/null +++ b/schemas/cultnet/weksa.speaker.schema.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Speaker", + "description": "The speaker's stable axes. Key `speaker:@`; 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}$" + } + } +} From 71e6058b640b049eed15d1fafd80d1dc2551d649 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 02:51:35 +0000 Subject: [PATCH 004/141] with_scene reads the item's speaker and group scene from the store; V17 addressee names a referent The view keeps its corpus item's speaker and group (LoweringInput::of takes the item; admissible is gone), and with_scene takes the SpeakerStateStore, so neither speaker nor scene comes from the caller and only admitted scenes reach a view. An addressee that names a predication is refused as V17. Pins scene numbering per group and the no-fallback case for a named addressee outside the participants; tests/scene.rs fixture now speaks as the item's speaker. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/store.rs | 5 +- crates/weksa/src/validate.rs | 126 +++---- crates/weksa/tests/display.rs | 2 + crates/weksa/tests/scene.rs | 404 ++++++++++++----------- crates/weksa/tests/speaker_state.rs | 27 +- crates/weksa/tests/validate.rs | 23 ++ docs/interlingua-standard.md | 15 +- docs/targets/interlingua-lowering-map.md | 4 +- 8 files changed, 351 insertions(+), 255 deletions(-) diff --git a/crates/weksa/src/store.rs b/crates/weksa/src/store.rs index 0263712..67f8f1a 100644 --- a/crates/weksa/src/store.rs +++ b/crates/weksa/src/store.rs @@ -21,7 +21,7 @@ use crate::inventory::{ }; use crate::packet::{EntityId, Packet}; use crate::speaker::{Annotator, MAX_PARTICIPANTS, Party, Scene, Speaker, scene_key, speaker_key}; -use crate::validate::{LoweringInput, Refusal, admissible, feature_refusals}; +use crate::validate::{LoweringInput, Refusal, feature_refusals, validate}; /// A broken store rule, named. #[derive(Clone, Debug, PartialEq, Eq)] @@ -959,7 +959,7 @@ impl PacketStore { corpus: &CorpusStore, entities: &EntityStore, ) -> Result { - let view = admissible(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?; + validate(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?; let item = corpus.resolve(&packet.provenance.corpus_item)?; if item.withdrawn { return Err(StoreRefusal::WithdrawnCorpusItem { @@ -983,6 +983,7 @@ impl PacketStore { .into()); } } + let view = LoweringInput::of(&packet, &item); let key = packet.id.to_string(); if let Some(existing) = self.get(&key)? { if existing == packet { diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index 9641062..06600e7 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -1,13 +1,14 @@ //! The validator owns the inventory and form rules a packet must meet. Every //! path that admits a packet applies them, but `validate` only judges: it //! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a -//! lowerer sees, comes from `PacketStore::admit` alone, which builds it through -//! the crate-private `admissible`. The stance is checked like every feature -//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V16: the +//! lowerer sees, comes from `PacketStore::admit` alone, which builds it from +//! the admitted packet and its corpus item through the crate-private +//! `LoweringInput::of`. The stance is checked like every feature +//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V17: the //! shape of an id or a grade is its type's parse, not a rule, so V9 is retired -//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] takes -//! a [`Scene`](crate::speaker::Scene) document, and `feature_refusals` is the -//! one feature check it and the speaker-state store share. +//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] reads the +//! scene of the view's own item group from the speaker-state store, and +//! `feature_refusals` is the one feature check it and that store share. //! //! `validate` owns form and capacity and makes no leak decision. A packet's one //! free text is its trace (`notes` and `rejected_readings`): `validate` does not @@ -27,12 +28,13 @@ use std::collections::BTreeSet; use std::fmt; +use crate::corpus::CorpusItem; use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind}; use crate::packet::{ Constraints, Context, Discourse, EntityId, Extension, FeatValue, FeatureValue, NodeId, Packet, PacketKind, Predication, ProvenanceSource, Referent, Relation, SCHEMA_VERSION, Stance, }; -use crate::speaker::Scene; +use crate::store::{SpeakerStateStore, StoreError}; /// A broken rule, named. #[derive(Clone, Debug, PartialEq, Eq)] @@ -110,8 +112,13 @@ pub enum Refusal { V15ConsentSource { source: ProvenanceSource, }, - /// V16: `with_scene` was given a speaker the scene does not list. + /// V16: `with_scene` found the item's speaker absent from its group's scene. V16SpeakerNotInScene, + /// V17: an addressee that names a predication. An act is not someone + /// spoken to, so the addressee names a referent. + V17AddresseeNotAReferent { + id: NodeId, + }, } impl Refusal { @@ -135,6 +142,7 @@ impl Refusal { } Refusal::V15ConsentSource { .. } => "V15", Refusal::V16SpeakerNotInScene => "V16", + Refusal::V17AddresseeNotAReferent { .. } => "V17", } } } @@ -161,6 +169,7 @@ impl fmt::Display for Refusal { Refusal::V14ConsentNotAPredication { .. } => "consent names no predication", Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording", Refusal::V16SpeakerNotInScene => "the speaker is not a participant of the scene", + Refusal::V17AddresseeNotAReferent { .. } => "the addressee names no referent", }; write!(f, "{}: {what}", self.rule()) } @@ -180,28 +189,6 @@ impl fmt::Display for Refusal { /// fn lower(p: &Packet, i: &Inventory) -> Result> { weksa::validate(p, i) } /// ``` pub fn validate(packet: &Packet, inventory: &Inventory) -> Result<(), Vec> { - admissible(packet, inventory).map(|_| ()) -} - -/// The one implementation of the rules. Crate-private: `PacketStore::admit` is -/// the only caller that keeps the view. -/// -/// The first block is the same call through the public name, so the second -/// fails for the private path and nothing else. -/// -/// ``` -/// use weksa::{Inventory, packet::Packet}; -/// fn judge(p: &Packet, i: &Inventory) { let _ = weksa::validate::validate(p, i); } -/// ``` -/// -/// ```compile_fail -/// use weksa::{Inventory, packet::Packet}; -/// fn lower(p: &Packet, i: &Inventory) { let _ = weksa::validate::admissible(p, i); } -/// ``` -pub(crate) fn admissible( - packet: &Packet, - inventory: &Inventory, -) -> Result> { let mut out = Vec::new(); if packet.schema_version != SCHEMA_VERSION { out.push(Refusal::V1SchemaVersion { @@ -221,6 +208,7 @@ pub(crate) fn admissible( } let nodes = check_nodes(packet, &mut out); check_refs(packet, &nodes, &mut out); + check_addressee(packet, &nodes, &mut out); check_repeats(packet, &mut out); check_consent(packet, &nodes, &mut out); if inventory.has_revision(packet.inventory_rev) { @@ -235,11 +223,7 @@ pub(crate) fn admissible( rev: packet.inventory_rev, }); } - if out.is_empty() { - Ok(LoweringInput::of(packet)) - } else { - Err(out) - } + if out.is_empty() { Ok(()) } else { Err(out) } } fn check_nodes(packet: &Packet, out: &mut Vec) -> BTreeSet { @@ -285,6 +269,17 @@ fn check_refs(packet: &Packet, nodes: &BTreeSet, out: &mut Vec) } } +/// V17: the addressee, when it names a node, names a referent. A dangling id +/// is V4's. +fn check_addressee(packet: &Packet, nodes: &BTreeSet, out: &mut Vec) { + if let Some(a) = &packet.discourse.addressee + && nodes.contains(a) + && !packet.referents.iter().any(|r| &r.id == a) + { + out.push(Refusal::V17AddresseeNotAReferent { id: a.clone() }); + } +} + /// V14 and V15: the addressee's consent is a typed fact with one origin. A /// dangling id is V4's, so V14 judges only a node that exists. fn check_consent(packet: &Packet, nodes: &BTreeSet, out: &mut Vec) { @@ -560,11 +555,17 @@ 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. +/// Scene values come only from the store: the call names neither speaker nor +/// scene. The first block is the call with the store; the second hands it a +/// scene value and a speaker and fails for that alone. /// /// ``` +/// fn pick(l: weksa::LoweringInput, i: &weksa::Inventory, s: &weksa::SpeakerStateStore) { +/// let _ = l.with_scene(i, s); +/// } +/// ``` +/// +/// ```compile_fail /// fn pick( /// l: weksa::LoweringInput, /// i: &weksa::Inventory, @@ -618,11 +619,16 @@ pub struct LoweringInput { constraints: Constraints, extensions: Vec, stance: Stance, + /// The item's speaker and group, as the corpus store holds them: the + /// only source of whose relation `with_scene` selects, and from which scene. + speaker: EntityId, + group: String, scene: Vec, } impl LoweringInput { - fn of(p: &Packet) -> Self { + /// The view of an admitted packet and the corpus item it was minted for. + pub(crate) fn of(p: &Packet, item: &CorpusItem) -> Self { Self { inventory_rev: p.inventory_rev, kind: p.kind, @@ -634,38 +640,44 @@ impl LoweringInput { constraints: p.constraints.clone(), extensions: p.extensions.clone(), stance: p.stance.clone(), + speaker: item.speaker.clone(), + group: item.group.clone(), scene: Vec::new(), } } /// 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. + /// values into a view. The speaker is the item's speaker and the scene is + /// the latest the store holds for the item's group; the caller names + /// neither. No scene for the group selects nothing; a scene that does not + /// list the item's speaker is V16. With no addressee 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> { - if !scene.participants.contains(speaker) { - return Err(vec![Refusal::V16SpeakerNotInScene]); + state: &SpeakerStateStore, + ) -> Result { + self.scene = Vec::new(); + let Some(scene) = state.scene(&self.group)? else { + return Ok(self); + }; + if !scene.participants.contains(&self.speaker) { + return Err(StoreError::Invalid(vec![Refusal::V16SpeakerNotInScene])); } - let addressee = self + let selected = 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(); + .and_then(|a| self.referents.iter().find(|r| &r.id == a)) + .map_or(&[][..], |r| { + scene.relation(&self.speaker, r.entity.as_ref()) + }); let out = feature_refusals(inventory, self.inventory_rev, selected, Slot::Scene); if !out.is_empty() { - return Err(out); + return Err(StoreError::Invalid(out)); } self.scene = selected.to_vec(); Ok(self) diff --git a/crates/weksa/tests/display.rs b/crates/weksa/tests/display.rs index 44063fa..7a1eaf0 100644 --- a/crates/weksa/tests/display.rs +++ b/crates/weksa/tests/display.rs @@ -122,6 +122,8 @@ fn refusal_display_names_the_rule_and_no_identifier() { Refusal::V15ConsentSource { source: ProvenanceSource::ImportedCorpus, }, + Refusal::V16SpeakerNotInScene, + Refusal::V17AddresseeNotAReferent { id: node() }, ]; for r in &all { assert!(r.to_string().starts_with(r.rule()), "{r:?}"); diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs index bc64acc..46885ae 100644 --- a/crates/weksa/tests/scene.rs +++ b/crates/weksa/tests/scene.rs @@ -1,244 +1,274 @@ -//! 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. +//! The speaker's relation to the addressee as a lowering input. `with_scene` +//! names neither speaker nor scene: the view knows its corpus item, so the +//! speaker is the item's speaker and the scene is the store's latest for the +//! item's group. The relation is the one from that speaker to the addressee +//! referent's entity. Typed feature values only, never wording. mod common; -use std::path::Path; - -use common::{eid, grade, iv, lab, nd}; +use common::{cast, eid, entities_in, grade, item_in, lab, nd}; use weksa::inventory::{proof_revision, seed, speaker_scene_revision}; use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet}; use weksa::speaker::{Annotator, PairRelation, Party, Scene}; use weksa::{ - CorpusStore, EntityId, EntityStore, Inventory, InventoryStore, LoweringInput, PacketStore, - Refusal, lower, + CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, NameSpan, PacketStore, + Refusal, SpeakerStateStore, StoreError, lower, }; -fn store() -> (InventoryStore, Inventory, tempfile::TempDir) { +struct W { + dir: tempfile::TempDir, + inventory: InventoryStore, + corpus: CorpusStore, + entities: EntityStore, + state: SpeakerStateStore, +} + +/// Group `g` holds `item:nibu:1`: Nibu says "Brokkr, come here." Group `h` +/// holds `item:nibu:2`, also spoken by Nibu. +fn world() -> W { let dir = tempfile::tempdir().unwrap(); - let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap(); + let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap(); for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision()] { - store.admit_revision(rev, entries).unwrap(); + inventory.admit_revision(rev, entries).unwrap(); + } + let entities = entities_in(dir.path()); + let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap(); + let mut first = item_in("item:nibu:1", "Brokkr, come here.", "g", cast()); + first.names = vec![NameSpan { + entity: eid("brokkr"), + start: 0, + end: 6, + }]; + corpus.add(first, &entities).unwrap(); + corpus + .add(item_in("item:nibu:2", "Go away.", "h", cast()), &entities) + .unwrap(); + let state = SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap(); + W { + dir, + inventory, + corpus, + entities, + state, } - let inv = store.inventory().unwrap(); - (store, inv, dir) } -fn admitted(packet: &Packet, inventory: &InventoryStore) -> LoweringInput { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let corpus = CorpusStore::open(&root.join("data/corpus/corpus.cc")).unwrap(); - let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap(); - let dir = tempfile::tempdir().unwrap(); - PacketStore::open(&dir.path().join("p.cc")) - .unwrap() - .admit(packet.clone(), inventory, &corpus, &entities) - .unwrap() +fn inv(w: &W) -> Inventory { + w.inventory.inventory().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() +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: id, value } + FeatureValue { feature, value } } -fn rules(r: Result>) -> Vec<&'static str> { - r.unwrap_err().iter().map(Refusal::rule).collect() +fn intimacy(v: u16) -> Vec { + vec![feat("intimacy", FeatValue::Grade(grade(v)))] } -fn speaker() -> EntityId { - eid("brokkr") +fn status() -> Vec { + vec![ + feat("relative_status", FeatValue::Option(lab("above"))), + feat("intimacy", FeatValue::Grade(grade(200))), + feat("in_group", FeatValue::Flag(false)), + ] } -/// 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 relation(from: &str, to: Party, features: Vec) -> PairRelation { + PairRelation { + from: eid(from), + to, + features, + } } -fn scene_with(relations: Vec) -> Scene { +/// Nibu's relations to Brokkr and to an unnamed party, and Brokkr's to Nibu. +fn scene(group: &str, n: u32, participants: &[&str], relations: Vec) -> Scene { Scene { - group: "g".into(), - n: 1, + group: group.into(), + n, inventory_rev: 3, - participants: vec![speaker(), eid("nibu")], + participants: participants.iter().map(|p| eid(p)).collect(), relations, annotator: Annotator::Operator, } } -fn selected(view: LoweringInput, inv: &Inventory, s: &Scene) -> Vec { - view.with_scene(inv, s, &speaker()) +fn standard(group: &str, n: u32) -> Scene { + scene( + group, + n, + &["nibu", "brokkr"], + vec![ + relation("nibu", Party::Named(eid("brokkr")), status()), + relation("nibu", Party::Unnamed, intimacy(7)), + relation("brokkr", Party::Named(eid("nibu")), intimacy(99)), + ], + ) +} + +fn put(w: &mut W, s: Scene) { + w.state + .put_scene(s, &w.inventory, &w.corpus, &w.entities) + .unwrap(); +} + +/// The line of `item:nibu:1` at `rev`, its addressee node `addressee` (`n1` +/// has no entity, `n3` is Brokkr), through the packet door. +fn view_of(w: &W, rev: u32, addressee: Option<&str>) -> LoweringInput { + let mut p: Packet = proof_packet(); + p.inventory_rev = rev; + p.referents[2].entity = Some(eid("brokkr")); + p.discourse.addressee = addressee.map(nd); + // A packet key holds its packet for good, so each view gets a fresh store. + let scratch = tempfile::tempdir().unwrap(); + PacketStore::open(&scratch.path().join("p.cc")) .unwrap() - .scene() - .to_vec() + .admit(p, &w.inventory, &w.corpus, &w.entities) + .unwrap() +} + +fn rules(r: Result) -> Vec<&'static str> { + match r.map(|_| ()).unwrap_err() { + StoreError::Invalid(rs) => rs.iter().map(Refusal::rule).collect(), + other => panic!("not a rule refusal: {other}"), + } } #[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); + let mut w = world(); + put(&mut w, standard("g", 1)); + let plain = view_of(&w, 3, Some("n3")); 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(); + let with = plain.clone().with_scene(&inv(&w), &w.state).unwrap(); + assert_eq!(with.scene(), status().as_slice()); assert_eq!( - lower(&with, &inv, &entities).unwrap(), - lower(&plain, &inv, &entities).unwrap() + lower(&with, &inv(&w), &w.entities).unwrap(), + lower(&plain, &inv(&w), &w.entities).unwrap() ); } #[test] fn 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() + let mut w = world(); + put(&mut w, standard("g", 1)); + let take = |w: &W, addressee| { + view_of(w, 3, addressee) + .with_scene(&inv(w), &w.state) + .unwrap() + .scene() + .to_vec() }; - assert!( - view.with_scene(&inv, &theirs, &eid("hero")) - .unwrap() - .scene() - .is_empty() + // A referent with an entity selects the relation to that entity; a + // referent without one, the relation to an unnamed party; and the + // speaker's relation, never Brokkr's to Nibu. + assert_eq!(take(&w, Some("n3")), status()); + assert_eq!(take(&w, Some("n1")), intimacy(7)); + // No addressee selects nothing. + assert!(take(&w, None).is_empty()); +} + +#[test] +fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() { + let mut w = world(); + // Brokkr is not a participant and Nibu has only an unnamed relation. + put( + &mut w, + scene( + "g", + 1, + &["nibu", "player"], + vec![relation("nibu", Party::Unnamed, intimacy(7))], + ), ); - // A replacement replaces: an empty selection clears the earlier one. - let first = admitted(&packet(3, Some("n3")), &store) - .with_scene(&inv, &s, &speaker()) + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) .unwrap(); - assert!( - first - .with_scene(&inv, &only_unnamed, &speaker()) - .unwrap() - .scene() - .is_empty() + assert!(got.scene().is_empty()); + // A participant without a relation behaves the same. + put( + &mut w, + scene( + "g", + 2, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Unnamed, intimacy(7))], + ), ); + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) + .unwrap(); + assert!(got.scene().is_empty()); +} + +#[test] +fn the_scene_is_the_items_group_and_its_latest_revision() { + let mut w = world(); + // Group h holds a different relation for the same pair. + put( + &mut w, + scene( + "h", + 1, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Named(eid("brokkr")), intimacy(1))], + ), + ); + // No scene for the item's group selects nothing, whatever other groups hold. + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) + .unwrap(); + assert!(got.scene().is_empty()); + // Revisions: the highest n is read. + put(&mut w, standard("g", 1)); + let mut second = standard("g", 2); + second.relations[0].features = intimacy(5); + put(&mut w, second); + let got = view_of(&w, 3, Some("n3")) + .with_scene(&inv(&w), &w.state) + .unwrap(); + assert_eq!(got.scene(), intimacy(5).as_slice()); + // A replacement replaces: a store without the scene clears the selection. + let empty = SpeakerStateStore::open(&w.dir.path().join("empty.cc")).unwrap(); + let cleared = got.with_scene(&inv(&w), &empty).unwrap(); + assert!(cleared.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. + let mut w = world(); + put(&mut w, standard("g", 1)); + // The scene was admitted at revision 3; a view of revision 2 predates the + // Scene features, so the selected relation's ids do not resolve there. assert_eq!( - 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())), + rules(view_of(&w, 2, Some("n3")).with_scene(&inv(&w), &w.state)), ["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); + // A scene that does not list the item's speaker: group k admitted while + // Brokkr was its only speaker, then an item of Nibu's joins it. + let mut item = item_in("item:brokkr:1", "Hello.", "k", cast()); + item.speaker = eid("brokkr"); + w.corpus.add(item, &w.entities).unwrap(); + put(&mut w, scene("k", 1, &["brokkr"], vec![])); + w.corpus + .add( + item_in("item:nibu:3", "Hello there.", "k", cast()), + &w.entities, + ) + .unwrap(); + let mut p = proof_packet(); + p.inventory_rev = 3; + p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap(); + p.referents[2].entity = Some(eid("nibu")); + let view = PacketStore::open(&w.dir.path().join("p3.cc")) + .unwrap() + .admit(p, &w.inventory, &w.corpus, &w.entities) + .unwrap(); + assert_eq!(rules(view.with_scene(&inv(&w), &w.state)), ["V16"]); } diff --git a/crates/weksa/tests/speaker_state.rs b/crates/weksa/tests/speaker_state.rs index 864d65a..dfb4c59 100644 --- a/crates/weksa/tests/speaker_state.rs +++ b/crates/weksa/tests/speaker_state.rs @@ -22,7 +22,7 @@ struct W { } /// Group `g` holds two live items (speakers nibu and brokkr) and one withdrawn -/// item (speaker hero). +/// item (speaker hero); group `g2` holds one live item of nibu's. fn world() -> W { let dir = tempfile::tempdir().unwrap(); let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap(); @@ -35,9 +35,14 @@ fn world() -> W { ("item:nibu:1", "Come here.", "nibu"), ("item:nibu:2", "Go there.", "brokkr"), ("item:nibu:3", "Stay away.", "hero"), + ("item:nibu:4", "Elsewhere.", "g2"), ] { let mut item = item_in(key, text, "g", cast()); item.speaker = eid(speaker); + if key == "item:nibu:4" { + item.group = "g2".into(); + item.speaker = eid("nibu"); + } corpus.add(item, &entities).unwrap(); } corpus.withdraw("item:nibu:3").unwrap(); @@ -219,6 +224,26 @@ fn scene_revisions_append() { assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10); let listed: Vec = w.state.all_scenes().unwrap().iter().map(|s| s.n).collect(); assert_eq!(listed, (1..=10).collect::>()); + // Numbering is per group: the first scene of another group is revision 1 + // whatever g holds, and its second is 2. + let other = |n| Scene { + group: "g2".into(), + participants: vec![eid("nibu")], + relations: vec![], + ..scene(n, 1) + }; + assert_eq!( + refused(put_scene(&mut w, other(2))), + not_next(&scene_key("g2", 2), 1, 2) + ); + assert_eq!(put_scene(&mut w, other(1)).unwrap(), AddOutcome::Added); + assert_eq!( + refused(put_scene(&mut w, other(3))), + not_next(&scene_key("g2", 3), 2, 3) + ); + assert_eq!(put_scene(&mut w, other(2)).unwrap(), AddOutcome::Added); + assert_eq!(w.state.scene("g2").unwrap().unwrap().n, 2); + assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10); } #[test] diff --git a/crates/weksa/tests/validate.rs b/crates/weksa/tests/validate.rs index c5bc172..b46cd85 100644 --- a/crates/weksa/tests/validate.rs +++ b/crates/weksa/tests/validate.rs @@ -739,3 +739,26 @@ fn a_source_that_only_read_wording_cannot_set_consent() { assert_eq!(refusals(&p), vec![Refusal::V15ConsentSource { source }]); } } + +// V17: the addressee is someone spoken to, a referent. + +#[test] +fn the_addressee_names_a_referent_not_a_predication() { + let mut p = good(); + p.discourse.addressee = Some(nd("n2")); + assert_eq!( + refusals(&p), + vec![Refusal::V17AddresseeNotAReferent { id: nd("n2") }] + ); + // A referent passes, and a dangling id is V4's alone. + p.discourse.addressee = Some(nd("n1")); + assert_eq!(refusals(&p), vec![]); + p.discourse.addressee = Some(nd("n99")); + assert_eq!( + refusals(&p), + vec![Refusal::V4DanglingRef { + id: nd("n99"), + at: "addressee" + }] + ); +} diff --git a/docs/interlingua-standard.md b/docs/interlingua-standard.md index 830a2f7..3a300fd 100644 --- a/docs/interlingua-standard.md +++ b/docs/interlingua-standard.md @@ -163,6 +163,7 @@ names the rule. - V15: an `addressee_consent` is set only by provenance `Authored` or `GameTelemetry`; a decomposition of text or imported corpus text cannot mint it. +- V17: an `addressee` names a referent, not a predication. V9 is retired and not renumbered: the form of a key or a grade is its type, and an extension key's family is V6. `PacketStore::admit` returns the lowering view of a @@ -190,7 +191,7 @@ ids. ### `discourse` -Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id, +Why the packet is said: `speech_act` is a speech-act id, `addressee` a referent's node id (V17), and `features` carry source-side nuance on content (hedge, certainty, irony and whatever the inventory grows). This is source-side communicative pressure, not target-language register. `addressee_consent` is no feature: it names the @@ -226,11 +227,13 @@ 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. +lifetime), which `admit_revision` enforces. `LoweringInput::with_scene` takes the +store and names neither speaker nor scene: the view knows its corpus item, so +the speaker is the item's speaker and the scene is the store's latest for the +item's group. The speaker must be a participant (V16), and the relation +selected is the speaker's to the entity of the addressee referent (to +`Unnamed` when that referent has no entity, to nothing when the packet has no +addressee or the scene holds no such relation). V13 is retired. ### `referents` diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 2b1959c..3cdc59e 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -399,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, 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:@`) and `weksa.scene` (`scene:@`, 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. +4a. `speaker-relation-input`: slot `Scene` and its first seed features (`relative_status`, `intimacy`, `in_group`; inventory revision 3, now `speaker_scene_revision`); `LoweringInput::with_scene` carries the speaker's relation to the addressee as typed `Scene` feature values. Since 4b it takes the `SpeakerStateStore`, reads the scene of the view's own item group for the item's speaker, and selects the relation by the addressee referent's entity (V16 when the item's speaker is not a participant; V17 refuses an addressee that names a predication; V13 is retired). The English lowerer does not render it. (PR for follow-up `chat-s3352-3352`) +4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): slots `Speaker` and `Scene` with their seed features; `weksa.speaker` (`speaker:@`) and `weksa.scene` (`scene:@`, participants and ordered-pair relations) documents with `Annotator` `Operator` and `Model { family, generator }`, validated by the packet's shared feature check, append-only per key; their stores, `weksa speaker` and `weksa scene` commands, published schemas; Model-annotated cast speakers and scenes for the committed items. Waits on 2d and 4; `pd-speaker-annotation` blocks only the public-domain annotator. Part 1 landed (branch `weksa-lowering/speaker-scene`): `speaker.rs`, `SpeakerStateStore`, slots `Speaker` and `Scene` exclusive to their own feature lists, `speaker_scene_revision()` (Speaker features 22-24), `with_scene` on the store, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c. 5. `language-ontology`: `weksa.community` from `data/cultural-ontology/` (owns follow-up `edda-referent-yaml-parse`), `weksa.ontology_entry` for en-US and ja-JP from `data/target-language-ontology/` (YAML deleted), speaker communities, deterministic `select`, the lowering view (packet without trace, speaker, scene, selected entries). Waits on 4. 6. `pd-importer`: public-domain play and fiction dialogue into the corpus with speaker, scene group, edition provenance, and Speaker and Scene annotations by the ruled annotator. Waits on 4, follow-up `gutenberg-terms` and question `pd-speaker-annotation`. 7a. `loop-context-builder`: `context.rs` (`TrainItems`, `EncoderOffer`, `Prompt`: corpus text reaches a model call only through `Prompt::mint` over live Train items or `Prompt::encode` over one offered item; instructions are `&'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) From bb152d02ce8319e03af8f6e557bb6b2619b71103 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 03:10:21 +0000 Subject: [PATCH 005/141] with_scene records what it read: SceneRead says no scene, no addressee, self-addressed, no relation or selected at a revision The view no longer collapses five cases into one empty feature list. Scene::relation returns None for an absent relation. Pins the item speaker over the first participant and V16 without an addressee. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/lib.rs | 2 +- crates/weksa/src/speaker.rs | 13 ++- crates/weksa/src/validate.rs | 86 +++++++++++++++----- crates/weksa/tests/scene.rs | 153 ++++++++++++++++++++++++++--------- 4 files changed, 192 insertions(+), 62 deletions(-) diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index e147524..f8043a3 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -38,4 +38,4 @@ pub use store::{ AddOutcome, CorpusStore, EntityBound, EntityStore, InventoryStore, PacketStore, SpeakerStateStore, StoreError, StoreRefusal, }; -pub use validate::{LoweringInput, Refusal, validate}; +pub use validate::{LoweringInput, Refusal, SceneRead, validate}; diff --git a/crates/weksa/src/speaker.rs b/crates/weksa/src/speaker.rs index 51367e7..c51a6b1 100644 --- a/crates/weksa/src/speaker.rs +++ b/crates/weksa/src/speaker.rs @@ -83,9 +83,14 @@ pub struct Scene { 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] { + /// to `Unnamed` when there is none; `None` when the scene holds no such + /// relation (a relation with no features is `Some(&[])`). A named + /// addressee the scene lacks never falls back to `Unnamed`. + pub fn relation( + &self, + speaker: &EntityId, + addressee: Option<&EntityId>, + ) -> Option<&[FeatureValue]> { self.relations .iter() .find(|r| { @@ -96,7 +101,7 @@ impl Scene { _ => false, } }) - .map_or(&[], |r| r.features.as_slice()) + .map(|r| r.features.as_slice()) } } diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index 06600e7..d8989ca 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -499,6 +499,28 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool { } } +/// What `with_scene` found for a view: the scene revision `rev` it read, or why +/// it selected no relation. Each case is distinct so a missing annotation or a +/// store at the wrong path cannot look like a scene that held nothing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SceneRead { + /// `with_scene` has not run. + Unread, + /// The store holds no scene for the item's group. + NoScene, + /// The scene was read and the packet names no addressee. + NoAddressee { rev: u32 }, + /// The addressee referent is the speaker themself. + SelfAddressed { rev: u32 }, + /// The scene holds no relation from the speaker to this addressee. + NoRelation { rev: u32 }, + /// The relation was found; its features may be none. + Selected { + rev: u32, + features: Vec, + }, +} + /// What the lowerer sees: a validated packet's meaning, with no packet id, no /// provenance (so no corpus item key, encoder or source) and no trace. Its /// fields are private and the constructor is crate-private, called only by @@ -623,7 +645,7 @@ pub struct LoweringInput { /// only source of whose relation `with_scene` selects, and from which scene. speaker: EntityId, group: String, - scene: Vec, + scene: SceneRead, } impl LoweringInput { @@ -642,44 +664,61 @@ impl LoweringInput { stance: p.stance.clone(), speaker: item.speaker.clone(), group: item.group.clone(), - scene: Vec::new(), + scene: SceneRead::Unread, } } - /// Adds the speaker's relation to the addressee, the only path for scene + /// Reads the speaker's relation to the addressee, the only path for scene /// values into a view. The speaker is the item's speaker and the scene is /// the latest the store holds for the item's group; the caller names - /// neither. No scene for the group selects nothing; a scene that does not - /// list the item's speaker is V16. With no addressee 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. + /// neither. A scene that does not list the item's speaker is V16, with or + /// without an addressee. What the read found is recorded on the view as a + /// [`SceneRead`], never an empty feature list standing for five cases: + /// no scene for the group, no addressee, an addressee who is the speaker, + /// no such relation, or the relation from the speaker to the addressee + /// referent's entity (to `Unnamed` when the referent has none), whose + /// features are checked like every feature list in slot `Scene` at this + /// view's inventory revision. A read replaces any earlier one. The English + /// lowerer ignores it. pub fn with_scene( mut self, inventory: &Inventory, state: &SpeakerStateStore, ) -> Result { - self.scene = Vec::new(); + self.scene = SceneRead::Unread; let Some(scene) = state.scene(&self.group)? else { + self.scene = SceneRead::NoScene; return Ok(self); }; + let rev = scene.n; if !scene.participants.contains(&self.speaker) { return Err(StoreError::Invalid(vec![Refusal::V16SpeakerNotInScene])); } - let selected = self - .discourse - .addressee - .as_ref() - .and_then(|a| self.referents.iter().find(|r| &r.id == a)) - .map_or(&[][..], |r| { - scene.relation(&self.speaker, r.entity.as_ref()) - }); - let out = feature_refusals(inventory, self.inventory_rev, selected, Slot::Scene); + let Some(addressee) = self.discourse.addressee.as_ref() else { + self.scene = SceneRead::NoAddressee { rev }; + return Ok(self); + }; + let entity = self + .referents + .iter() + .find(|r| &r.id == addressee) + .and_then(|r| r.entity.as_ref()); + if entity == Some(&self.speaker) { + self.scene = SceneRead::SelfAddressed { rev }; + return Ok(self); + } + let Some(features) = scene.relation(&self.speaker, entity) else { + self.scene = SceneRead::NoRelation { rev }; + return Ok(self); + }; + let out = feature_refusals(inventory, self.inventory_rev, features, Slot::Scene); if !out.is_empty() { return Err(StoreError::Invalid(out)); } - self.scene = selected.to_vec(); + self.scene = SceneRead::Selected { + rev, + features: features.to_vec(), + }; Ok(self) } @@ -716,6 +755,13 @@ impl LoweringInput { /// 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] { + match &self.scene { + SceneRead::Selected { features, .. } => features, + _ => &[], + } + } + /// What `with_scene` read; `Unread` until it ran. + pub fn scene_read(&self) -> &SceneRead { &self.scene } } diff --git a/crates/weksa/tests/scene.rs b/crates/weksa/tests/scene.rs index 46885ae..f27bc76 100644 --- a/crates/weksa/tests/scene.rs +++ b/crates/weksa/tests/scene.rs @@ -11,7 +11,7 @@ use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet}; use weksa::speaker::{Annotator, PairRelation, Party, Scene}; use weksa::{ CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, NameSpan, PacketStore, - Refusal, SpeakerStateStore, StoreError, lower, + Refusal, SceneRead, SpeakerStateStore, StoreError, lower, }; struct W { @@ -121,9 +121,15 @@ fn put(w: &mut W, s: Scene) { /// The line of `item:nibu:1` at `rev`, its addressee node `addressee` (`n1` /// has no entity, `n3` is Brokkr), through the packet door. fn view_of(w: &W, rev: u32, addressee: Option<&str>) -> LoweringInput { + view_to(w, rev, addressee, "brokkr") +} + +/// As `view_of`, with referent `n3` naming `entity` (Nibu is the item's +/// speaker, so he may be named on the line; Brokkr is named by its text). +fn view_to(w: &W, rev: u32, addressee: Option<&str>, entity: &str) -> LoweringInput { let mut p: Packet = proof_packet(); p.inventory_rev = rev; - p.referents[2].entity = Some(eid("brokkr")); + p.referents[2].entity = Some(eid(entity)); p.discourse.addressee = addressee.map(nd); // A packet key holds its packet for good, so each view gets a fresh store. let scratch = tempfile::tempdir().unwrap(); @@ -140,14 +146,27 @@ fn rules(r: Result) -> Vec<&'static str> { } } +fn read(w: &W, view: LoweringInput) -> SceneRead { + view.with_scene(&inv(w), &w.state) + .unwrap() + .scene_read() + .clone() +} + +fn selected(rev: u32, features: Vec) -> SceneRead { + SceneRead::Selected { rev, features } +} + #[test] fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { let mut w = world(); put(&mut w, standard("g", 1)); let plain = view_of(&w, 3, Some("n3")); + assert_eq!(plain.scene_read(), &SceneRead::Unread); assert!(plain.scene().is_empty()); let with = plain.clone().with_scene(&inv(&w), &w.state).unwrap(); assert_eq!(with.scene(), status().as_slice()); + assert_eq!(with.scene_read(), &selected(1, status())); assert_eq!( lower(&with, &inv(&w), &w.entities).unwrap(), lower(&plain, &inv(&w), &w.entities).unwrap() @@ -155,23 +174,54 @@ fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() { } #[test] -fn with_scene_selects_by_addressee() { +fn with_scene_says_which_of_five_things_it_found() { let mut w = world(); + // No scene for the group. + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene); put(&mut w, standard("g", 1)); - let take = |w: &W, addressee| { - view_of(w, 3, addressee) - .with_scene(&inv(w), &w.state) - .unwrap() - .scene() - .to_vec() - }; // A referent with an entity selects the relation to that entity; a // referent without one, the relation to an unnamed party; and the // speaker's relation, never Brokkr's to Nibu. - assert_eq!(take(&w, Some("n3")), status()); - assert_eq!(take(&w, Some("n1")), intimacy(7)); - // No addressee selects nothing. - assert!(take(&w, None).is_empty()); + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(1, status())); + assert_eq!( + read(&w, view_of(&w, 3, Some("n1"))), + selected(1, intimacy(7)) + ); + // The scene was read and the packet names no addressee. + assert_eq!( + read(&w, view_of(&w, 3, None)), + SceneRead::NoAddressee { rev: 1 } + ); + // The addressee is the speaker. + assert_eq!( + read(&w, view_to(&w, 3, Some("n3"), "nibu")), + SceneRead::SelfAddressed { rev: 1 } + ); + // The scene holds no relation to this addressee. + put( + &mut w, + scene( + "g", + 2, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Unnamed, intimacy(7))], + ), + ); + assert_eq!( + read(&w, view_of(&w, 3, Some("n3"))), + SceneRead::NoRelation { rev: 2 } + ); + // A relation with no features is found, not absent. + put( + &mut w, + scene( + "g", + 3, + &["nibu", "brokkr"], + vec![relation("nibu", Party::Named(eid("brokkr")), vec![])], + ), + ); + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(3, vec![])); } #[test] @@ -187,10 +237,10 @@ fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() { vec![relation("nibu", Party::Unnamed, intimacy(7))], ), ); - let got = view_of(&w, 3, Some("n3")) - .with_scene(&inv(&w), &w.state) - .unwrap(); - assert!(got.scene().is_empty()); + assert_eq!( + read(&w, view_of(&w, 3, Some("n3"))), + SceneRead::NoRelation { rev: 1 } + ); // A participant without a relation behaves the same. put( &mut w, @@ -201,10 +251,32 @@ fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() { vec![relation("nibu", Party::Unnamed, intimacy(7))], ), ); - let got = view_of(&w, 3, Some("n3")) - .with_scene(&inv(&w), &w.state) - .unwrap(); - assert!(got.scene().is_empty()); + assert_eq!( + read(&w, view_of(&w, 3, Some("n3"))), + SceneRead::NoRelation { rev: 2 } + ); +} + +#[test] +fn the_speaker_is_the_items_not_the_first_participant() { + let mut w = world(); + // Brokkr is listed first and has an unnamed relation of his own. + put( + &mut w, + scene( + "g", + 1, + &["brokkr", "nibu"], + vec![ + relation("brokkr", Party::Unnamed, intimacy(99)), + relation("nibu", Party::Unnamed, intimacy(7)), + ], + ), + ); + assert_eq!( + read(&w, view_of(&w, 3, Some("n1"))), + selected(1, intimacy(7)) + ); } #[test] @@ -220,11 +292,8 @@ fn the_scene_is_the_items_group_and_its_latest_revision() { vec![relation("nibu", Party::Named(eid("brokkr")), intimacy(1))], ), ); - // No scene for the item's group selects nothing, whatever other groups hold. - let got = view_of(&w, 3, Some("n3")) - .with_scene(&inv(&w), &w.state) - .unwrap(); - assert!(got.scene().is_empty()); + // No scene for the item's group, whatever other groups hold. + assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene); // Revisions: the highest n is read. put(&mut w, standard("g", 1)); let mut second = standard("g", 2); @@ -233,10 +302,12 @@ fn the_scene_is_the_items_group_and_its_latest_revision() { let got = view_of(&w, 3, Some("n3")) .with_scene(&inv(&w), &w.state) .unwrap(); + assert_eq!(got.scene_read(), &selected(2, intimacy(5))); assert_eq!(got.scene(), intimacy(5).as_slice()); - // A replacement replaces: a store without the scene clears the selection. + // A read replaces the earlier one: a store without the scene says so. let empty = SpeakerStateStore::open(&w.dir.path().join("empty.cc")).unwrap(); let cleared = got.with_scene(&inv(&w), &empty).unwrap(); + assert_eq!(cleared.scene_read(), &SceneRead::NoScene); assert!(cleared.scene().is_empty()); } @@ -262,13 +333,21 @@ fn with_scene_refusals() { &w.entities, ) .unwrap(); - let mut p = proof_packet(); - p.inventory_rev = 3; - p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap(); - p.referents[2].entity = Some(eid("nibu")); - let view = PacketStore::open(&w.dir.path().join("p3.cc")) - .unwrap() - .admit(p, &w.inventory, &w.corpus, &w.entities) - .unwrap(); - assert_eq!(rules(view.with_scene(&inv(&w), &w.state)), ["V16"]); + for addressee in [Some("n3"), None] { + let mut p = proof_packet(); + p.inventory_rev = 3; + p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap(); + p.referents[2].entity = Some(eid("nibu")); + p.discourse.addressee = addressee.map(nd); + let scratch = tempfile::tempdir().unwrap(); + let view = PacketStore::open(&scratch.path().join("p.cc")) + .unwrap() + .admit(p, &w.inventory, &w.corpus, &w.entities) + .unwrap(); + assert_eq!( + rules(view.with_scene(&inv(&w), &w.state)), + ["V16"], + "{addressee:?}" + ); + } } From eff94fb4afdc67d9734cd4137bc1fad2c267c201 Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 03:25:44 +0000 Subject: [PATCH 006/141] Pin LoweringInput::of's privacy with the right arity; scene() doc points to scene_read() Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/validate.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/weksa/src/validate.rs b/crates/weksa/src/validate.rs index d8989ca..a74066f 100644 --- a/crates/weksa/src/validate.rs +++ b/crates/weksa/src/validate.rs @@ -609,11 +609,13 @@ pub enum SceneRead { /// call, so the second fails for that call and nothing else. /// /// ``` -/// fn build(p: &weksa::packet::Packet) { let _ = p; } +/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) { let _ = (p, item); } /// ``` /// /// ```compile_fail -/// fn build(p: &weksa::packet::Packet) { let _ = weksa::LoweringInput::of(p); } +/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) { +/// let _ = weksa::LoweringInput::of(p, item); +/// } /// ``` /// /// A lowering view is never read from bytes: `Deserialize` would let a caller @@ -752,8 +754,9 @@ impl LoweringInput { pub fn stance(&self) -> &Stance { &self.stance } - /// The features of the scene relation from the speaker to the addressee; - /// empty until `with_scene`, or when the scene holds none. + /// The features of the scene relation from the speaker to the addressee. + /// Empty for every [`SceneRead`] but `Selected` (and for a selected + /// relation that has no features): ask [`scene_read`](Self::scene_read) which. pub fn scene(&self) -> &[FeatureValue] { match &self.scene { SceneRead::Selected { features, .. } => features, From e7b4d9a68b3337c304653d0ad46f775c1c45126d Mon Sep 17 00:00:00 2001 From: Weksa Date: Tue, 10 Nov 2026 03:45:49 +0000 Subject: [PATCH 007/141] RunStores is the one public producer of TrainItems and EncoderOffer: a run pins its corpus and entity stores by digest TrainItems::from_corpus and EncoderOffer::for_round become pub(crate). RunStores (run.rs) creates a run over three stores, records their canonical paths and FNV-1a 64 digests plus the prompt and lowerer source digests in run.cc, and reopens only from that record, refusing on drift. RunRecord and RoundRecord are published CultCache types. tests/context.rs and the context.rs doctests move to RunStores; the two mutated-clone gate cases become a unit test. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/context.rs | 85 +++- crates/weksa/src/lib.rs | 1 + crates/weksa/src/run.rs | 433 +++++++++++++++++++ crates/weksa/src/schema.rs | 3 + crates/weksa/src/score/mod.rs | 4 +- crates/weksa/src/split.rs | 13 +- crates/weksa/src/store.rs | 2 +- crates/weksa/tests/cli.rs | 2 + crates/weksa/tests/common/mod.rs | 22 + crates/weksa/tests/context.rs | 105 +++-- crates/weksa/tests/golf_loop.rs | 145 +++++++ schemas/cultnet/weksa.loop_round.schema.json | 227 ++++++++++ schemas/cultnet/weksa.loop_run.schema.json | 58 +++ 13 files changed, 1029 insertions(+), 71 deletions(-) create mode 100644 crates/weksa/src/run.rs create mode 100644 crates/weksa/tests/golf_loop.rs create mode 100644 schemas/cultnet/weksa.loop_round.schema.json create mode 100644 schemas/cultnet/weksa.loop_run.schema.json diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index a4f2749..2936d7a 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -3,8 +3,14 @@ //! [`TrainItems`] or by encoding one item an [`EncoderOffer`] holds: Train-only //! minting and per-item encoding (leak-owned-by-flow-and-scorer-3, //! loop-context-builder-2). Instruction text is [`MINT_INSTRUCTIONS`] and -//! [`ENCODE_INSTRUCTIONS`], consts in this module, and nothing else: `mint` and -//! `encode` take none, so no run-time text can become instructions. +//! [`ENCODE_INSTRUCTIONS`], consts in this module; an encode prompt also lists +//! the inventory at the run's revision. +//! +//! Item blocks are data from the run's pinned corpus, reached only through +//! `RunStores`, the one public producer of `TrainItems` and `EncoderOffer` +//! (forged-item-text-in-prompt). A forged item in a scratch store is a different +//! run with a different corpus digest, visible in its record; nothing inside a +//! process its forger controls can stop it. use std::marker::PhantomData; @@ -25,9 +31,12 @@ pub const ENCODE_INSTRUCTIONS: &str = "Encode the line below."; /// item. /// /// ``` -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # let p = |n: &str| dir.path().join(n); +/// # for n in ["c.cc", "i.cc", "e.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") }; +/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); +/// let t = run.train().unwrap(); /// assert!(t.is_empty()); /// ``` /// @@ -36,17 +45,23 @@ pub const ENCODE_INSTRUCTIONS: &str = "Encode the line below."; /// ``` /// /// ```compile_fail -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # let p = |n: &str| dir.path().join(n); +/// # for n in ["c.cc", "i.cc", "e.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") }; +/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); +/// let t = run.train().unwrap(); /// let _u = t.clone(); /// ``` /// /// ```compile_fail /// fn s(_: &T) {} -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # let p = |n: &str| dir.path().join(n); +/// # for n in ["c.cc", "i.cc", "e.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") }; +/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); +/// let t = run.train().unwrap(); /// s(&t); /// ``` pub struct TrainItems { @@ -54,7 +69,7 @@ pub struct TrainItems { } impl TrainItems { - pub fn from_corpus(corpus: &CorpusStore) -> anyhow::Result { + pub(crate) fn from_corpus(corpus: &CorpusStore) -> anyhow::Result { let items = corpus .all()? .into_iter() @@ -134,8 +149,9 @@ pub struct EncoderOffer<'cp> { } impl<'cp> EncoderOffer<'cp> { - /// A round offers Train and Validation items. - pub fn for_round(item: &CorpusItem) -> Result, OfferRefusal> { + /// A round offers Train and Validation items. `RunStores::offer` and + /// `RunStores::offers` are the public producers. + pub(crate) fn for_round(item: &CorpusItem) -> Result, OfferRefusal> { match Self::gate(item)? { Split::Test => Err(OfferRefusal::TestBeforeCheckpoint), _ => Ok(Self::of(item)), @@ -188,9 +204,12 @@ impl<'cp> EncoderOffer<'cp> { /// constructors are [`Prompt::mint`] and [`Prompt::encode`]. /// /// ``` -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # let p = |n: &str| dir.path().join(n); +/// # for n in ["c.cc", "i.cc", "e.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") }; +/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); +/// let t = run.train().unwrap(); /// let p = weksa::context::Prompt::mint(&t); /// assert_eq!(p.text(), weksa::context::MINT_INSTRUCTIONS); /// ``` @@ -198,17 +217,23 @@ impl<'cp> EncoderOffer<'cp> { /// Instructions are not a parameter: /// /// ```compile_fail -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # let p = |n: &str| dir.path().join(n); +/// # for n in ["c.cc", "i.cc", "e.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") }; +/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); +/// let t = run.train().unwrap(); /// let p = weksa::context::Prompt::mint("x", &t); /// ``` /// /// ```compile_fail /// fn s(_: &T) {} -/// let dir = tempfile::tempdir().unwrap(); -/// let store = weksa::CorpusStore::open(&dir.path().join("c.cc")).unwrap(); -/// let t = weksa::context::TrainItems::from_corpus(&store).unwrap(); +/// # let dir = tempfile::tempdir().unwrap(); +/// # let p = |n: &str| dir.path().join(n); +/// # for n in ["c.cc", "i.cc", "e.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") }; +/// # let run = weksa::run::RunStores::create(&p("run"), "t", paths, "m", "e").unwrap(); +/// let t = run.train().unwrap(); /// let p = weksa::context::Prompt::mint(&t); /// s(&p); /// ``` @@ -303,6 +328,20 @@ mod tests { assert_eq!(check(&both), Some(OfferRefusal::Withdrawn)); } + #[test] + fn round_offer_gate_precedence() { + let round = |i: &CorpusItem| EncoderOffer::for_round(i).err(); + let mut no_split = item("item:c:2", "m", &group_in(Split::Test), true); + no_split.withdrawn = true; + // Withdrawn wins over NoSplit. + assert_eq!(round(&no_split), Some(OfferRefusal::Withdrawn)); + // A withdrawn Test item is Withdrawn, not TestBeforeCheckpoint. + let mut test = item("item:c:1", "x", &group_in(Split::Test), false); + assert_eq!(round(&test), Some(OfferRefusal::TestBeforeCheckpoint)); + test.withdrawn = true; + assert_eq!(round(&test), Some(OfferRefusal::Withdrawn)); + } + #[test] fn checkpoint_offers_each_item_once() { let mut cp = Checkpoint::for_test(1); diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index f8043a3..1b8c3c8 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -12,6 +12,7 @@ pub mod inventory; pub mod lower; pub mod packet; pub mod phones; +pub mod run; pub mod schema; pub mod score; pub mod speaker; diff --git a/crates/weksa/src/run.rs b/crates/weksa/src/run.rs new file mode 100644 index 0000000..8b892d0 --- /dev/null +++ b/crates/weksa/src/run.rs @@ -0,0 +1,433 @@ +//! One golf-loop run: the stores it pinned at open, the rounds it has recorded, +//! and the one writer of both (`RunStores::create` and, for rounds, `round`). +//! +//! A run pins its corpus and entity store files by canonical path and FNV-1a 64 +//! digest of their bytes. Every round reopens them only from that record and +//! refuses on drift, and every round record carries the digests. Nothing inside +//! a process whose writer controls the code can stop an item faked into a +//! scratch store; what this makes true is that a run over a scratch store is a +//! different run with a different corpus digest, visible in its record, and a +//! later reader compares the rounds of one run only. `RunStores` is the one +//! public producer of [`TrainItems`] and [`EncoderOffer`]: the CLI paths that +//! take caller stores (`validate`, `packet proof|dao`, `lower`) are unchanged. + +use std::fmt; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use cultcache_rs::{CultCache, DatabaseEntry}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::context::{EncoderOffer, TrainItems}; +use crate::form::{is_attestation, is_slug}; +use crate::score::{CHRF_VERSION, SplitMean}; +use crate::split::{fnv1a64, hex16}; +use crate::store::{CorpusStore, EntityStore, InventoryStore, open_cache}; + +/// The digest of the prompt builder's source. Any edit to `context.rs`, +/// comments included, ends a run; code outside that file is not covered. +pub const PROMPT_SOURCE_DIGEST: u64 = fnv1a64(include_bytes!("context.rs")); + +/// The digest of the lowerer's source. Any edit to `lower.rs`, comments +/// included, ends a run; code outside that file is not covered. +pub const LOWERER_SOURCE_DIGEST: u64 = fnv1a64(include_bytes!("lower.rs")); + +/// What a run pinned at open. Key `run: