lowerer-first-cut: lower(&LoweringInput,&Inventory) by a fixed English table keyed by inventory ids; weksa lower, score, packet proof, inventory proof; inventory rev 2 and the nibu-proof-001 packet

Measured against the proof lowering: item:nibu:1 chrF 23.12 cosine 0.6313,
item:nibu:2 chrF 25.98 cosine 0.7245, item:brokkr:1 chrF 19.42 cosine 0.4516.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Weksa 2026-11-01 08:29:38 +00:00
commit 3d851bf0e1
9 changed files with 772 additions and 3 deletions

View file

@ -454,3 +454,38 @@ pub fn seed() -> (InventoryRevision, Vec<InventoryEntry>) {
entries,
)
}
/// Revision 2: the concepts and frames the `nibu-proof-001` packet names, which
/// the seed leaves open. Hand-seeded like [`seed`] until the golf loop's
/// minting round owns concepts and frames.
pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
let mut entries: Vec<InventoryEntry> = Vec::new();
for (family, glosses) in [
(
Family::Concept,
["endanger", "prevent", "player", "control", "ship_mind"].as_slice(),
),
(Family::Frame, ["hazard_warning", "intervention"].as_slice()),
] {
for (i, gloss) in glosses.iter().enumerate() {
entries.push(InventoryEntry {
id: family.id(NonZeroU32::MIN.saturating_add(i as u32)),
gloss: gloss.to_string(),
value_kind: None,
applies_to: vec![],
introduced_rev: 2,
retired_rev: None,
});
}
}
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
(
InventoryRevision {
n: 2,
parent: Some(1),
changes,
frozen: false,
},
entries,
)
}

View file

@ -7,6 +7,7 @@ pub mod corpus;
pub mod entity;
pub mod form;
pub mod inventory;
pub mod lower;
pub mod packet;
pub mod schema;
pub mod score;
@ -21,6 +22,7 @@ pub use inventory::{
Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet,
Slot, ValueKind,
};
pub use lower::{LowerError, lower};
pub use packet::{
EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION,
};

189
crates/weksa/src/lower.rs Normal file
View file

