From 5eb58c31001bf40a8e15dd71389f85d86a3e016d Mon Sep 17 00:00:00 2001 From: Weksa Date: Sat, 7 Nov 2026 02:10:10 +0000 Subject: [PATCH 1/6] Dao De Jing test bed: PublicDomain provenance fields, Legge slice, first golf-loop number Source::PublicDomain now requires licence, jurisdiction, translator and edition_year; the store refuses an item missing any. data/daodejing holds five Legge items (Gutenberg 216, no Gutenberg matter stored), inventory revision 3, five packets with stance, and the lowerer rows to render them. docs/targets/dao-de-jing-test-bed.md reports the first closeness number and what the scorer cannot see. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/corpus.rs | 9 + crates/weksa/src/daodejing.rs | 325 ++++++++++++++++++ crates/weksa/src/lib.rs | 1 + crates/weksa/src/lower.rs | 47 +++ crates/weksa/src/main.rs | 55 ++- crates/weksa/src/store.rs | 12 + crates/weksa/tests/cli.rs | 88 +++++ crates/weksa/tests/daodejing.rs | 71 ++++ crates/weksa/tests/stores.rs | 77 ++++- data/daodejing/corpus.cc | 1 + data/daodejing/entity.cc | 1 + data/daodejing/inventory.cc | Bin 0 -> 10361 bytes data/daodejing/packets.cc | 1 + docs/targets/dao-de-jing-test-bed.md | 116 +++++++ schemas/cultnet/weksa.corpus_item.schema.json | 22 +- 15 files changed, 823 insertions(+), 3 deletions(-) create mode 100644 crates/weksa/src/daodejing.rs create mode 100644 crates/weksa/tests/daodejing.rs create mode 100644 data/daodejing/corpus.cc create mode 100644 data/daodejing/entity.cc create mode 100644 data/daodejing/inventory.cc create mode 100644 data/daodejing/packets.cc create mode 100644 docs/targets/dao-de-jing-test-bed.md diff --git a/crates/weksa/src/corpus.rs b/crates/weksa/src/corpus.rs index d67cc60..0beaa17 100644 --- a/crates/weksa/src/corpus.rs +++ b/crates/weksa/src/corpus.rs @@ -29,10 +29,19 @@ pub enum Source { OperatorApproved { note: String, }, + /// A public-domain work, with everything a reader needs to check the claim: + /// the edition's licence and the jurisdiction the public-domain status is + /// asserted in, who translated it (the translation is the text's copyright, + /// not the original's) and when the edition appeared. A work missing any + /// is refused at admission. PublicDomain { work: String, author: String, edition_url: String, + licence: String, + jurisdiction: String, + translator: String, + edition_year: u16, }, /// Labelled model output; never in the test split, never judged by its own family. /// `family` is `Unrecorded` when nothing records one. `generator` is the diff --git a/crates/weksa/src/daodejing.rs b/crates/weksa/src/daodejing.rs new file mode 100644 index 0000000..acd45f3 --- /dev/null +++ b/crates/weksa/src/daodejing.rs @@ -0,0 +1,325 @@ +//! The Dao De Jing test bed: inventory revision 3 of the `data/daodejing` +//! stores and the packets for the slice of James Legge's chapters in its corpus. +//! It follows `proof_packet`'s precedent: a hand-authored packet the stores +//! admit through the same doors as any other. +//! +//! The ids carry the leak labels the golf loop needs. A frame minted while +//! reading a Train chapter is generic evidence; one minted for a Validation or +//! Test chapter was written with that chapter's reference in view, so a score +//! on it measures the lowerer table's reach and not the interlingua's. + +use std::num::NonZeroU32; + +use crate::corpus::CorpusItemId; +use crate::inventory::{Change, Family, InvId, InventoryEntry, InventoryRevision}; +use crate::packet::{ + Constraints, Context, Discourse, EncoderId, FeatValue, FeatureValue, Grade, NodeId, Packet, + PacketId, PacketKind, Predication, Provenance, ProvenanceSource, Referent, RoleFill, + SCHEMA_VERSION, Stance, Trace, +}; + +const CONCEPTS: &[&str] = &[ + "sage", + "mind", + "belly", + "will", + "bone", + "empty", + "fill", + "weaken", + "strengthen", + "heaven", + "earth", + "is_long_enduring", + "continues_long", + "highest_excellence", + "water", + "true_words", + "seem_paradoxical", + "space_between_heaven_and_earth", + "bellows", + "compare", + "resemble", +]; + +/// Frame glosses, from `frame:3`. 3 is minted from a Train chapter, 4 and 5 +/// from Validation chapters, 6 and 7 from Test chapters. +const FRAMES: &[&str] = &[ + "act", + "intransitive", + "likeness", + "seems_paradoxical", + "may_be_compared", +]; + +/// Revision 3 of the Dao De Jing stores, after the seed and the proof revision. +pub fn revision() -> (InventoryRevision, Vec) { + let mut entries = Vec::new(); + for (family, glosses) in [(Family::Concept, CONCEPTS), (Family::Frame, FRAMES)] { + let first = if family == Family::Concept { 6 } else { 3 }; + for (i, gloss) in glosses.iter().enumerate() { + entries.push(InventoryEntry { + id: family.id(NonZeroU32::new(first + i as u32).expect("nonzero")), + gloss: (*gloss).to_string(), + value_kind: None, + applies_to: vec![], + introduced_rev: 3, + retired_rev: None, + }); + } + } + let changes = entries.iter().map(|e| Change::Add(e.id)).collect(); + ( + InventoryRevision { + n: 3, + parent: Some(2), + changes, + frozen: false, + }, + entries, + ) +} + +fn concept(gloss: &str) -> &'static str { + let at = CONCEPTS + .iter() + .position(|c| *c == gloss) + .expect("known concept"); + Box::leak(format!("concept:{}", 6 + at).into_boxed_str()) +} + +fn frame(gloss: &str) -> &'static str { + let at = FRAMES + .iter() + .position(|c| *c == gloss) + .expect("known frame"); + Box::leak(format!("frame:{}", 3 + at).into_boxed_str()) +} + +fn hex8(ascii: &str) -> String { + assert_eq!(ascii.len(), 8); + ascii.bytes().map(|b| format!("{b:02x}")).collect() +} + +struct Spec { + /// Eight ASCII characters; the packet id is their hex. + id: &'static str, + item: &'static str, + speech_act: &'static str, + /// `(node, referent kind id, concept gloss)`. + referents: &'static [(&'static str, &'static str, &'static str)], + /// `(node, concept gloss, frame gloss, [(role id, filler node)])`. + predications: &'static [( + &'static str, + &'static str, + &'static str, + &'static [(&'static str, &'static str)], + )], + /// The author's reading of the speaker's felt state: `(feature gloss, permille)`. + stance: &'static [(&'static str, u16)], + note: &'static str, +} + +const AGENT: &str = "role:1"; +const PATIENT: &str = "role:2"; +const EXPERIENCER: &str = "role:3"; +const GOAL: &str = "role:6"; +const PERSON: &str = "referent_kind:1"; +const ORGANISM: &str = "referent_kind:3"; +const PLACE: &str = "referent_kind:4"; +const ABSTRACT: &str = "referent_kind:11"; + +/// Feature ids of the seed: restraint is the tenth stance feature. +fn stance_feature(gloss: &str) -> InvId { + let n = [ + "anger", + "fear", + "sadness", + "joy", + "disgust", + "surprise", + "contempt", + "affection", + "shame", + "restraint", + ] + .iter() + .position(|g| *g == gloss) + .expect("stance feature"); + // Features before the stance block: polarity, tense, aspect, modality, + // evidentiality, hedge, certainty, irony. + InvId::parse(&format!("feature:{}", 9 + n)).expect("feature id") +} + +const SPECS: &[Spec] = &[ + // Chapter 3 section 2. Train. + Spec { + id: "dao-0302", + item: "item:daodejing:1", + speech_act: "speech_act:1", + referents: &[ + ("n1", PERSON, "sage"), + ("n2", ABSTRACT, "mind"), + ("n3", ORGANISM, "belly"), + ("n4", ABSTRACT, "will"), + ("n5", ORGANISM, "bone"), + ], + predications: &[ + ("n6", "empty", "act", &[(AGENT, "n1"), (PATIENT, "n2")]), + ("n7", "fill", "act", &[(AGENT, "n1"), (PATIENT, "n3")]), + ("n8", "weaken", "act", &[(AGENT, "n1"), (PATIENT, "n4")]), + ("n9", "strengthen", "act", &[(AGENT, "n1"), (PATIENT, "n5")]), + ], + stance: &[("restraint", 800)], + note: "Four acts of one agent; 'in the exercise of his government' and 'their' are not carried.", + }, + // Chapter 7 section 1, first sentence. Validation. + Spec { + id: "dao-0701", + item: "item:daodejing:2", + speech_act: "speech_act:1", + referents: &[("n1", PLACE, "heaven"), ("n2", PLACE, "earth")], + predications: &[ + ( + "n3", + "is_long_enduring", + "intransitive", + &[(EXPERIENCER, "n1")], + ), + ( + "n4", + "continues_long", + "intransitive", + &[(EXPERIENCER, "n2")], + ), + ], + stance: &[("restraint", 800)], + note: "Heaven and earth as places; whether they are entities is a ruling the ontology owes.", + }, + // Chapter 8 section 1, first sentence. Validation. + Spec { + id: "dao-0801", + item: "item:daodejing:3", + speech_act: "speech_act:1", + referents: &[ + ("n1", ABSTRACT, "highest_excellence"), + ("n2", ABSTRACT, "water"), + ], + predications: &[( + "n3", + "resemble", + "likeness", + &[(EXPERIENCER, "n1"), (GOAL, "n2")], + )], + stance: &[("restraint", 800), ("affection", 300)], + note: "The metaphor is a plain likeness predication; the tenor and vehicle are not marked.", + }, + // Chapter 78 section 4. Test. + Spec { + id: "dao-7804", + item: "item:daodejing:4", + speech_act: "speech_act:1", + referents: &[("n1", ABSTRACT, "true_words")], + predications: &[( + "n2", + "seem_paradoxical", + "seems_paradoxical", + &[(EXPERIENCER, "n1")], + )], + stance: &[("restraint", 800)], + note: "The whole paradox is in the referent's concept and the frame; the packet holds no more than the surface does.", + }, + // Chapter 5 section 2, first sentence. Test. + Spec { + id: "dao-0502", + item: "item:daodejing:5", + speech_act: "speech_act:2", + referents: &[ + ("n1", PLACE, "space_between_heaven_and_earth"), + ("n2", ARTIFACT, "bellows"), + ], + predications: &[( + "n3", + "compare", + "may_be_compared", + &[(EXPERIENCER, "n1"), (GOAL, "n2")], + )], + stance: &[("restraint", 800)], + note: "A rhetorical question; the speech act is ask and the negated 'may not' is dropped.", + }, +]; + +const ARTIFACT: &str = "referent_kind:5"; + +/// The packets of the slice, each minted for its corpus item. +pub fn packets() -> Vec { + SPECS.iter().map(build).collect() +} + +fn build(spec: &Spec) -> Packet { + let id = |s: &str| InvId::parse(s).expect("ids are well formed"); + let node = |s: &str| NodeId::parse(s).expect("nodes are well formed"); + Packet { + id: PacketId::parse(&format!("packet:{}", hex8(spec.id))).expect("packet id"), + schema_version: SCHEMA_VERSION, + inventory_rev: 3, + kind: PacketKind::CorpusExample, + provenance: Provenance { + source: ProvenanceSource::Authored, + corpus_item: CorpusItemId::parse(spec.item).expect("item key"), + encoder: EncoderId::parse(&format!("encoder:{}", hex8("claude-s"))).expect("encoder"), + confidence: Grade::new(600).expect("grade"), + }, + context: Context::default(), + discourse: Discourse { + speech_act: Some(id(spec.speech_act)), + addressee: None, + features: vec![], + }, + referents: spec + .referents + .iter() + .map(|(n, kind, gloss)| Referent { + id: node(n), + kind: id(kind), + concept: id(concept(gloss)), + entity: None, + features: vec![], + }) + .collect(), + predications: spec + .predications + .iter() + .map(|(n, c, f, roles)| Predication { + id: node(n), + concept: id(concept(c)), + frame: id(frame(f)), + roles: roles + .iter() + .map(|(role, filler)| RoleFill { + role: id(role), + filler: node(filler), + }) + .collect(), + features: vec![], + }) + .collect(), + relations: vec![], + constraints: Constraints::default(), + extensions: vec![], + trace: Trace { + notes: vec![spec.note.to_string()], + rejected_readings: vec![], + }, + stance: Stance { + features: spec + .stance + .iter() + .map(|(g, v)| FeatureValue { + feature: stance_feature(g), + value: FeatValue::Grade(Grade::new(*v).expect("grade")), + }) + .collect(), + }, + } +} diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index 0bfdb8c..0fdb1b0 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -4,6 +4,7 @@ //! (chrF, meaning cosine, leak diagnostics). pub mod corpus; +pub mod daodejing; pub mod entity; pub mod form; pub mod inventory; diff --git a/crates/weksa/src/lower.rs b/crates/weksa/src/lower.rs index d6fcdfb..1084ca8 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -26,6 +26,33 @@ const CONCEPTS: &[Row] = &[ ("concept:3", "player", "player"), ("concept:4", "control", "control"), ("concept:5", "ship_mind", "ship mind"), + // The Dao De Jing test bed (`daodejing.rs`). Nouns, then the verbs a + // `{verb}` frame speaks. + ("concept:6", "sage", "sage"), + ("concept:7", "mind", "minds"), + ("concept:8", "belly", "bellies"), + ("concept:9", "will", "wills"), + ("concept:10", "bone", "bones"), + ("concept:11", "empty", "empties"), + ("concept:12", "fill", "fills"), + ("concept:13", "weaken", "weakens"), + ("concept:14", "strengthen", "strengthens"), + ("concept:15", "heaven", "heaven"), + ("concept:16", "earth", "earth"), + ("concept:17", "is_long_enduring", "is long-enduring"), + ("concept:18", "continues_long", "continues long"), + ("concept:19", "highest_excellence", "highest excellence"), + ("concept:20", "water", "water"), + ("concept:21", "true_words", "words that are strictly true"), + ("concept:22", "seem_paradoxical", "seem paradoxical"), + ( + "concept:23", + "space_between_heaven_and_earth", + "space between heaven and earth", + ), + ("concept:24", "bellows", "bellows"), + ("concept:25", "compare", "compare"), + ("concept:26", "resemble", "resemble"), ]; /// Frames: the clause a predication of that frame says. `{gloss}` is the filler @@ -41,11 +68,27 @@ const FRAMES: &[Row] = &[ "intervention", "{agent} will prevent {patient} to protect {beneficiary}", ), + // The Dao De Jing test bed. Frame 3 was written from a Train chapter, 4 and + // 5 from Validation chapters, 6 and 7 from Test chapters. + ("frame:3", "act", "{agent} {verb} {patient}"), + ("frame:4", "intransitive", "{experiencer} {verb}"), + ("frame:5", "likeness", "{experiencer} is like {goal}"), + ( + "frame:6", + "seems_paradoxical", + "{experiencer} seem paradoxical", + ), + ( + "frame:7", + "may_be_compared", + "{experiencer} may be compared to {goal}", + ), ]; /// Speech acts: the opener of the first sentence. const SPEECH_ACTS: &[Row] = &[ ("speech_act:1", "assert", ""), + ("speech_act:2", "ask", ""), ("speech_act:5", "warn", "Warning: "), ]; @@ -156,6 +199,10 @@ fn clause(input: &LoweringInput, inv: &Inventory, p: &Predication) -> Result, #[arg(long)] edition_url: Option, + /// The edition's licence, for `public-domain`. + #[arg(long)] + licence: Option, + /// Where the public-domain status is asserted, for `public-domain`. + #[arg(long)] + jurisdiction: Option, + #[arg(long)] + translator: Option, + #[arg(long)] + edition_year: Option, #[arg(long = "model-family", value_enum)] family: Option, /// The generator string the fixture or run wrote, verbatim. @@ -265,7 +279,15 @@ impl AddArgs { SourceKind::CastAuthored => &["persona", "fixture", "author"], SourceKind::OperatorWritten => &[], SourceKind::OperatorApproved => &["note"], - SourceKind::PublicDomain => &["work", "author", "edition-url"], + SourceKind::PublicDomain => &[ + "work", + "author", + "edition-url", + "licence", + "jurisdiction", + "translator", + "edition-year", + ], SourceKind::ModelWritten => &["model-family", "generator", "prompt-digest"], }; let given = [ @@ -275,6 +297,10 @@ impl AddArgs { ("note", self.note.is_some()), ("work", self.work.is_some()), ("edition-url", self.edition_url.is_some()), + ("licence", self.licence.is_some()), + ("jurisdiction", self.jurisdiction.is_some()), + ("translator", self.translator.is_some()), + ("edition-year", self.edition_year.is_some()), ("model-family", self.family.is_some()), ("generator", self.generator.is_some()), ("prompt-digest", self.prompt_digest.is_some()), @@ -301,6 +327,12 @@ impl AddArgs { work: get(&self.work, "work")?, author: get(&self.author, "author")?, edition_url: get(&self.edition_url, "edition-url")?, + licence: get(&self.licence, "licence")?, + jurisdiction: get(&self.jurisdiction, "jurisdiction")?, + translator: get(&self.translator, "translator")?, + edition_year: self + .edition_year + .context("this source needs --edition-year")?, }, SourceKind::ModelWritten => Source::ModelWritten { family: self @@ -472,6 +504,27 @@ fn main() -> Result<()> { .map_err(refused)?; println!("admitted rev:2 with {count} entries"); } + Command::Inventory(InventoryCommand::Dao) => { + let (rev, entries) = weksa::daodejing::revision(); + let count = entries.len(); + InventoryStore::open(&cli.inventory)? + .admit_revision(rev, entries) + .map_err(refused)?; + println!("admitted rev:3 with {count} entries"); + } + Command::Packet(PacketCommand::Dao { store }) => { + let mut packets = PacketStore::open(&store)?; + let inventory = InventoryStore::open(&cli.inventory)?; + let corpus = CorpusStore::open(&cli.corpus)?; + let entities = EntityStore::open(&cli.entities)?; + for packet in weksa::daodejing::packets() { + let key = packet.id.to_string(); + packets + .admit(packet, &inventory, &corpus, &entities) + .map_err(refused)?; + println!("admitted {key}"); + } + } Command::Packet(PacketCommand::Proof { store }) => { let packet = proof_packet(); let key = packet.id.to_string(); diff --git a/crates/weksa/src/store.rs b/crates/weksa/src/store.rs index 1caa6ac..6032db6 100644 --- a/crates/weksa/src/store.rs +++ b/crates/weksa/src/store.rs @@ -402,6 +402,18 @@ fn empty_attestation(source: &Source) -> Option<&'static str> { Source::PublicDomain { edition_url, .. } if empty(edition_url) => { Some("a public-domain source names its edition url") } + Source::PublicDomain { licence, .. } if empty(licence) => { + Some("a public-domain source names its licence") + } + Source::PublicDomain { jurisdiction, .. } if empty(jurisdiction) => { + Some("a public-domain source names its jurisdiction") + } + Source::PublicDomain { translator, .. } if empty(translator) => { + Some("a public-domain source names its translator") + } + Source::PublicDomain { + edition_year: 0, .. + } => Some("a public-domain source names its edition year"), Source::ModelWritten { generator, .. } if empty_some(generator) => { Some("a model-written source names a non-empty generator") } diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index e05b24a..c9be4fb 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -210,6 +210,14 @@ fn model_written_and_public_domain_sources_name_their_provenance() { "a", "--edition-url", "https://example.org/w", + "--licence", + "Public domain", + "--jurisdiction", + "US", + "--translator", + "T. Translator", + "--edition-year", + "1891", ]); assert!(pd.status.success(), "{}", out(&pd)); let bad_turn = run(&[ @@ -566,6 +574,14 @@ fn source_flags_must_match_the_source() { "a", "--edition-url", "u", + "--licence", + "l", + "--jurisdiction", + "j", + "--translator", + "t", + "--edition-year", + "1891", "--note", "n", ], @@ -618,6 +634,78 @@ fn source_flags_must_match_the_source() { &["--work", "w", "--edition-url", "u"], "--author", ), + ( + "public-domain", + &[ + "--work", + "w", + "--author", + "a", + "--edition-url", + "u", + "--jurisdiction", + "j", + "--translator", + "t", + "--edition-year", + "1891", + ], + "--licence", + ), + ( + "public-domain", + &[ + "--work", + "w", + "--author", + "a", + "--edition-url", + "u", + "--licence", + "l", + "--translator", + "t", + "--edition-year", + "1891", + ], + "--jurisdiction", + ), + ( + "public-domain", + &[ + "--work", + "w", + "--author", + "a", + "--edition-url", + "u", + "--licence", + "l", + "--jurisdiction", + "j", + "--edition-year", + "1891", + ], + "--translator", + ), + ( + "public-domain", + &[ + "--work", + "w", + "--author", + "a", + "--edition-url", + "u", + "--licence", + "l", + "--jurisdiction", + "j", + "--translator", + "t", + ], + "--edition-year", + ), // Empty provenance strings. ( "model-written", diff --git a/crates/weksa/tests/daodejing.rs b/crates/weksa/tests/daodejing.rs new file mode 100644 index 0000000..8138ebd --- /dev/null +++ b/crates/weksa/tests/daodejing.rs @@ -0,0 +1,71 @@ +//! The Dao De Jing test bed (`data/daodejing`): its Legge items carry full +//! public-domain provenance, and its packets are admitted and lower to the +//! English the first golf-loop run scored (`docs/targets/dao-de-jing-test-bed.md`). + +use std::path::Path; + +use weksa::daodejing::packets; +use weksa::{CorpusStore, EntityStore, InventoryStore, PacketStore, Source, lower}; + +fn root() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/daodejing") +} + +#[test] +fn every_legge_item_names_licence_jurisdiction_translator_and_edition_year() { + let items = CorpusStore::open(&root().join("corpus.cc")) + .unwrap() + .all() + .unwrap(); + assert_eq!(items.len(), 5); + for item in items { + let Source::PublicDomain { + licence, + jurisdiction, + translator, + edition_year, + .. + } = item.source + else { + panic!("{} is not a public-domain item", item.id); + }; + assert!( + !licence.is_empty() && !jurisdiction.is_empty(), + "{}", + item.id + ); + assert_eq!((translator.as_str(), edition_year), ("James Legge", 1891)); + } +} + +#[test] +fn the_slice_packets_are_admitted_and_lower_to_the_scored_english() { + let inventory = InventoryStore::open(&root().join("inventory.cc")).unwrap(); + let corpus = CorpusStore::open(&root().join("corpus.cc")).unwrap(); + let entities = EntityStore::open(&root().join("entity.cc")).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut store = PacketStore::open(&dir.path().join("p.cc")).unwrap(); + let inv = inventory.inventory().unwrap(); + let lowered: Vec = packets() + .into_iter() + .map(|p| { + let view = store.admit(p, &inventory, &corpus, &entities).unwrap(); + lower(&view, &inv).unwrap() + }) + .collect(); + assert_eq!( + lowered, + [ + "The sage empties the minds. The sage fills the bellies. The sage weakens the wills. The sage strengthens the bones.", + "The heaven is long-enduring. The earth continues long.", + "The highest excellence is like the water.", + "The words that are strictly true seem paradoxical.", + "The space between heaven and earth may be compared to the bellows.", + ] + ); + // The committed packet store holds exactly what the module builds. + let stored = PacketStore::open(&root().join("packets.cc")).unwrap(); + for p in packets() { + assert_eq!(stored.get(&p.id.to_string()).unwrap(), Some(p)); + } +} diff --git a/crates/weksa/tests/stores.rs b/crates/weksa/tests/stores.rs index 07cbe0c..2a9889c 100644 --- a/crates/weksa/tests/stores.rs +++ b/crates/weksa/tests/stores.rs @@ -371,7 +371,7 @@ fn an_attestation_holds_something_readable() { let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap(); let s = |x: &str| x.to_string(); type Build = fn(String) -> Source; - let builders: [(Build, &'static str); 9] = [ + let builders: [(Build, &'static str); 12] = [ ( |v| Source::CastAuthored { persona: v, @@ -405,6 +405,10 @@ fn an_attestation_holds_something_readable() { work: v, author: "a".into(), edition_url: "u".into(), + licence: "l".into(), + jurisdiction: "j".into(), + translator: "t".into(), + edition_year: 1891, }, "a public-domain source names its work", ), @@ -413,6 +417,10 @@ fn an_attestation_holds_something_readable() { work: "w".into(), author: v, edition_url: "u".into(), + licence: "l".into(), + jurisdiction: "j".into(), + translator: "t".into(), + edition_year: 1891, }, "a public-domain source names its author", ), @@ -421,9 +429,49 @@ fn an_attestation_holds_something_readable() { work: "w".into(), author: "a".into(), edition_url: v, + licence: "l".into(), + jurisdiction: "j".into(), + translator: "t".into(), + edition_year: 1891, }, "a public-domain source names its edition url", ), + ( + |v| Source::PublicDomain { + work: "w".into(), + author: "a".into(), + edition_url: "u".into(), + licence: v, + jurisdiction: "j".into(), + translator: "t".into(), + edition_year: 1891, + }, + "a public-domain source names its licence", + ), + ( + |v| Source::PublicDomain { + work: "w".into(), + author: "a".into(), + edition_url: "u".into(), + licence: "l".into(), + jurisdiction: v, + translator: "t".into(), + edition_year: 1891, + }, + "a public-domain source names its jurisdiction", + ), + ( + |v| Source::PublicDomain { + work: "w".into(), + author: "a".into(), + edition_url: "u".into(), + licence: "l".into(), + jurisdiction: "j".into(), + translator: v, + edition_year: 1891, + }, + "a public-domain source names its translator", + ), ( |v| Source::ModelWritten { family: ModelFamily::Unrecorded, @@ -528,6 +576,10 @@ fn an_item_source_attests_with_non_empty_strings() { work: s(work), author: s(author), edition_url: s(url), + licence: s("l"), + jurisdiction: s("j"), + translator: s("t"), + edition_year: 1891, }; let cases: [(Source, &'static str); 9] = [ ( @@ -1292,3 +1344,26 @@ fn seed_reseeded() { replay.add(i, &entities).unwrap(); } } + +#[test] +fn a_public_domain_source_without_an_edition_year_is_refused() { + let d = dir(); + let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap(); + let key = "item:pd:1"; + let source = |edition_year| Source::PublicDomain { + work: "w".into(), + author: "a".into(), + edition_url: "u".into(), + licence: "l".into(), + jurisdiction: "j".into(), + translator: "t".into(), + edition_year, + }; + assert_eq!( + refusal(store.add(item_in(key, "line", "scene-1", source(0)), &ents(&d))), + malformed(key, "a public-domain source names its edition year") + ); + store + .add(item_in(key, "line", "scene-1", source(1891)), &ents(&d)) + .unwrap(); +} diff --git a/data/daodejing/corpus.cc b/data/daodejing/corpus.cc new file mode 100644 index 0000000..45bcda5 --- /dev/null +++ b/data/daodejing/corpus.cc @@ -0,0 +1 @@ +“²cultcache.store.v1‘—±weksa.corpus_item±weksa.corpus_item´weksa.corpus_item.v1±weksa.corpus_itemÙV{"schemaName":"weksa.corpus_item","schemaVersion":"weksa.corpus_item.v1","members":[]}‘±weksa.corpus_item�•”°item:daodejing:1±weksa.corpus_item´2026-11-07T02:09:00ZÅ4›°item:daodejing:1Ù’Therefore the sage, in the exercise of his government, empties their minds, fills their bellies, weakens their wills, and strengthens their bones.¥en-US¬entity:laozi³daodejing-legge-ch3��¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù�Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ�”°item:daodejing:2±weksa.corpus_item´2026-11-07T02:09:00ZÅÓ›°item:daodejing:2Ù1Heaven is long-enduring and earth continues long.¥en-US¬entity:laozi³daodejing-legge-ch7��¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù�Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ�”°item:daodejing:3±weksa.corpus_item´2026-11-07T02:09:00ZÅÑ›°item:daodejing:3Ù/The highest excellence is like (that of) water.¥en-US¬entity:laozi³daodejing-legge-ch8��¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù�Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ�”°item:daodejing:4±weksa.corpus_item´2026-11-07T02:09:00ZÅ×›°item:daodejing:4Ù4Words that are strictly true seem to be paradoxical.¥en-US¬entity:laozi´daodejing-legge-ch78��¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù�Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ�”°item:daodejing:5±weksa.corpus_item´2026-11-07T02:09:00ZÅæ›°item:daodejing:5ÙDMay not the space between heaven and earth be compared to a bellows?¥en-US¬entity:laozi³daodejing-legge-ch5��¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù�Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ� \ No newline at end of file diff --git a/data/daodejing/entity.cc b/data/daodejing/entity.cc new file mode 100644 index 0000000..c11c6df --- /dev/null +++ b/data/daodejing/entity.cc @@ -0,0 +1 @@ +“²cultcache.store.v1‘—¬weksa.entity¬weksa.entity¯weksa.entity.v1¬weksa.entityÙL{"schemaName":"weksa.entity","schemaVersion":"weksa.entity.v1","members":[]}‘¬weksa.entity�‘”¬entity:laozi¬weksa.entity´2026-11-07T02:09:00ZÄb“¬entity:laozi‘’¥en-US‘¥Laozi�¬PublicDomain‘Ù4The Tao Teh King, or the Tao and its Characteristics \ No newline at end of file diff --git a/data/daodejing/inventory.cc b/data/daodejing/inventory.cc new file mode 100644 index 0000000000000000000000000000000000000000..0d2377ff42d9d08028c301dc25757a2c0443d6ed GIT binary patch literal 10361 zcmbVSTaOz_70%@sENo_y*@0|^%M+Wm+t(QhAtc1U0n#EKKw!$ z+!1}qgR#{a(HPUaj-XnsZh=J*(Az z{l(p-9=D~TN5UaxuO8oewYXGxwQBcP1KwF016Tbhq^};|HqlV@4h*zS~;m|_vVvz3}$lXsMmt#MiQenWtJ;mUD z$G|=PY~&9|l*c{#$fZF*p#U>%Ra25i6&b%t)M8wpoh~ zsuqp(bn2Heo6_DaVjj0JQByYJ?>H|xrQ_a&Fycjze3w|o>7a`9eH|yK@gF`RF75Sc zJfk%1i8APs(CbxXu!AbvyM~?SrVm*V&1{#TD%J#SZQN)K4zYTugDTm32E+E2Q4T9- zVC5;wwFf#+8|z6x=5obQS&wp{k@ZcOE^Im?2s|u63J&GIdzxHp$4CC8H}*rX2#9ju z+ZwRF;$&!f4%O}qtjXc3JlfN3}UCNS-vjr29(58MsZt; zA1(E`?Ia2a^W*u)o1af&*m&`$pyGb)U(jtxha~;+>t84LYi#zi)7F@5o?4-8A#Afkzej zi2>h9_y~?X*Fd9c-elh0K`W0L@x!uXQ~{zg@dI7BP7>3QQ&qK#)r9ak-IQ?Hl&lBS zv@L97e@p`?1-pQic~rjsUYl>M37NV6PAJRbbU|PEV=Y`wSTqDyJZ&f=()Z&j z2{!WiUS6nFcQvo(b{u&mF#FkZP2AMxZW4jZVxI(QgX2CdvAdAEz>Ki@#-rSa9=D77 zfe%fOdV8s%>f}RBt@SrJ+IoV4$#1dtz_J@}{cu1TaDW}aBw|2eLpYy~uc3k}s+y+K zPDa!l%A|j~`-#upXv#RX+PumVRCP7AozBjsEKUnR_RKf1g(|oAwXJUAvnZUeej1SB z8<;^Yz7MpS(C7+=h#8Hmuya1HhD|I~ zS=`UD+J8?^H@tMbkrPJMz_)VH&ZlEChht4o9ur`n3;W4W8HG}Z*!0d}>x)eqd-NKb zDNln8`=^%{GHQQzyeth*YUxKc0Nk3g3)3gF~Yf`AJe`jfrPKGR+PO5-W z>HlpTxUr4ckc2)TS7D=SqC~oxz)3B=o+x_*RU9SKtu0&v`GY;4Mit8g+ac{MKjbm2 z>7vb)sUyqA3S?(P-6)8LaB?a`qgINNySoXbNL=Y|)Y@=tyX15YH{WnQpHaSKG{{a( z*djLuUb&I1YMC8Z8WdIEM@w_$7WRti`T(sHd}};G750INSzkr7u!M)IE&bcUe<1h-!*{ooQ5T1zh)Sb(Sq9T|X)o%m{0hD5~(EV#d3YEHt60CWdF02fWe}x&MQudn?tF`8EypxNE@DwUp-;y}()i6r$ z{FT2^DSAhubhcSRX2%kRgv!p`;oYk~?EMG6>%$2?qxAwVC8#^=d{{SD%$}CX{R|IXs4N^3Qi^399ROTHPn6_5w zxweFe%E7w@#5Qd3DJ{<&m5@cqP7;quSdI`XGmi@_yE_86>(MZGX963pv;F_1Q2Dw3 z%iI55+=A&gX6)lTdXjx1^%YwVHJ+5W64TWH6C|!Vw#1fmm!-PF%MHCESef&*VG8>rbciuHG+w$ z5q#8*qwuK`1rsF-8cGx#lqd)&QCL@^Fs($(u86krnJRONmSr}o&sZ`Wl`NR)_XRaA z3vOB#tzUu0dw+54|Wm0jbDj~2IXKFheP&yCWh`9BRwm*%2$X)a2a=Axi2 zZ^8tS6dXygfg||k&BfyOt=Bh>iX3IEaXTh_&F9o$KvN${2Ys)BlyX`. +- Source: Project Gutenberg eBook 216, "The Tao Teh King, or the Tao and its + Characteristics", Laozi, tr. James Legge (Sacred Books of the East 39, 1891). + Gutenberg's licence (1.E.2) lets a work derived from texts not protected by US + copyright be used with no fee provided every Project Gutenberg reference is + removed from the work. The items hold only Legge's words, one numbered section + or sentence each; no Gutenberg header, licence text or trademark is stored, + and the eBook file itself is not committed. `edition_url` names the page the + text was retrieved from, as a citation of provenance and not as part of any + item's text. The legal-root question of whether a citation counts as a + "reference" is not ruled; if Gutenberg says it does, the field moves to the + Sacred Books of the East print edition. +- `Source::PublicDomain` now carries `licence`, `jurisdiction`, `translator` and + `edition_year`, and the store refuses an item missing any of them (an empty + string, or year 0). + +## The slice + +Corpus groups are chapters, so the derived split spreads the slice: + +| item | Legge | split | +|---|---|---| +| `item:daodejing:1` | ch. 3 s. 2 | Train | +| `item:daodejing:2` | ch. 7 s. 1, first sentence | Validation | +| `item:daodejing:3` | ch. 8 s. 1, first sentence | Validation | +| `item:daodejing:4` | ch. 78 s. 4 | Test | +| `item:daodejing:5` | ch. 5 s. 2, first sentence | Test | + +Each packet states neutral facts (referents, predications, a speech act) and a +stance (restraint 800 on all; affection 300 on the water likeness). The stance is +my reading of the sage's voice, not data. + +## First closeness number + +Scored with `weksa score` (chrF, and meaning as cosine under `qwen3-embedding:0.6b` +through the local Ollama), one lowered output against its own item. + +| item | split | chrF | meaning | lowered | +|---|---|---|---|---| +| 1 | Train | 44.18 | 0.831 | The sage empties the minds. The sage fills the bellies. The sage weakens the wills. The sage strengthens the bones. | +| 2 | Validation | 82.12 | 0.976 | The heaven is long-enduring. The earth continues long. | +| 3 | Validation | 74.99 | 0.974 | The highest excellence is like the water. | +| 4 | Test | 82.50 | 0.949 | The words that are strictly true seem paradoxical. | +| 5 | Test | 75.99 | 0.894 | The space between heaven and earth may be compared to the bellows. | + +Mean over the five: chrF 71.96, meaning 0.925. Mismatched baseline (each output +against the other four references, 20 pairs): chrF 14.1, meaning 0.458. So the +scorer separates the right reference from a wrong one by a wide margin; that is +all it shows here. + +**Do not read 72 as a measure of the interlingua.** The five outputs are the +ceiling of an author who saw the reference: + +- One author wrote the packets, the inventory entries and the lowerer rows with + Legge's sentence open. Frame 3 (`act`) was written from the Train item. Frames + 4 and 5 came from Validation items and frames 6 and 7 from Test items, each + written while reading that item. The Train item, the only one lowered by a + frame minted from Train, scored lowest (chrF 44.18). The scores rise with + how bespoke the frame is, the reverse of generalisation. The Validation and + Test numbers measure the lowerer table, not what the interlingua can carry. +- n = 5, one translation as reference, one embedding model, no variance. + +## What the scorer cannot see + +- **Stance.** The stance features are in the packets and the lowerer does not + render them, so the number is blind to the packet's voice entirely. It would + not change if every stance were deleted. +- **Dropped meaning.** Item 5 is a negated rhetorical question ("May not ... be + compared"); the lowering is a plain statement with no question mark and no + "not". Item 1 loses "Therefore", "in the exercise of his government" and "their". + Item 4 loses "to be". chrF scores the overlap and cannot say that the speech + act changed. The embedding cosine of 0.894 for item 5 is not much lower than + for the statement-for-statement items. +- **Table defects it forgives.** "The heaven" and "the water" are wrong English + (the lowerer prefixes "the" to every referent and has no proper-noun, + mass-noun or entity rendering). chrF takes a small penalty only. +- **Register and meaning of the Chinese.** The packets were read off Legge's + English, not the Chinese, so this is a round trip English to interlingua to + English. It measures how well the packet kept Legge's wording, not whether Legge + or the packet is right about the Dao. The term "Tao" never appears in the slice. + The multi-reading term modelling Meta described is untested. +- **Leak.** `weksa score` scores the whole output against the whole reference + with no mask, as the leak ruling says. Nothing here enforces that the + author did not use the reference; only the labels above say so. + +## What the run showed the interlingua needs + +- Negation and question force: `polarity` is refused by the lowerer ("features") + and `ask` has no punctuation row, so two of Laozi's habitual moves, the negated + claim and the rhetorical question, cannot be lowered at all. +- A frame per sentence shape is the lowerer's cost model today: each new shape + is one more table row and one more inventory frame. The Dao's aphorisms are + mostly copular and paradoxical, which suggests the next frames to mint + (attribution, contrast, negated identity) before more chapters are added. diff --git a/schemas/cultnet/weksa.corpus_item.schema.json b/schemas/cultnet/weksa.corpus_item.schema.json index 7b613be..c82c4da 100644 --- a/schemas/cultnet/weksa.corpus_item.schema.json +++ b/schemas/cultnet/weksa.corpus_item.schema.json @@ -170,6 +170,7 @@ ] }, { + "description": "A public-domain work, with everything a reader needs to check the claim:\nthe edition's licence and the jurisdiction the public-domain status is\nasserted in, who translated it (the translation is the text's copyright,\nnot the original's) and when the edition appeared. A work missing any\nis refused at admission.", "type": "object", "properties": { "PublicDomain": { @@ -181,6 +182,21 @@ "edition_url": { "type": "string" }, + "edition_year": { + "type": "integer", + "format": "uint16", + "maximum": 65535, + "minimum": 0 + }, + "jurisdiction": { + "type": "string" + }, + "licence": { + "type": "string" + }, + "translator": { + "type": "string" + }, "work": { "type": "string" } @@ -188,7 +204,11 @@ "required": [ "work", "author", - "edition_url" + "edition_url", + "licence", + "jurisdiction", + "translator", + "edition_year" ] } }, From a5c6392b4e928c675d8a4a7b50d68eecf1fb2660 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 01:02:20 +0000 Subject: [PATCH 2/6] Fixture: the phones line for Nibu's name (n, i primary, b, u) The expected `weksa lower --phones` output for the proof packet, committed before the code that must produce it. Co-Authored-By: Claude Sonnet 5.5 --- data/phonology/fixtures/nibu-en-US.phones.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 data/phonology/fixtures/nibu-en-US.phones.json diff --git a/data/phonology/fixtures/nibu-en-US.phones.json b/data/phonology/fixtures/nibu-en-US.phones.json new file mode 100644 index 0000000..45e2e11 --- /dev/null +++ b/data/phonology/fixtures/nibu-en-US.phones.json @@ -0,0 +1 @@ +{"language":"en-US","source":"ˈnibu","phones":[{"ipa":"n","stress":"none"},{"ipa":"i","stress":"primary"},{"ipa":"b","stress":"none"},{"ipa":"u","stress":"none"}]} From 5508000d213b29988f6930c67b1e606571bfbb69 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 01:04:03 +0000 Subject: [PATCH 3/6] Phone inventory, segmenter and `weksa lower --phones` data/phonology/en-US.phones.tsv lists the phones (tense/lax a column); the segmenter splits ruled IPA into {ipa, stress} by longest inventory match and refuses a symbol it does not list (LowerError::UnknownPhone) or a stress mark with no vowel (DanglingStress). No durations, pitch or acoustics. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/lib.rs | 4 +- crates/weksa/src/lower.rs | 53 ++++++++++- crates/weksa/src/main.rs | 13 ++- crates/weksa/src/phones.rs | 152 ++++++++++++++++++++++++++++++++ crates/weksa/tests/cli.rs | 12 +++ crates/weksa/tests/lower.rs | 139 ++++++++++++++++++++++++++++- data/phonology/en-US.phones.tsv | 46 ++++++++++ 7 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 crates/weksa/src/phones.rs create mode 100644 data/phonology/en-US.phones.tsv diff --git a/crates/weksa/src/lib.rs b/crates/weksa/src/lib.rs index 3f6b43c..a0ef6c6 100644 --- a/crates/weksa/src/lib.rs +++ b/crates/weksa/src/lib.rs @@ -9,6 +9,7 @@ pub mod form; pub mod inventory; pub mod lower; pub mod packet; +pub mod phones; pub mod schema; pub mod score; pub mod split; @@ -22,7 +23,8 @@ pub use inventory::{ Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet, Slot, ValueKind, }; -pub use lower::{EntityForms, LowerError, TARGET_LANGUAGE, lower, pronounce}; +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, }; diff --git a/crates/weksa/src/lower.rs b/crates/weksa/src/lower.rs index 43a8752..51f5a1f 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -23,9 +23,12 @@ use std::fmt; +use serde::Serialize; + use crate::entity::Entity; use crate::inventory::{Family, InvId, Inventory}; use crate::packet::{EntityId, NodeId, Predication}; +use crate::phones::{Phone, PhoneInventory, SegmentError}; use crate::store::EntityStore; use crate::validate::LoweringInput; @@ -95,6 +98,13 @@ pub enum LowerError { NoPronunciation { entity: EntityId }, /// IPA was requested for a packet that cites no entity. NoEntityReferent, + /// Phones were requested for an entity whose ruled IPA holds a symbol the + /// language's phone inventory does not list (or the language has no + /// inventory). The lowerer does not guess a phone. + UnknownPhone { entity: EntityId, at: usize, text: String }, + /// Phones were requested for an entity whose ruled IPA has a stress mark + /// with no vowel after it. + DanglingStress { entity: EntityId, at: usize }, /// The entity port failed to read. EntityRead { entity: EntityId, why: String }, } @@ -194,13 +204,54 @@ pub fn pronounce( input: &LoweringInput, entities: &dyn EntityForms, ) -> Result, LowerError> { + Ok(ruled(input, entities)?.into_iter().map(|(_, ipa)| ipa).collect()) +} + +/// One entity's ruled pronunciation as the phones line a sound consumer reads: +/// the language tag, the stored IPA it came from, and the segmented phones. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PhoneLine { + pub language: &'static str, + pub source: String, + pub phones: Vec, +} + +/// The phones line of every entity a packet cites, in [`pronounce`] order, each +/// segmented by [`TARGET_LANGUAGE`]'s phone inventory. A symbol the inventory +/// does not list refuses the whole call. +pub fn phones( + input: &LoweringInput, + entities: &dyn EntityForms, +) -> Result, LowerError> { + let inventory = PhoneInventory::for_language(TARGET_LANGUAGE); + ruled(input, entities)? + .into_iter() + .map(|(entity, source)| { + let unknown = |at, text| LowerError::UnknownPhone { entity: entity.clone(), at, text }; + let inventory = inventory.ok_or_else(|| unknown(0, source.clone()))?; + let phones = inventory.segment(&source).map_err(|e| match e { + SegmentError::UnknownPhone { at, text } => unknown(at, text), + SegmentError::DanglingStress { at } => { + LowerError::DanglingStress { entity: entity.clone(), at } + } + })?; + Ok(PhoneLine { language: TARGET_LANGUAGE, source, phones }) + }) + .collect() +} + +/// `(entity, ruled IPA)` for each entity referent, in referent order. +fn ruled( + input: &LoweringInput, + entities: &dyn EntityForms, +) -> Result, LowerError> { let mut out = Vec::new(); for id in input.referents().iter().filter_map(|r| r.entity.as_ref()) { let entity = entity(entities, id)?; let p = entity .pronunciation_in(TARGET_LANGUAGE) .ok_or_else(|| LowerError::NoPronunciation { entity: id.clone() })?; - out.push(p.ipa.clone()); + out.push((id.clone(), p.ipa.clone())); } if out.is_empty() { return Err(LowerError::NoEntityReferent); diff --git a/crates/weksa/src/main.rs b/crates/weksa/src/main.rs index 5518823..bc61c7e 100644 --- a/crates/weksa/src/main.rs +++ b/crates/weksa/src/main.rs @@ -53,8 +53,13 @@ enum Command { key: String, /// Print the ruled IPA of each entity the packet cites, one per line, /// instead of the text; refused for an entity with no ruled pronunciation. - #[arg(long)] + #[arg(long, conflicts_with = "phones")] ipa: bool, + /// Print the phones line of each entity the packet cites, one JSON + /// object per line ({language, source, phones: [{ipa, stress}]}), + /// instead of the text; refused for a phone the inventory lacks. + #[arg(long)] + phones: bool, }, /// Score output text (stdin, or --output) against corpus items, one embed call. Score(ScoreArgs), @@ -527,7 +532,7 @@ fn main() -> Result<()> { .map_err(refused)?; println!("admitted {key}"); } - Command::Lower { store, key, ipa } => { + Command::Lower { store, key, ipa, phones } => { let inventory_store = InventoryStore::open(&cli.inventory)?; let inventory = inventory_store.inventory()?; let mut packets = PacketStore::open(&store)?; @@ -549,6 +554,10 @@ fn main() -> Result<()> { for line in pronounce(&view, &entities)? { println!("{line}"); } + } else if phones { + for line in weksa::phones(&view, &entities)? { + println!("{}", serde_json::to_string(&line)?); + } } else { println!("{}", lower(&view, &inventory, &entities)?); } diff --git a/crates/weksa/src/phones.rs b/crates/weksa/src/phones.rs new file mode 100644 index 0000000..1477812 --- /dev/null +++ b/crates/weksa/src/phones.rs @@ -0,0 +1,152 @@ +//! The phone inventory and the segmenter: a ruled IPA string to an ordered list of +//! phones with stress, for the consumer that turns phones into sound. The +//! inventory is data (`data/phonology/.phones.tsv`); this module reads +//! it and owns one rule, that every symbol of the string is a phone the inventory +//! lists. A symbol it does not list is refused: no nearest phone, no spelling +//! rule, no guess. Tense/lax is a column of the inventory, so /i/ and /ɪ/ are two +//! phones here, not one phone and a flag on an entity. +//! +//! Stress marks are not phones. `ˈ` (primary) and `ËŒ` (secondary) put their +//! stress on the next vowel phone, the syllable's nucleus; every other phone is +//! `none`. A mark with no vowel after it is refused. Durations, pitch and +//! acoustics are the consumer's, never read or written here. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use serde::Serialize; + +const EN_US: &str = include_str!("../../../data/phonology/en-US.phones.tsv"); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Stress { + None, + Primary, + Secondary, +} + +/// One phone of a segmented string: its IPA symbol as the inventory spells it +/// and its stress. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Phone { + pub ipa: String, + pub stress: Stress, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tenseness { + Tense, + Lax, + Reduced, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Class { + Consonant, + Vowel(Tenseness), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SegmentError { + /// The symbol (or the text from it on) matches no inventory phone. + UnknownPhone { at: usize, text: String }, + /// A stress mark (`at` is its byte offset) that finds no vowel before the + /// string ends or another mark comes. + DanglingStress { at: usize }, +} + +pub struct PhoneInventory { + phones: BTreeMap, + longest: usize, +} + +impl PhoneInventory { + /// The inventory for a language tag, if one is committed. + pub fn for_language(language: &str) -> Option<&'static PhoneInventory> { + static EN_US_INV: OnceLock = OnceLock::new(); + match language { + "en-US" => Some(EN_US_INV.get_or_init(|| { + PhoneInventory::parse(EN_US).expect("the committed en-US phone inventory parses") + })), + _ => None, + } + } + + /// Parse the tab-separated inventory. Malformed lines are errors, as are + /// repeated symbols. + pub fn parse(text: &str) -> Result { + let mut phones = BTreeMap::new(); + for (n, line) in text.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let cols: Vec<&str> = line.split('\t').collect(); + let [ipa, class, tenseness, gloss] = cols[..] else { + return Err(format!("line {}: want 4 tab-separated columns", n + 1)); + }; + let class = match (class, tenseness) { + ("consonant", "-") => Class::Consonant, + ("vowel", "tense") => Class::Vowel(Tenseness::Tense), + ("vowel", "lax") => Class::Vowel(Tenseness::Lax), + ("vowel", "reduced") => Class::Vowel(Tenseness::Reduced), + _ => return Err(format!("line {}: bad class/tenseness {class}/{tenseness}", n + 1)), + }; + if ipa.is_empty() || gloss.is_empty() || ipa.chars().any(|c| matches!(c, 'ˈ' | 'ËŒ')) { + return Err(format!("line {}: empty or stress-marked entry", n + 1)); + } + if phones.insert(ipa.to_string(), class).is_some() { + return Err(format!("line {}: {ipa} repeats", n + 1)); + } + } + let longest = phones.keys().map(|k| k.chars().count()).max().unwrap_or(0); + Ok(Self { phones, longest }) + } + + pub fn class(&self, ipa: &str) -> Option<&Class> { + self.phones.get(ipa) + } + + /// Segment `ipa` into phones: longest inventory match at each position. + pub fn segment(&self, ipa: &str) -> Result, SegmentError> { + let chars: Vec<(usize, char)> = ipa.char_indices().collect(); + let mut out: Vec = Vec::new(); + let mut pending: Option<(Stress, usize)> = None; + let mut i = 0; + while i < chars.len() { + let at = chars[i].0; + let mark = match chars[i].1 { + 'ˈ' => Some(Stress::Primary), + 'ËŒ' => Some(Stress::Secondary), + _ => None, + }; + if let Some(m) = mark { + if let Some((_, was)) = pending { + return Err(SegmentError::DanglingStress { at: was }); + } + pending = Some((m, at)); + i += 1; + continue; + } + let hit = (1..=self.longest.min(chars.len() - i)).rev().find_map(|len| { + let end = chars.get(i + len).map_or(ipa.len(), |c| c.0); + let s = &ipa[at..end]; + self.phones.get(s).map(|c| (len, s, c)) + }); + let Some((len, s, class)) = hit else { + return Err(SegmentError::UnknownPhone { at, text: ipa[at..].to_string() }); + }; + let stress = if matches!(class, Class::Vowel(_)) { + pending.take().map_or(Stress::None, |(m, _)| m) + } else { + Stress::None + }; + out.push(Phone { ipa: s.to_string(), stress }); + i += len; + } + if let Some((_, at)) = pending { + return Err(SegmentError::DanglingStress { at }); + } + Ok(out) + } +} diff --git a/crates/weksa/tests/cli.rs b/crates/weksa/tests/cli.rs index 72ad140..b2e5b58 100644 --- a/crates/weksa/tests/cli.rs +++ b/crates/weksa/tests/cli.rs @@ -876,6 +876,18 @@ fn proof_packet_lowers_and_the_lowering_scores_against_items() { let ipa = weksa(d.path(), &args); assert!(ipa.status.success(), "{}", out(&ipa)); assert_eq!(String::from_utf8(ipa.stdout).unwrap(), "ˈnibu\n"); + // The phones line is the committed fixture, byte for byte, and is a different + // output from --ipa, so the two flags cannot be combined. + let phones = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031", "--phones"]); + assert!(phones.status.success(), "{}", out(&phones)); + let fixture = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data/phonology/fixtures/nibu-en-US.phones.json"), + ) + .unwrap(); + assert_eq!(String::from_utf8(phones.stdout).unwrap(), fixture); + let both = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031", "--phones", "--ipa"]); + assert!(!both.status.success(), "{}", out(&both)); let again = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031"]); assert_eq!(String::from_utf8(again.stdout).unwrap(), text); diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index 4f1691c..342b25d 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -11,7 +11,7 @@ use weksa::inventory::proof_revision; use weksa::packet::{EntityId, FeatValue, FeatureValue, Packet, proof_packet}; use weksa::{ CorpusStore, EntityForms, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput, - PacketStore, Pronunciation, lower, pronounce, validate, + PacketStore, PhoneInventory, PhoneLine, Pronunciation, Stress, lower, pronounce, validate, }; const PROOF: &str = "Warning: you may endanger yourself by touching the control. \ @@ -434,3 +434,140 @@ fn ipa_needs_an_entity_the_port_holds_and_a_packet_that_cites_one() { Err(LowerError::NoEntityReferent) ); } + +// ---- the phones line: the phone inventory and the segmenter ---- + +fn phones_of(ipa: &str) -> Result, LowerError> { + let mut n = nibu(&[("en-US", &["Nibu"])]); + n.forms[0].pronunciation = Some(ruled(ipa, &[])); + let mut e = Entities::default(); + e.held.push(n); + let (inv, _dir) = committed(); + weksa::phones(&view(&proof_packet(), &inv), &e) +} + +fn seg(ipa: &str) -> Vec<(String, Stress)> { + let inv = PhoneInventory::for_language("en-US").unwrap(); + inv.segment(ipa) + .unwrap() + .into_iter() + .map(|p| (p.ipa, p.stress)) + .collect() +} + +fn pairs(v: &[(&str, Stress)]) -> Vec<(String, Stress)> { + v.iter().map(|(i, s)| ((*i).to_string(), *s)).collect() +} + +#[test] +fn nibus_committed_ruling_lowers_to_the_committed_phones_fixture() { + let (inv, _dir) = committed(); + let lines = weksa::phones(&view(&proof_packet(), &inv), &committed_entities()).unwrap(); + let fixture = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data/phonology/fixtures/nibu-en-US.phones.json"), + ) + .unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(serde_json::to_string(&lines[0]).unwrap(), fixture.trim_end()); +} + +#[test] +fn tense_i_is_a_phone_and_the_rejected_lax_form_is_a_different_one() { + use weksa::phones::{Class, Tenseness}; + let inv = PhoneInventory::for_language("en-US").unwrap(); + assert_eq!(inv.class("i"), Some(&Class::Vowel(Tenseness::Tense))); + assert_eq!(inv.class("ɪ"), Some(&Class::Vowel(Tenseness::Lax))); + assert_eq!(inv.class("u"), Some(&Class::Vowel(Tenseness::Tense))); + let ruled_form = seg("ˈnibu"); + let lax = seg("ˈnɪbu"); + assert_ne!(ruled_form, lax); + assert_eq!(lax[1], ("ɪ".to_string(), Stress::Primary)); + // The committed seed rejects the lax form, and it is not what the seed emits. + let seed = committed_entities(); + let nibu = seed.get(&EntityId::parse("entity:nibu").unwrap()).unwrap().unwrap(); + let p = nibu.pronunciation_in("en-US").unwrap(); + assert_eq!(p.rejected, ["ˈnɪbu"]); + assert_eq!(seg(&p.ipa), ruled_form); + assert!(p.rejected.iter().all(|r| seg(r) != seg(&p.ipa))); +} + +#[test] +fn stress_lands_on_the_vowel_after_the_mark_and_nowhere_else() { + use Stress::{None as N, Primary as P, Secondary as S}; + assert_eq!(seg("ˈnibu"), pairs(&[("n", N), ("i", P), ("b", N), ("u", N)])); + assert_eq!(seg("nˈibu"), seg("ˈnibu")); + assert_eq!(seg("bˈunËŒi"), pairs(&[("b", N), ("u", P), ("n", N), ("i", S)])); + assert_eq!(seg("nibu"), pairs(&[("n", N), ("i", N), ("b", N), ("u", N)])); +} + +#[test] +fn multi_character_phones_are_one_phone_by_longest_match() { + use Stress::{None as N, Primary as P}; + assert_eq!(seg("ˈtʃeɪ"), pairs(&[("tʃ", N), ("eɪ", P)])); + assert_eq!(seg("dÊ’aÊŠ"), pairs(&[("dÊ’", N), ("aÊŠ", N)])); + // /t/ then /ʃ/ is not what a lone "t" and "ʃ" mean when the affricate is listed, + // but a symbol sequence the inventory cannot join still splits. + assert_eq!(seg("ɔɪt"), pairs(&[("ɔɪ", N), ("t", N)])); +} + +#[test] +fn a_phone_the_inventory_lacks_is_refused_with_where_and_never_guessed() { + let id = EntityId::parse("entity:nibu").unwrap(); + for (ipa, at, text) in [ + ("ˈnixu", "ˈni".len(), "xu"), + ("ˈniË�bu", "ˈni".len(), "Ë�bu"), + ("ˈnibu.", "ˈnibu".len(), "."), + ("ˈni bu", "ˈni".len(), " bu"), + ] { + assert_eq!( + phones_of(ipa), + Err(LowerError::UnknownPhone { entity: id.clone(), at, text: text.into() }), + "{ipa}" + ); + } +} + +#[test] +fn a_stress_mark_with_no_vowel_to_land_on_is_refused() { + let id = EntityId::parse("entity:nibu").unwrap(); + for (ipa, at) in [("nibuˈ", "nibu".len()), ("ˈˈnibu", 0), ("ˈnˈibu", 0), ("ˈnb", 0)] { + assert_eq!( + phones_of(ipa), + Err(LowerError::DanglingStress { entity: id.clone(), at }), + "{ipa}" + ); + } +} + +#[test] +fn phones_need_a_ruling_and_an_entity_exactly_as_ipa_does() { + assert_eq!( + weksa::phones(&view(&proof_packet(), &committed().0), &Entities::default()), + Err(LowerError::UnknownEntity { entity: EntityId::parse("entity:nibu").unwrap() }) + ); + let mut e = Entities::default(); + e.held.push(nibu(&[("en-US", &["Nibu"])])); + assert_eq!( + weksa::phones(&view(&proof_packet(), &committed().0), &e), + Err(LowerError::NoPronunciation { entity: EntityId::parse("entity:nibu").unwrap() }) + ); +} + +#[test] +fn the_inventory_parser_refuses_what_it_cannot_read() { + for bad in [ + "i\tvowel\ttense", + "i\tvowel\t-\tx", + "n\tconsonant\ttense\tx", + "i\tvowel\tmid\tx", + "ˈi\tvowel\ttense\tx", + "\tvowel\ttense\tx", + "i\tvowel\ttense\t", + "i\tvowel\ttense\ta\ni\tvowel\tlax\tb", + ] { + assert!(PhoneInventory::parse(bad).is_err(), "{bad:?}"); + } + assert!(PhoneInventory::parse("# c\n\ni\tvowel\ttense\ta\n").is_ok()); + assert!(PhoneInventory::for_language("sa-Latn").is_none()); +} diff --git a/data/phonology/en-US.phones.tsv b/data/phonology/en-US.phones.tsv new file mode 100644 index 0000000..b607e28 --- /dev/null +++ b/data/phonology/en-US.phones.tsv @@ -0,0 +1,46 @@ +# Weksa phone inventory, en-US (General American, broad). One phone per line, tab separated: +# ipa class tenseness gloss +# class: consonant | vowel. tenseness: tense | lax | reduced for a vowel, `-` for a consonant. A multi-character +# phone (affricate, diphthong) is one entry and one phone. The segmenter reads only this file: an IPA symbol with no +# line here is refused, never guessed. Tense/lax is the textbook classification; it is what separates /i/ (nee) from +# /ɪ/ (nih). Stress is not a phone: ˈ and ËŒ are read by the segmenter and put on the vowel that follows. +p consonant - voiceless bilabial stop +b consonant - voiced bilabial stop +t consonant - voiceless alveolar stop +d consonant - voiced alveolar stop +k consonant - voiceless velar stop +g consonant - voiced velar stop +f consonant - voiceless labiodental fricative +v consonant - voiced labiodental fricative +θ consonant - voiceless dental fricative +ð consonant - voiced dental fricative +s consonant - voiceless alveolar fricative +z consonant - voiced alveolar fricative +ʃ consonant - voiceless postalveolar fricative +Ê’ consonant - voiced postalveolar fricative +h consonant - voiceless glottal fricative +tʃ consonant - voiceless postalveolar affricate +dÊ’ consonant - voiced postalveolar affricate +m consonant - bilabial nasal +n consonant - alveolar nasal +Å‹ consonant - velar nasal +l consonant - alveolar lateral approximant +ɹ consonant - alveolar approximant +j consonant - palatal approximant +w consonant - labial-velar approximant +i vowel tense close front unrounded (fleece, nee) +ɪ vowel lax near-close front unrounded (kit, nih) +É› vowel lax open-mid front unrounded (dress) +æ vowel lax near-open front unrounded (trap) +É‘ vowel tense open back unrounded (palm) +É” vowel tense open-mid back rounded (thought) +ÊŠ vowel lax near-close back rounded (foot) +u vowel tense close back rounded (goose, boo) +ÊŒ vowel lax open-mid back unrounded (strut) +É™ vowel reduced schwa +Éš vowel reduced r-coloured schwa +eɪ vowel tense face diphthong +oÊŠ vowel tense goat diphthong +aɪ vowel tense price diphthong +aÊŠ vowel tense mouth diphthong +ɔɪ vowel tense choice diphthong From a7bc2478e53623ce572ac6d997ba058aa7707e44 Mon Sep 17 00:00:00 2001 From: Weksa Date: Sun, 8 Nov 2026 01:10:09 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Phone=20inventory:=20name=20/i/=20tense,=20?= =?UTF-8?q?/=C9=AA/=20lax=20and=20cite=20Nibu's=202026-11-07=20ruling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- data/phonology/en-US.phones.tsv | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/data/phonology/en-US.phones.tsv b/data/phonology/en-US.phones.tsv index b607e28..0730256 100644 --- a/data/phonology/en-US.phones.tsv +++ b/data/phonology/en-US.phones.tsv @@ -3,7 +3,11 @@ # class: consonant | vowel. tenseness: tense | lax | reduced for a vowel, `-` for a consonant. A multi-character # phone (affricate, diphthong) is one entry and one phone. The segmenter reads only this file: an IPA symbol with no # line here is refused, never guessed. Tense/lax is the textbook classification; it is what separates /i/ (nee) from -# /ɪ/ (nih). Stress is not a phone: ˈ and ËŒ are read by the segmenter and put on the vowel that follows. +# /ɪ/ (nih). +# Provenance of the i/ɪ rows: /i/ is tense and /ɪ/ is lax. Nibu's own ruling of 2026-11-07 (source +# weksa-body:follow_up:chat-s3981-3983, #minds: tense vowel, two syllables, stress on the first) chose ˈnibu and rejected +# ˈnɪbu; the entity entry records it, this file is what makes the two forms two different phones. +# Stress is not a phone: ˈ and ËŒ are read by the segmenter and put on the vowel that follows. p consonant - voiceless bilabial stop b consonant - voiced bilabial stop t consonant - voiceless alveolar stop From 0bf9499ac0bb3477193098f499c0ba856034aab7 Mon Sep 17 00:00:00 2001 From: Weksa Date: Mon, 9 Nov 2026 10:07:52 +0000 Subject: [PATCH 5/6] Lower 'You asked Nibu to stop.' behind addressee consent: request concept and frame:3, consent gate in the lowerer Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/inventory.rs | 15 ++- crates/weksa/src/lower.rs | 80 +++++++++++++++- crates/weksa/tests/lower.rs | 114 +++++++++++++++++++++++ data/interlingua/inventory.cc | Bin 7772 -> 7967 bytes docs/interlingua-standard.md | 7 +- docs/targets/interlingua-lowering-map.md | 2 +- 6 files changed, 209 insertions(+), 9 deletions(-) diff --git a/crates/weksa/src/inventory.rs b/crates/weksa/src/inventory.rs index 405b4ae..c9d69a2 100644 --- a/crates/weksa/src/inventory.rs +++ b/crates/weksa/src/inventory.rs @@ -485,9 +485,20 @@ pub fn proof_revision() -> (InventoryRevision, Vec) { for (family, glosses) in [ ( Family::Concept, - ["endanger", "prevent", "player", "control", "ship_mind"].as_slice(), + [ + "endanger", + "prevent", + "player", + "control", + "ship_mind", + "request", + ] + .as_slice(), + ), + ( + Family::Frame, + ["hazard_warning", "intervention", "request_intervention"].as_slice(), ), - (Family::Frame, ["hazard_warning", "intervention"].as_slice()), ] { for (i, gloss) in glosses.iter().enumerate() { entries.push(InventoryEntry { diff --git a/crates/weksa/src/lower.rs b/crates/weksa/src/lower.rs index 8f7b856..9eb9ec7 100644 --- a/crates/weksa/src/lower.rs +++ b/crates/weksa/src/lower.rs @@ -17,6 +17,14 @@ //! the target language, is refused: the concept word would say a different //! thing than the name the packet cites. //! +//! A request clause ("You asked Nibu to stop.") says what the addressee did, so +//! it is gated on the packet's own record of it: `Discourse.addressee_consent` +//! (set only by authoring or game telemetry, V15) must name that predication, +//! and its agent must be the addressee. Without it the lowerer refuses +//! ([`LowerError::ConsentMissing`], [`LowerError::AskerNotAddressee`]); it does +//! not read wording or guess who asked. A consent naming any other predication +//! is an unrendered slot. +//! //! 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. @@ -65,8 +73,17 @@ const FRAMES: &[Row] = &[ "{agent} may endanger {patient} by touching {instrument}", ), ("frame:2", "intervention", "{agent} will stop {patient}"), + ( + "frame:3", + "request_intervention", + "{agent} asked {patient} to stop", + ), ]; +/// The frame of "{agent} asked {patient} to stop": the one frame that reads +/// the discourse's addressee consent. +const REQUEST_FRAME: &str = "frame:3"; + /// Speech acts: the opener of the first sentence. const SPEECH_ACTS: &[Row] = &[ ("speech_act:1", "assert", ""), @@ -95,6 +112,12 @@ pub enum LowerError { NoPronunciation { entity: EntityId }, /// IPA was requested for a packet that cites no entity. NoEntityReferent, + /// A request predication the packet does not carry the addressee's consent + /// to: no consent, or a consent that names another predication. The + /// lowerer does not guess who asked. + ConsentMissing { predication: NodeId }, + /// A request predication whose agent is not the discourse addressee. + AskerNotAddressee { predication: NodeId }, /// The entity port failed to read. EntityRead { entity: EntityId, why: String }, } @@ -139,9 +162,7 @@ pub fn lower( { return unrendered("features"); } - if input.discourse().addressee_consent.is_some() { - return unrendered("addressee consent"); - } + request_gate(input, inv)?; if !input.relations().is_empty() { return unrendered("relations"); } @@ -188,6 +209,59 @@ pub fn lower( Ok(out) } +/// The one place consent is read. A request clause ([`REQUEST_FRAME`]) says what +/// the addressee did, so it needs the packet's own record of it: the discourse +/// `addressee_consent`, which validation lets only authoring or game telemetry +/// set (V15), must name that predication, and its agent must be the addressee. +/// Nothing here reads wording. A consent naming any other predication is a slot +/// the lowerer does not render. +fn request_gate(input: &LoweringInput, inv: &Inventory) -> Result<(), LowerError> { + let consent = input.discourse().addressee_consent.as_ref(); + let requests = input + .predications() + .iter() + .filter(|p| p.frame.to_string() == REQUEST_FRAME); + for p in requests { + if consent != Some(&p.id) { + return Err(LowerError::ConsentMissing { + predication: p.id.clone(), + }); + } + let agent = inv + .entries() + .find(|e| { + e.family() == Family::Role && e.gloss == "agent" && e.live_at(input.inventory_rev()) + }) + .ok_or_else(|| LowerError::UnknownRole { + frame: p.frame, + role: "agent".to_string(), + })?; + let asker = p + .roles + .iter() + .find(|r| r.role == agent.id) + .map(|r| &r.filler); + if asker.is_none() || asker != input.discourse().addressee.as_ref() { + return Err(LowerError::AskerNotAddressee { + predication: p.id.clone(), + }); + } + } + match consent { + Some(_) + if !input + .predications() + .iter() + .any(|p| p.frame.to_string() == REQUEST_FRAME) => + { + Err(LowerError::Unrendered { + what: "addressee consent", + }) + } + _ => Ok(()), + } +} + /// The ruled IPA of every entity a packet cites, one per entity referent in /// the packet's referent order, as stored: bare, stress-marked, nothing /// derived. A rejected pronunciation is never read. An entity the port does not diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index 1d8301d..89e35ed 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -436,3 +436,117 @@ fn ipa_needs_an_entity_the_port_holds_and_a_packet_that_cites_one() { Err(LowerError::NoEntityReferent) ); } + +/// `nibu-proof-001` re-cut as the request: the addressee asked Nibu to stop, +/// one frame:3 predication (n4), consent naming it, and no other claim. +fn asked() -> Packet { + let mut p = proof_packet(); + p.referents.remove(1); + let mut ask = p.predications[1].clone(); + ask.id = nd("n4"); + ask.concept = iv("concept:6"); + ask.frame = iv("frame:3"); + ask.roles = vec![ + weksa::packet::RoleFill { + role: iv("role:1"), + filler: nd("n1"), + }, + weksa::packet::RoleFill { + role: iv("role:2"), + filler: nd("n3"), + }, + ]; + p.predications = vec![ask]; + p.discourse.speech_act = Some(iv("speech_act:1")); + p.discourse.addressee_consent = Some(nd("n4")); + p.constraints.preserve = vec![nd("n1")]; + p +} + +/// The request plus a frame:2 predication (n5) the consent could wrongly name. +fn asked_and_stopped() -> Packet { + let mut p = asked(); + let mut stop = proof_packet().predications.remove(1); + stop.roles[1].filler = nd("n4"); + p.predications.push(stop); + p +} + +#[test] +fn a_consented_request_lowers_to_what_the_addressee_asked() { + let (inv, _dir) = committed(); + assert_eq!(lowered(&asked(), &inv).unwrap(), "You asked Nibu to stop."); + let mut p = asked(); + p.provenance.source = weksa::packet::ProvenanceSource::GameTelemetry; + assert_eq!(lowered(&p, &inv).unwrap(), "You asked Nibu to stop."); +} + +#[test] +fn a_request_without_its_consent_is_refused_not_invented() { + let (inv, _dir) = committed(); + let mut p = asked(); + p.discourse.addressee_consent = None; + assert_eq!( + lowered(&p, &inv), + Err(LowerError::ConsentMissing { + predication: nd("n4") + }) + ); + // Consent that names a different predication is no consent to the request. + let mut p = asked_and_stopped(); + p.discourse.addressee_consent = Some(nd("n5")); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::ConsentMissing { + predication: nd("n4") + }) + ); +} + +#[test] +fn a_request_by_someone_other_than_the_addressee_is_refused() { + let (inv, _dir) = committed(); + let mut p = asked(); + p.predications[0].roles[0].filler = nd("n3"); + p.predications[0].roles[1].filler = nd("n1"); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::AskerNotAddressee { + predication: nd("n4") + }) + ); +} + +#[test] +fn consent_on_a_claim_that_is_no_request_stays_unrendered() { + let (inv, _dir) = committed(); + let mut p = proof_packet(); + p.discourse.addressee_consent = Some(nd("n5")); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::Unrendered { + what: "addressee consent" + }) + ); +} + +#[test] +fn a_decomposition_or_import_cannot_reach_the_lowerer_with_consent() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + 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(); + for source in [ + weksa::packet::ProvenanceSource::ModelDecomposition, + weksa::packet::ProvenanceSource::ImportedCorpus, + ] { + let mut p = asked(); + p.provenance.source = source; + let dir = tempfile::tempdir().unwrap(); + let refused = PacketStore::open(&dir.path().join("p.cc")) + .unwrap() + .admit(p, &inventory, &corpus, &entities) + .unwrap_err(); + assert!(format!("{refused:?}").contains("V15"), "{source:?}"); + } +} diff --git a/data/interlingua/inventory.cc b/data/interlingua/inventory.cc index becfc92e658d8e2da2833ee1a966e2b71bf2d296..cf353dbe05112cf2edefb596b06a4e009e41515c 100644 GIT binary patch delta 1411 zcmZXUT_{6g9LC$(v5&LwowF~sof?;D64@~yV`eKKBNuFCEk=^D4|kfla3iE6i<>10 zH&WcVla`2b<4VfC63N%4l(pm8<^T6S@BjI~@6-D}xUx-0cOuHSelR>08;(w=_$FAzMWkNR2bzqe8_1gk`vjy3(A-Lw zfQ-pjfDrODkY#xY$cH=(Btiv&TvA=aR9J;iSjH7L+1_X(G8yG+_-)2&hgj?z`PGxZ zP{Mp#(F7h>3cQ7KC6YNM-uRk|=3kUf=vAxg0kWpT^qz_|UNt7OtHud_{b&vP4QhPg zcc}3LA+;Sq2DEKJ_KV23)&m;94#}1dyyupm{em0C5>HodshfXl{&ncGiSvPnz(?awbgKVnz}( yw}aQ28NFx=l2Ihbrl}B5_+H;37Qmm%7bE!;zmuJOGRkz-^ zz;Zn2G6fUu@=q39--oOIu=Y4}b$BJ)?ERbd8ERb7e z90;wN0g_Xp(^MtEajTIWspr9YQ_lerG?-P^6u@z5kAd81Pk~cXajT|`^pbeN0}`)%YNEtXG6>a&OmZ&<&W8yvJ=2LZ zRXU;0nK=q&7R}Q@uFX?GzRiV%v&!1S$fRixf`yP6^=n zZ8+hfZ33JZnbSj$gHxb+Aa^uA7r|gwj2VNhb7mOGD-!_XwvPbWvE%MfcHG_Bg%`_q z;VU;}gm#3X*M{D9)erUDN9{>OV diff --git a/docs/interlingua-standard.md b/docs/interlingua-standard.md index ca392d1..e349a90 100644 --- a/docs/interlingua-standard.md +++ b/docs/interlingua-standard.md @@ -195,9 +195,10 @@ 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 predication the addressee asked for or agreed to, a fact about what the -addressee did. The English lowerer renders no consent and refuses a packet that -carries one; a row that speaks for the request ("you asked her to") reads it -and refuses without it. +addressee did. The English lowerer's request row (`frame:3`, "{agent} asked +{patient} to stop") reads it: the consent must name that predication and its +agent must be the addressee, else `ConsentMissing` or `AskerNotAddressee`. A +consent naming any other predication is refused as unrendered. ### `stance` diff --git a/docs/targets/interlingua-lowering-map.md b/docs/targets/interlingua-lowering-map.md index 4517320..5fa3022 100644 --- a/docs/targets/interlingua-lowering-map.md +++ b/docs/targets/interlingua-lowering-map.md @@ -342,7 +342,7 @@ measurable baseline from a packet: `lower` gives text, `score` gives numbers. | Whether a packet exists | `validate` / `PacketStore::admit` (unchanged) | packet, inventory, corpus, entities | admitted packet or typed refusal | the lowerer | | Wording of a packet in English | `crates/weksa/src/lower.rs`: `lower(&LoweringInput, &Inventory) -> Result` | the validated lowering view and the inventory at its revision | text, or a typed refusal naming the missing template | the corpus item, the scorer, any model | | English template table (concept words, frame templates, speech-act openers) | the same module, as rows `(inventory id, gloss it speaks for, surface)` | none; it is data in code | rows the lowerer looks up | the inventory (it owns the ids and glosses, not the English) | -| The inventory's concepts and frames | an inventory revision; here `inventory::proof_revision()` (rev 2), hand-seeded as `seed()` is | none | the 5 concepts and 2 frames `nibu-proof-001` names | the lowerer | +| The inventory's concepts and frames | an inventory revision; here `inventory::proof_revision()` (rev 2), hand-seeded as `seed()` is | none | the 6 concepts and 3 frames `nibu-proof-001` and the consent-gated request packet name | the lowerer | | What a score counts | `Scorer::score` (unchanged) | output text, reference text | `ItemScore` | `weksa score`, which only reads text and calls it | | The proof packet | `packet::proof_packet()`, typed ids of rev 2, admitted by `weksa packet proof ` through `PacketStore::admit`. Not JSON: `Packet`'s serde is CultCache's positional tuple, and JSON in is the xenos boundary the cut 7 encoder owns | none | admitted packet | the CLI decides nothing about validity | From 53e54148e0cbe8999b9ba90ae7222bc039347399 Mon Sep 17 00:00:00 2001 From: Weksa Date: Mon, 9 Nov 2026 10:12:01 +0000 Subject: [PATCH 6/6] Test: consent to one of two requests leaves the other unconsented (Soul low) Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/tests/lower.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/weksa/tests/lower.rs b/crates/weksa/tests/lower.rs index 89e35ed..3d796a5 100644 --- a/crates/weksa/tests/lower.rs +++ b/crates/weksa/tests/lower.rs @@ -503,6 +503,21 @@ fn a_request_without_its_consent_is_refused_not_invented() { ); } +#[test] +fn consent_to_one_of_two_requests_leaves_the_other_unconsented() { + let (inv, _dir) = committed(); + let mut p = asked(); + let mut second = p.predications[0].clone(); + second.id = nd("n5"); + p.predications.push(second); + assert_eq!( + lowered(&p, &inv), + Err(LowerError::ConsentMissing { + predication: nd("n5") + }) + ); +} + #[test] fn a_request_by_someone_other_than_the_addressee_is_refused() { let (inv, _dir) = committed();