Merge remote-tracking branch 'origin/main' into weksa-lowering/corpus-intake-lows-2

This commit is contained in:
Weksa 2026-11-09 10:43:04 +00:00
commit 3cf499dbd4
23 changed files with 1467 additions and 17 deletions

View file

@ -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

View file

@ -0,0 +1,326 @@
//! 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:4`. 4 is minted from a Train chapter, 5 and 6
/// from Validation chapters, 7 and 8 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<InventoryEntry>) {
let mut entries = Vec::new();
for (family, glosses) in [(Family::Concept, CONCEPTS), (Family::Frame, FRAMES)] {
let first = if family == Family::Concept { 7 } else { 4 };
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:{}", 7 + 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:{}", 4 + 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<Packet> {
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,
addressee_consent: 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(),
},
}
}

View file

@ -485,9 +485,20 @@ pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
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 {

View file

@ -4,11 +4,13 @@
//! (chrF, meaning cosine, leak diagnostics).
pub mod corpus;
pub mod daodejing;
pub mod entity;
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 +24,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,
};

View file

@ -17,15 +17,26 @@
//! 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.
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;
@ -54,6 +65,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:7", "sage", "sage"),
("concept:8", "mind", "minds"),
("concept:9", "belly", "bellies"),
("concept:10", "will", "wills"),
("concept:11", "bone", "bones"),
("concept:12", "empty", "empties"),
("concept:13", "fill", "fills"),
("concept:14", "weaken", "weakens"),
("concept:15", "strengthen", "strengthens"),
("concept:16", "heaven", "heaven"),
("concept:17", "earth", "earth"),
("concept:18", "is_long_enduring", "is long-enduring"),
("concept:19", "continues_long", "continues long"),
("concept:20", "highest_excellence", "highest excellence"),
("concept:21", "water", "water"),
("concept:22", "true_words", "words that are strictly true"),
("concept:23", "seem_paradoxical", "seem paradoxical"),
(
"concept:24",
"space_between_heaven_and_earth",
"space between heaven and earth",
),
("concept:25", "bellows", "bellows"),
("concept:26", "compare", "compare"),
("concept:27", "resemble", "resemble"),
];
/// Frames: the clause a predication of that frame says. `{gloss}` is the filler
@ -65,11 +103,36 @@ 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 Dao De Jing test bed. Frame 4 was written from a Train chapter, 5 and
// 6 from Validation chapters, 7 and 8 from Test chapters.
("frame:4", "act", "{agent} {verb} {patient}"),
("frame:5", "intransitive", "{experiencer} {verb}"),
("frame:6", "likeness", "{experiencer} is like {goal}"),
(
"frame:7",
"seems_paradoxical",
"{experiencer} seem paradoxical",
),
(
"frame:8",
"may_be_compared",
"{experiencer} may be compared to {goal}",
),
];
/// 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", ""),
("speech_act:2", "ask", ""),
("speech_act:5", "warn", "Warning: "),
];
@ -95,6 +158,19 @@ 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 },
/// 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 +215,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 +262,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
@ -197,13 +324,54 @@ pub fn pronounce(
input: &LoweringInput,
entities: &dyn EntityForms,
) -> Result<Vec<String>, 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<Phone>,
}
/// 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<Vec<PhoneLine>, 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<Vec<(EntityId, String)>, 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);
@ -228,6 +396,10 @@ fn clause(
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())..];
if name == "verb" {
out.push_str(row(CONCEPTS, p.concept, inv, rev)?);
continue;
}
let role = inv
.entries()
.find(|e| e.family() == Family::Role && e.gloss == name && e.live_at(rev))

View file