@ -0,0 +1,189 @@
//! The English lowerer: the validated lowering view of a packet to text, by a
//! fixed table keyed by inventory ids. It renders predications, referents and the
//! speech act, and nothing else: no style, no model, no corpus text. It takes a
//! [`LoweringInput`], so it cannot see the trace or the corpus item. A packet
//! carrying anything it does not render is refused, because rendering part of a
//! meaning would assert a different one (`polarity: negative` dropped says the
//! opposite).
//!
//! The table is a temporary owner. Each row speaks for an inventory entry and
//! names the gloss it speaks for, so a row cannot outlive a change of what its id
//! means. `language-ontology` moves the rows into ontology documents.
use std::fmt;
use crate::inventory::{Family, InvId, Inventory};
use crate::packet::{NodeId, Predication};
use crate::validate::LoweringInput;
/// `(inventory id, the gloss the row speaks for, surface)`.
type Row = (&'static str, &'static str, &'static str);
/// Referent concepts, as the noun phrase after "the".
const CONCEPTS: &[Row] = &[
("concept:3", "player", "player"),
("concept:4", "control", "control"),
("concept:5", "ship_mind", "ship mind"),
];
/// Frames: the clause a predication of that frame says. `{gloss}` is the filler
/// of the role whose inventory gloss it is.
const FRAMES: &[Row] = &[
(
"frame:1",
"hazard_warning",
"{agent} may endanger {patient} by touching {instrument}",
),
(
"frame:2",
"intervention",
"{agent} will prevent {patient} to protect {beneficiary}",
),
];
/// Speech acts: the opener of the first sentence.
const SPEECH_ACTS: &[Row] = &[
("speech_act:1", "assert", ""),
("speech_act:5", "warn", "Warning: "),
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LowerError {
/// The packet carries a slot the lowerer does not render.
Unrendered { what: &'static str },
/// No table row for an id the packet uses.
NoRow { id: InvId },
/// The row's gloss is not the gloss the inventory gives its id at the
/// packet's revision, or the id does not resolve there.
GlossMismatch { id: InvId, row: &'static str },
/// A frame template names a role the inventory has no live entry for.
UnknownRole { frame: InvId, role: String },
/// The predication fills no role its frame's template needs.
MissingRole { predication: NodeId, role: String },
}
impl fmt::Display for LowerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
impl std::error::Error for LowerError {}
fn row(
table: &'static [Row],
id: InvId,
inv: &Inventory,
rev: u32,
) -> Result<&'static str, LowerError> {
let (_, gloss, surface) = table
.iter()
.find(|(key, _, _)| *key == id.to_string())
.ok_or(LowerError::NoRow { id })?;
match inv.resolve(&id, rev) {
Some(e) if e.gloss == *gloss => Ok(surface),
_ => Err(LowerError::GlossMismatch { id, row: gloss }),
}
}
pub fn lower(input: &LoweringInput, inv: &Inventory) -> Result<String, LowerError> {
let rev = input.inventory_rev();
let unrendered = |what| Err(LowerError::Unrendered { what });
if input.predications().is_empty() {
return unrendered("no predication");
}
if !input.discourse().features.is_empty()
|| input.referents().iter().any(|r| !r.features.is_empty())
|| input.predications().iter().any(|p| !p.features.is_empty())
{
return unrendered("features");
}
if !input.relations().is_empty() {
return unrendered("relations");
}
if !input.extensions().is_empty() {
return unrendered("extensions");
}
let opener = match input.discourse().speech_act {
Some(act) => row(SPEECH_ACTS, act, inv, rev)?,
None => "",
};
let mut sentences = Vec::new();
for p in input.predications() {
sentences.push(clause(input, inv, p)?);
}
let mut out = String::new();
for (i, clause) in sentences.iter().enumerate() {
if i > 0 {
out.push(' ');
}
if i == 0 {
out.push_str(opener);
}
let mut chars = clause.chars();
if let Some(first) = chars.next() {
if i == 0 && !opener.is_empty() {
out.push(first);
} else {
out.extend(first.to_uppercase());
}
out.push_str(chars.as_str());
}
out.push('.');
}
Ok(out)
}
fn clause(input: &LoweringInput, inv: &Inventory, p: &Predication) -> Result<String, LowerError> {
let rev = input.inventory_rev();
let template = row(FRAMES, p.frame, inv, rev)?;
let mut said: Vec<&NodeId> = Vec::new();
let mut out = String::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let close = rest[open..].find('}').map_or(rest.len(), |c| open + c);
let name = &rest[open + 1..close];
rest = &rest[(close + 1).min(rest.len())..];
let role = inv
.entries()
.find(|e| e.family() == Family::Role && e.gloss == name && e.live_at(rev))
.ok_or_else(|| LowerError::UnknownRole {
frame: p.frame,
role: name.to_string(),
})?;
let fill =
p.roles
.iter()
.find(|r| r.role == role.id)
.ok_or_else(|| LowerError::MissingRole {
predication: p.id.clone(),
role: name.to_string(),
})?;
out.push_str(&filler(input, inv, &fill.filler, &said)?);
said.push(&fill.filler);
}
out.push_str(rest);
Ok(out)
}
/// A role filler as a phrase: the addressee is "you", a predication is "that",
/// another referent is "the" and its concept word. The addressee said twice in
/// one predication is reflexive; no other referent is, because the packet has no
/// animacy to choose "themself" or "itself" by.
fn filler(
input: &LoweringInput,
inv: &Inventory,
node: &NodeId,
said: &[&NodeId],
) -> Result<String, LowerError> {
let again = said.contains(&node);
if let Some(r) = input.referents().iter().find(|r| &r.id == node) {
if input.discourse().addressee.as_ref() == Some(node) {
return Ok(if again { "yourself" } else { "you" }.to_string());
}
let word = row(CONCEPTS, r.concept, inv, input.inventory_rev())?;
return Ok(format!("the {word}"));
}
Ok("that".to_string())
}

View file

@ -8,10 +8,12 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use unicode_normalization::UnicodeNormalization;
use weksa::corpus::{CorpusItem, ModelFamily, NameSpan, Source, Turn};
use weksa::entity::{Entity, EntityOrigin, LanguageForms};
use weksa::inventory::seed;
use weksa::inventory::{proof_revision, seed};
use weksa::packet::proof_packet;
use weksa::score::ScoreInput;
use weksa::{
CorpusStore, EntityId, EntityStore, InventoryStore, PacketStore, SplitScheme, StoreError,
validate,
CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, PacketStore, Scorer,
SplitScheme, StoreError, lower, validate,
};
#[derive(Parser)]
@ -43,6 +45,12 @@ enum Command {
Inventory(InventoryCommand),
#[command(subcommand)]
Entity(EntityCommand),
#[command(subcommand)]
Packet(PacketCommand),
/// Lower a stored packet to English text.
Lower { store: PathBuf, key: String },
/// Score output text (stdin, or --output) against corpus items, one embed call.
Score(ScoreArgs),
/// Print the split of a corpus item.
Split { item_key: String },
/// Write the published JSON Schemas.
@ -52,6 +60,26 @@ enum Command {
},
}
#[derive(Subcommand)]
enum PacketCommand {
/// Admit the nibu-proof-001 packet through `PacketStore::admit`.
Proof { store: PathBuf },
}
#[derive(Args)]
struct ScoreArgs {
/// Corpus item keys, the references.
#[arg(required = true)]
items: Vec<String>,
/// The text to score; stdin when absent.
#[arg(long)]
output: Option<PathBuf>,
#[arg(long, default_value = "http://127.0.0.1:11434")]
ollama_url: String,
#[arg(long, default_value = "qwen3-embedding:0.6b")]
model: String,
}
#[derive(Subcommand)]
enum CorpusCommand {
Add(Box<AddArgs>),
@ -68,6 +96,8 @@ enum InventoryCommand {
Dump,
/// Admit revision 1, the standard's enumerated values and the nuance features.
Seed,
/// Admit revision 2, the concepts and frames the nibu-proof-001 packet names.
Proof,
}
#[derive(Subcommand)]
@ -434,6 +464,75 @@ fn main() -> Result<()> {
.map_err(refused)?;
println!("seeded rev:1 with {count} entries");
}
Command::Inventory(InventoryCommand::Proof) => {
let (rev, entries) = proof_revision();
let count = entries.len();
InventoryStore::open(&cli.inventory)?
.admit_revision(rev, entries)
.map_err(refused)?;
println!("admitted rev:2 with {count} entries");
}
Command::Packet(PacketCommand::Proof { store }) => {
let packet = proof_packet();
let key = packet.id.to_string();
PacketStore::open(&store)?
.admit(
packet,
&InventoryStore::open(&cli.inventory)?,
&CorpusStore::open(&cli.corpus)?,
&EntityStore::open(&cli.entities)?,
)
.map_err(refused)?;
println!("admitted {key}");
}
Command::Lower { store, key } => {
let inventory = InventoryStore::open(&cli.inventory)?.inventory()?;
let packet = PacketStore::open(&store)?
.get(&key)?
.with_context(|| format!("no packet {key}"))?;
let view = validate(&packet, &inventory).map_err(|refusals| {
anyhow::anyhow!(
"{}",
refusals
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
)
})?;
println!("{}", lower(&view, &inventory)?);
}
Command::Score(args) => {
let output = match &args.output {
Some(path) => std::fs::read_to_string(path)?,
None => std::io::read_to_string(std::io::stdin())?,
};
let corpus = CorpusStore::open(&cli.corpus)?;
let references = args
.items
.iter()
.map(|key| {
Ok(corpus
.get(key)?
.with_context(|| format!("no item {key}"))?
.text)
})
.collect::<Result<Vec<_>>>()?;
let inputs: Vec<ScoreInput> = references
.iter()
.map(|reference| ScoreInput {
reference,
output: output.trim(),
})
.collect();
let scorer = Scorer::new(OllamaEmbedder::new(&args.ollama_url, &args.model));
for (key, score) in args.items.iter().zip(scorer.score(&inputs)?) {
match score.meaning {
Some(m) => println!("{key} chrf={:.2} meaning={m:.4}", score.chrf),
None => println!("{key} chrf={:.2} meaning=none", score.chrf),
}
}
}
Command::Split { item_key } => {
let item = CorpusStore::open(&cli.corpus)?
.get(&item_key)?

View file

@ -337,3 +337,85 @@ pub struct Trace {
pub notes: Vec<String>,
pub rejected_readings: Vec<String>,
}
/// The `nibu-proof-001` packet: the meaning of `examples/english-lowering/nibu-proof-001/interlingua.yaml`
/// (warn the player off the damaged control; Nibu prevents it for them) as typed
/// ids of inventory revision 2, for the corpus item `item:nibu:1`. No features:
/// nothing here is style.
pub fn proof_packet() -> Packet {
fn id(s: &str) -> InvId {
InvId::parse(s).expect("proof ids are well formed")
}
fn node(s: &str) -> NodeId {
NodeId::parse(s).expect("proof nodes are well formed")
}
let referent = |n: &str, kind: &str, concept: &str, entity: Option<&str>| Referent {
id: node(n),
kind: id(kind),
concept: id(concept),
entity: entity.map(|e| EntityId::parse(e).expect("proof entity is well formed")),
features: vec![],
};
let fill = |role: &str, filler: &str| RoleFill {
role: id(role),
filler: node(filler),
};
Packet {
id: PacketId::parse("packet:6e6962752d303031").expect("proof id"),
schema_version: SCHEMA_VERSION,
inventory_rev: 2,
kind: PacketKind::DialogueLine,
provenance: Provenance {
source: ProvenanceSource::Authored,
corpus_item: CorpusItemId::parse("item:nibu:1").expect("proof item"),
encoder: EncoderId::parse("encoder:6f70657261746f72").expect("proof encoder"),
confidence: Grade::new(900).expect("proof grade"),
},
context: Context::default(),
discourse: Discourse {
speech_act: Some(id("speech_act:5")),
addressee: Some(node("n1")),
features: vec![],
},
referents: vec![
referent("n1", "referent_kind:1", "concept:3", None),
referent("n2", "referent_kind:5", "concept:4", None),
referent("n3", "referent_kind:1", "concept:5", Some("entity:nibu")),
],
predications: vec![
Predication {
id: node("n4"),
concept: id("concept:1"),
frame: id("frame:1"),
roles: vec![
fill("role:1", "n1"),
fill("role:4", "n2"),
fill("role:2", "n1"),
],
features: vec![],
},
Predication {
id: node("n5"),
concept: id("concept:2"),
frame: id("frame:2"),
roles: vec![
fill("role:1", "n3"),
fill("role:2", "n4"),
fill("role:8", "n1"),
],
features: vec![],
},
],
relations: vec![],
constraints: Constraints {
preserve: vec![node("n1"), node("n2")],
deterministic: true,
max_latency_ms: None,
},
extensions: vec![],
trace: Trace {
notes: vec!["First proof packet for Nibu flavored-English lowering.".to_string()],
rejected_readings: vec![],
},
}
}

View file

@ -742,3 +742,128 @@ fn every_decomposed_form_and_turn_is_stored_composed() {
.unwrap();
assert_eq!(item.prior_turns[0].text, "Caf\u{e9} now");
}
/// An Ollama stand-in: every text embeds to `[1, its length]`, so identical
/// texts have cosine 1.
fn fake_embedder() -> String {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut buf = vec![0u8; 65536];
let mut n = 0;
let body = loop {
n += stream.read(&mut buf[n..]).unwrap();
let text = String::from_utf8_lossy(&buf[..n]).into_owned();
if let Some((head, body)) = text.split_once("\r\n\r\n") {
let want: usize = head
.to_lowercase()
.split("content-length: ")
.nth(1)
.unwrap()
.split("\r\n")
.next()
.unwrap()
.parse()
.unwrap();
if body.len() >= want {
break body.to_string();
}
}
};
let body: serde_json::Value = serde_json::from_str(&body).unwrap();
let vectors: Vec<Vec<f64>> = body["input"]
.as_array()
.unwrap()
.iter()
.map(|t| vec![1.0, t.as_str().unwrap().len() as f64])
.collect();
let reply = serde_json::json!({ "embeddings": vectors }).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{reply}",
reply.len()
)
.unwrap();
}
});
url
}
#[test]
fn proof_packet_lowers_and_the_lowering_scores_against_items() {
let d = tempfile::tempdir().unwrap();
cast_entities(d.path(), &["nibu"]);
for args in [&["inventory", "seed"][..], &["inventory", "proof"]] {
let run = weksa(d.path(), args);
assert!(run.status.success(), "{}", out(&run));
}
let item = weksa(
d.path(),
&[
"corpus",
"add",
"--slug",
"nibu",
"--text",
"Warning: you may endanger yourself by touching the control. The ship mind will prevent that to protect you.",
"--speaker",
"entity:nibu",
"--group",
"scene-1",
"--admitted-on",
"2026-11-01",
"--source",
"operator-written",
],
);
assert!(item.status.success(), "{}", out(&item));
let admitted = weksa(d.path(), &["packet", "proof", "./p.cc"]);
assert!(
admitted.status.success() && out(&admitted).contains("admitted packet:6e6962752d303031"),
"{}",
out(&admitted)
);
let lowered = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031"]);
assert!(lowered.status.success(), "{}", out(&lowered));
let text = String::from_utf8(lowered.stdout).unwrap();
assert!(
text.starts_with("Warning: you may endanger yourself"),
"{text}"
);
let missing = weksa(d.path(), &["lower", "./p.cc", "packet:0000000000000000"]);
assert!(!missing.status.success() && out(&missing).contains("no packet"));
std::fs::write(d.path().join("out.txt"), &text).unwrap();
let url = fake_embedder();
let scored = weksa(
d.path(),
&[
"score",
"item:nibu:1",
"--output",
"out.txt",
"--ollama-url",
&url,
],
);
assert!(scored.status.success(), "{}", out(&scored));
assert_eq!(
out(&scored).trim(),
"item:nibu:1 chrf=100.00 meaning=1.0000"
);
let unknown = weksa(
d.path(),
&[
"score",
"item:nibu:9",
"--output",
"out.txt",
"--ollama-url",
&url,
],
);
assert!(!unknown.status.success() && out(&unknown).contains("no item item:nibu:9"));
}

182
crates/weksa/tests/lower.rs Normal file
View file

@ -0,0 +1,182 @@
//! The English lowerer against the committed inventory (revision 2) and the
//! `nibu-proof-001` packet, and the rules that keep a table row from speaking
//! for a meaning it does not carry.
mod common;
use std::path::Path;
use common::{iv, nd};
use weksa::inventory::proof_revision;
use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet};
use weksa::{
CorpusStore, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput, PacketStore,
lower, validate,
};
const PROOF: &str = "Warning: you may endanger yourself by touching the control. \
The ship mind will prevent that to protect you.";
fn committed() -> (Inventory, tempfile::TempDir) {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let inv = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).unwrap();
(inv.inventory().unwrap(), tempfile::tempdir().unwrap())
}
fn view(packet: &Packet, inv: &Inventory) -> LoweringInput {
validate(packet, inv).unwrap()
}
fn lowered(packet: &Packet, inv: &Inventory) -> Result<String, LowerError> {
lower(&view(packet, inv), inv)
}
#[test]
fn the_proof_packet_is_admitted_by_the_store_and_lowers_to_its_sentences() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let (inv, dir) = committed();
let inventory = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).unwrap();
let corpus = CorpusStore::open(&root.join("data/corpus/corpus.cc")).unwrap();
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
let mut packets = PacketStore::open(&dir.path().join("p.cc")).unwrap();
packets
.admit(proof_packet(), &inventory, &corpus, &entities)
.unwrap();
let stored = packets.get("packet:6e6962752d303031").unwrap().unwrap();
assert_eq!(lowered(&stored, &inv).unwrap(), PROOF);
}
#[test]
fn only_the_addressee_is_you_and_is_reflexive() {
let (inv, _dir) = committed();
let mut p = proof_packet();
p.discourse.addressee = None;
assert_eq!(
lowered(&p, &inv).unwrap(),
"Warning: the player may endanger the player by touching the control. \
The ship mind will prevent that to protect the player."
);
}
#[test]
fn no_speech_act_means_no_opener_and_a_capital() {
let (inv, _dir) = committed();
let mut p = proof_packet();
p.discourse.speech_act = None;
assert!(lowered(&p, &inv).unwrap().starts_with("You may endanger"));
p.discourse.speech_act = Some(iv("speech_act:1"));
assert!(lowered(&p, &inv).unwrap().starts_with("You may endanger"));
}
#[test]
fn a_packet_carrying_what_the_lowerer_does_not_render_is_refused() {
let dir = tempfile::tempdir().unwrap();
let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
let (rev1, entries) = weksa::inventory::seed();
store.admit_revision(rev1, entries).unwrap();
let (rev2, entries) = proof_revision();
store.admit_revision(rev2, entries).unwrap();
let mut ext = common::entry("extension:1", 3);
ext.value_kind = Some(weksa::ValueKind::Grade);
common::admit(&mut store, 3, vec![ext]);
let inv = store.inventory().unwrap();
let discourse = |p: &mut Packet| {
p.discourse.features.push(FeatureValue {
feature: iv("feature:6"),
value: FeatValue::Grade(common::grade(300)),
})
};
let predication = |p: &mut Packet| {
p.predications[0].features.push(FeatureValue {
feature: iv("feature:1"),
value: FeatValue::Option(common::lab("negative")),
})
};
let relation = |p: &mut Packet| {
p.relations.push(weksa::packet::Relation {
relation: iv("relation:8"),
from: nd("n4"),
to: nd("n5"),
features: vec![],
})
};
let extension = |p: &mut Packet| {
p.inventory_rev = 3;
p.extensions.push(weksa::packet::Extension {
key: iv("extension:1"),
value: FeatValue::Grade(common::grade(3)),
})
};
let referent_feature = |p: &mut Packet| {
p.referents[0].features.push(FeatureValue {
feature: iv("feature:6"),
value: FeatValue::Grade(common::grade(1)),
})
};
let no_predication = |p: &mut Packet| p.predications.clear();
for (what, edit) in [
("features", &discourse as &dyn Fn(&mut Packet)),
("features", &predication),
("relations", &relation),
("extensions", &extension),
("no predication", &no_predication),
] {
let mut p = proof_packet();
edit(&mut p);
assert_eq!(
lower(&view(&p, &inv), &inv),
Err(LowerError::Unrendered { what }),
"{what}"
);
}
// The seed lets no feature sit on a referent, so that arm is refused by validate.
let mut p = proof_packet();
referent_feature(&mut p);
assert!(validate(&p, &inv).is_err());
}
#[test]
fn an_unknown_row_and_an_unfilled_role_are_refused_by_name() {
let (inv, _dir) = committed();
let mut p = proof_packet();
p.discourse.speech_act = Some(iv("speech_act:3"));
assert_eq!(
lowered(&p, &inv),
Err(LowerError::NoRow {
id: iv("speech_act:3")
})
);
let mut p = proof_packet();
p.predications[0].roles.pop();
assert_eq!(
lowered(&p, &inv),
Err(LowerError::MissingRole {
predication: nd("n4"),
role: "patient".into()
})
);
}
#[test]
fn a_row_cannot_speak_for_an_id_whose_gloss_differs() {
// Revision 2 with concept:4 glossed "hatch" in place of "control": the row
// for concept:4 says "control" and must not render it.
let dir = tempfile::tempdir().unwrap();
let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
let (rev1, entries) = weksa::inventory::seed();
store.admit_revision(rev1, entries).unwrap();
let (rev2, mut entries) = proof_revision();
for e in &mut entries {
if e.id == iv("concept:4") {
e.gloss = "hatch".into();
}
}
store.admit_revision(rev2, entries).unwrap();
let inv = store.inventory().unwrap();
assert_eq!(
lowered(&proof_packet(), &inv),
Err(LowerError::GlossMismatch {
id: iv("concept:4"),
row: "control"
})
);
}