@ -54,8 +54,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),
@ -72,6 +77,8 @@ enum Command {
enum PacketCommand {
/// Admit the nibu-proof-001 packet through `PacketStore::admit`.
Proof { store: PathBuf },
/// Admit the Dao De Jing slice's packets through `PacketStore::admit`.
Dao { store: PathBuf },
}
#[derive(Args)]
@ -106,6 +113,8 @@ enum InventoryCommand {
Seed,
/// Admit revision 2, the concepts and frames the nibu-proof-001 packet names.
Proof,
/// Admit revision 3 of the Dao De Jing test bed stores (after seed and proof).
Dao,
}
#[derive(Subcommand)]
@ -282,6 +291,16 @@ struct AddArgs {
author: Option<String>,
#[arg(long)]
edition_url: Option<String>,
/// The edition's licence, for `public-domain`.
#[arg(long)]
licence: Option<String>,
/// Where the public-domain status is asserted, for `public-domain`.
#[arg(long)]
jurisdiction: Option<String>,
#[arg(long)]
translator: Option<String>,
#[arg(long)]
edition_year: Option<u16>,
#[arg(long = "model-family", value_enum)]
family: Option<ModelFamilyArg>,
/// The generator string the fixture or run wrote, verbatim.
@ -341,7 +360,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 = [
@ -351,6 +378,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()),
@ -377,6 +408,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
@ -544,6 +581,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();
@ -557,7 +615,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)?;
@ -577,6 +635,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)?);
}

152
crates/weksa/src/phones.rs Normal file
View file

@ -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/<language>.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<String, Class>,
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<PhoneInventory> = 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<Self, String> {
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<Vec<Phone>, SegmentError> {
let chars: Vec<(usize, char)> = ipa.char_indices().collect();
let mut out: Vec<Phone> = 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)
}
}

View file

@ -573,6 +573,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")
}

View file

@ -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",
@ -876,6 +964,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);

View file

@ -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<String> = packets()
.into_iter()
.map(|p| {
let view = store.admit(p, &inventory, &corpus, &entities).unwrap();
lower(&view, &inv, &entities).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));
}
}

View file

@ -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. \
@ -436,3 +436,269 @@ 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<Vec<PhoneLine>, 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());
}
/// `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 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();
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:?}");
}
}

View file

@ -522,7 +522,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,
@ -556,6 +556,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",
),
@ -564,6 +568,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",
),
@ -572,9 +580,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,
@ -679,6 +727,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] = [
(
@ -1443,3 +1495,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();
}

1
data/daodejing/corpus.cc Normal file
View file

@ -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<EFBFBD>•”°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<EFBFBD><EFBFBD>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>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ÀÂ<C380>”°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<68><37>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>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ÀÂ<C380>”°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<68><38>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>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ÀÂ<C380>”°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<37><38>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>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ÀÂ<C380>”°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<68><35>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>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ÀÂ<C380>

1
data/daodejing/entity.cc Normal file
View file

@ -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<EFBFBD>‘”¬entity:laozi¬weksa.entity´2026-11-07T02:09:00ZÄb“¬entity:laozi‘’¥en-US‘¥Laozi<EFBFBD>¬PublicDomain‘Ù4The Tao Teh King, or the Tao and its Characteristics

BIN
data/daodejing/inventory.cc Normal file

Binary file not shown.

View file

@ -0,0 +1 @@
“²cultcache.store.v1‘—¸weksa.interlingua_packet¸weksa.interlingua_packet»weksa.interlingua_packet.v1¸weksa.interlingua_packetÙd{"schemaName":"weksa.interlingua_packet","schemaVersion":"weksa.interlingua_packet.v1","members":[]}‘¸weksa.interlingua_packet<EFBFBD>•”·packet:64616f2d30333032¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅSž·packet:64616f2d30333032­CorpusExample”¨Authored°item:daodejing:1¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>••¢n1¯referent_kind:1©concept:7À<EFBFBD>•¢n2°referent_kind:11©concept:8À<EFBFBD>•¢n3¯referent_kind:3©concept:9À<EFBFBD>•¢n4°referent_kind:11ªconcept:10À<EFBFBD>•¢n5¯referent_kind:3ªconcept:11À<EFBFBD>”•¢n6ªconcept:12§frame:4’’¦role:1¢n1’¦role:2¢n2<EFBFBD>•¢n7ªconcept:13§frame:4’’¦role:1¢n1’¦role:2¢n3<EFBFBD>•¢n8ªconcept:14§frame:4’’¦role:1¢n1’¦role:2¢n4<EFBFBD>•¢n9ªconcept:15§frame:4’’¦role:1¢n1’¦role:2¢n5<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙXFour acts of one agent; 'in the exercise of his government' and 'their' are not carried.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ ”·packet:64616f2d30353032¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅ^ž·packet:64616f2d30353032­CorpusExample”¨Authored°item:daodejing:5¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:2ÀÀ<EFBFBD>’•¢n1¯referent_kind:4ªconcept:24À<EFBFBD>•¢n2¯referent_kind:5ªconcept:25À<EFBFBD>‘•¢n3ªconcept:26§frame:8’’¦role:3¢n1’¦role:6¢n2<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙRA rhetorical question; the speech act is ask and the negated 'may not' is dropped.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ ”·packet:64616f2d30373031¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅyž·packet:64616f2d30373031­CorpusExample”¨Authored°item:daodejing:2¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>’•¢n1¯referent_kind:4ªconcept:16À<EFBFBD>•¢n2¯referent_kind:4ªconcept:17À<EFBFBD>’•¢n3ªconcept:18§frame:5‘’¦role:3¢n1<EFBFBD>•¢n4ªconcept:19§frame:5‘’¦role:3¢n2<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙTHeaven and earth as places; whether they are entities is a ruling the ontology owes.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ ”·packet:64616f2d30383031¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅwž·packet:64616f2d30383031­CorpusExample”¨Authored°item:daodejing:3¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>’•¢n1°referent_kind:11ªconcept:20À<EFBFBD>•¢n2°referent_kind:11ªconcept:21À<EFBFBD>‘•¢n3ªconcept:27§frame:6’’¦role:3¢n1’¦role:6¢n2<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙSThe metaphor is a plain likeness predication; the tenor and vehicle are not marked.<EFBFBD>‘’’ªfeature:18<EFBFBD>¥GradeÍ ’ªfeature:16<EFBFBD>¥GradeÍ,”·packet:64616f2d37383034¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅNž·packet:64616f2d37383034­CorpusExample”¨Authored°item:daodejing:4¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>‘•¢n1°referent_kind:11ªconcept:22À<EFBFBD>‘•¢n2ªconcept:23§frame:7‘’¦role:3¢n1<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙmThe whole paradox is in the referent's concept and the frame; the packet holds no more than the surface does.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ

Binary file not shown.

View file

@ -0,0 +1,50 @@
# Weksa phone inventory, en-US (General American, broad). One phone per line, tab separated:
# ipa <TAB> class <TAB> tenseness <TAB> 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).
# 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
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
Can't render this file because it has a wrong number of fields in line 11.

View file

@ -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"}]}

View file

@ -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`

View file

@ -0,0 +1,116 @@
# Dao De Jing test bed
A lowering test bed on James Legge's public-domain translation of the Dao De
Jing, from Meta's analogy: the Dao has many varied human translations, so it is
a ready-made field of precedents for what one meaning can be lowered to. Meta
proposed it in #weksa:gamecult.org on 2026-11-06 (Matrix event
`$2_P0mbtbnnsP4I61UhVMlatuDSTjFXMOuGa4SSbhGmc`: public-domain translations only
as the scoring corpus, copyrighted ones cited without copying) and noted the
ontology consequence (event `$E4VHwS-zFEk9Fa-daFX8MkfoySn0iX0m1grltEJ9A8Y`:
model an imperial native term as a term with several context-tagged readings
plus a separately recorded chosen gloss per lowering).
## What is here
- `data/daodejing/`: its own stores (inventory revisions 1 to 3, one entity, five
corpus items, five packets), apart from the production stores in `data/`.
Revision 3 is `weksa::daodejing::revision()`; the packets are
`weksa::daodejing::packets()`. The scene revision (`scene_revision()`, also
numbered 3) is not applied in these stores and will need renumbering when it
is applied anywhere both meet.
- Rebuild: `weksa --corpus C --inventory I --entities E` with `inventory seed`,
`inventory proof`, `inventory dao`, `entity put entity:laozi ...`,
`corpus add ... --source public-domain` per item, then `packet dao <store>`.
- 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.

View file

@ -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<String, LowerError>` | 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 <store>` 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 |

View file

@ -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"
]
}
},