Compare commits

...
Author SHA1 Message Date
157892a95a Merge pull request 'speaker-state onto main (stance in packet, admit-only LoweringInput) + build notes' (#3) from weksa-lowering/speaker-state-on-main into main 2026-11-03 19:27:33 +00:00
Weksa
c1b2778b15 Merge weksa-lowering/speaker-state into main; adapt the lowerer to admit-only LoweringInput
No textual conflict except data/interlingua/inventory.cc (binary): regenerated as
the branch's rev 1 (61 entries) plus `weksa inventory proof` (rev 2, 7 entries).
Semantic breaks fixed: proof_packet() carries an empty Stance; `weksa lower`
re-admits the stored packet (idempotent) because validate no longer returns the
view; tests/lower.rs builds views through PacketStore::admit; seed_reseeded
expects seed plus proof revision. AGENTS.md records the CARGO_HOME requirement
and the proof packet key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 15:54:42 +00:00
Weksa
22eba3a106 lower: refuse role fills the frame does not name and referents no role mentions; header states what is and is not rendered (Soul pass on 3d851bf)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:27:18 +00:00
Weksa
3d851bf0e1 lowerer-first-cut: lower(&LoweringInput,&Inventory) by a fixed English table keyed by inventory ids; weksa lower, score, packet proof, inventory proof; inventory rev 2 and the nibu-proof-001 packet
Measured against the proof lowering: item:nibu:1 chrF 23.12 cosine 0.6313,
item:nibu:2 chrF 25.98 cosine 0.7245, item:brokkr:1 chrF 19.42 cosine 0.4516.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:29:38 +00:00
8d9c0270d1 entity-door-lows (2d): attested origins, every Cf refused in forms, bytes-only form bound, S6 under NFKC + case fold keeping marks, no-alnum texts refused, CLI flags and echoes (squash of weksa-lowering/entity-door-lows f1c9815..57dacf4; Soul s1, s2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:44:02 +01:00
d5ae292ebc Face state: delete the void.speech_receipts record (20 outbound Discord receipts); ruling face-posts-tainted
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:16:12 +01:00
4fb817b18f validate: positive twin for the admissible compile_fail doctest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:39:42 +01:00
ad02feeeb6 speaker-state: rev 1 now has 61 entries (stance features in, three display features out)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:38:51 +01:00
283678a58b speaker-state: reseed inventory rev 1, regenerate schemas (packet v4), fmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:38:05 +01:00
86371d2ce4 speaker-state: packet stance (slot Stance), seed stance features, drop formality/warmth/intensity, admit is the only LoweringInput source, withdrawn items refused (WIP before regeneration)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:37:02 +01:00
f1c9815b26 Map: specs entity-door-lows (2d) and speaker-state (4, stance half); speaker-scene (4b) in the sequence; UAX #29 word probe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:30:36 +01:00
869c82b686 scorer: chrF and meaning cosine of whole output against whole reference, no mask; Embedder port with Ollama adapter; pooled novel-n-gram leak diagnostics; aggregation by derived split (squash of weksa-lowering/scorer onto 20865ca, head 5689e62; spec r3, Soul s1-s3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:17:46 +01:00
20865cac59 entity-store (2c): referents cite entity ids; typed entity store with bounded NFC forms per language; corpus name spans on grapheme boundaries; required CorpusItemId; packet schema v3 (squash of weksa-lowering/entity-store 29d671e..ea2c96c; Soul s1, s2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:14:06 +01:00
29d671e338 Target: no-surface-leak with no free-text packet field and no scorer mask (leak-owned-by-flow-and-scorer-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:47:51 +01:00
28d4d37802 Map: the design paragraph names referents by entity id, not free text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:44:46 +01:00
3064667ce7 Map: names are entity ids from a typed entity store; the scorer masks nothing (leak ruling 3), entity-store cut 2c, scorer r3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:44:30 +01:00
01afcfbdb4 Target: no-surface-leak cites leak-owned-by-flow-and-scorer-2 (output-only name deletion)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:01:54 +01:00
6ba10a3fdc Map: leak ruling 2 (output-only whole-word name mask, novel n-gram coverage), scorer s1 probe facts, scorer r2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:58:13 +01:00
c47e8e415d packet-store-corpus (2b): truthful corpus labels with ModelFamily and verbatim generator, whole-item S1, canonical keys, S6 no copy under a new key (squash of weksa-lowering/packet-store-corpus 491536c..f9258d5; Soul s1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:34:00 +01:00
491536cb30 Map: speaker state on the model page (stance in the packet, Speaker and Scene documents), body facts for 2b and scorer, speaker-state and language-ontology cuts before golf-loop-en
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:06:14 +01:00
28284cc8db packet-store: weksa crate with typed packet, inventory and corpus stores, validator V1-V8 and V10-V12, derived split, published schemas and CLI (squash of weksa-lowering/packet-store 35fa979..c6190a6; spec r3, Soul s1-s4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:40:49 +01:00
b7aa687d13 Target: no-surface-leak restated as a scoring invariant with two owners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:21:34 +01:00
548ddac8bd Map: surface leak owned by the loop's context builder and the scorer, not door text rules (ruling leak-owned-by-flow-and-scorer, spec cut-packet-store.r3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:20:54 +01:00
044971a375 Target: persona drives register through each language's ontology (operator 2026-10-29)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:40:00 +01:00
9466b8342f Map: packet-store r2 rows (typed ids, names, minted_for, split exclusion) and cut 2b packet-store-corpus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:15:58 +01:00
35fa97913b park-node-body fixes: remove Discord-derived ontology content and its catalog entries; contract is planned design only; drop stale fixture refs (squash of weksa-lowering/park-node-body-fix 3327edd..HEAD; Soul s1, s2 findings)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:41:36 +01:00
3327eddca3 park-node-body: park the Node body and Discord-sourced harness at parked/weksa-node-body; true README (squash of weksa-lowering/park-node-body 2583fb9..e41b60d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:28:33 +01:00
2583fb91c3 Map: interlingua lowering model page, scoring, convergence and cut sequence
Prose map for campaign weksa-lowering, written by Imagination for the
Eureka-area Self (session self-2026-10-29-eureka).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:10:21 +01:00
735708d33e Research: fine-tuned lowerers and embedding-mapped lexicons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:45:11 +01:00
d76b96789d Research: golf-loop metric, leakage and corpus prior art
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:37:35 +01:00
2cea2b8a6d Target: persona-nuance goal, corpus golf loop, conlangs in scope
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:30:41 +01:00
adb3615298 Target and research for the interlingua-lowering campaign
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:28:56 +01:00
Meta Vens
55c75e3bd0 Quarantine the Eve operator surface publisher; Eve is deprecated, rebuild on Thing 2
Removes buildOperatorSurface, the surface_state and interface_binding documents, eve-surfaces.cc and the surface advertisement entries. Typed state and the provider advertisement stay. Parked at parked/weksa-eve-surface per thing2:ruling:eve-dashboards-quarantined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:36 +02:00
94 changed files with 12799 additions and 7759 deletions

2
.gitignore vendored
View file

@ -10,6 +10,7 @@ state/scratch-runtime/
.weksa-runs/
.weksa-smoke/
.weksa/*.cc
*.cc.lock
.weksa/*.json
.weksa/*.log
.weksa/*.pid
@ -20,3 +21,4 @@ dist/
build/
.venv/
venv/
target/

Binary file not shown.

View file

@ -46,8 +46,18 @@ The live state architecture is ported from EpiphanyAgent in reduced form:
- `state/map.yaml` is canonical and slow-changing.
- `state/scratch.md` is temporary and disposable.
- `schemas/` explains state contracts in human language.
- `packages/weksa-state-model` owns executable typed state.
- `crates/weksa` owns executable typed state.
Project truth does not belong in agent personality memory. If Weksa later grows
role dossiers or heartbeat scheduling, those surfaces must remain separate from
language-project truth.
## Building And Verifying
- `crates/weksa` depends on CultLib's `cultcache-rs` as a git dependency pinned by
`rev`, fetched from GitHub. With the default `CARGO_HOME` that fetch fails on a
Mind's host. Use the pre-fetched home:
`CARGO_HOME=/var/lib/mind-weksa-self/cargo-home cargo test --workspace`.
- `weksa packet proof <store>` admits the nibu proof packet under the key
`packet:6e6962752d303031` (hex of `nibu-001`), not `nibu-proof-001`. Use that key
with `weksa lower <store> <key>`.

View file

@ -1,3 +1,3 @@
Repository doctrine for this project lives in AGENTS.md, shared with Codex.
Repository doctrine for this project lives in AGENTS.md, shared with other coding agents.
@AGENTS.md

1009
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
resolver = "3"
members = ["crates/weksa"]

View file

@ -1,12 +1,40 @@
# Weksa
Weksa is a procedural language engine for alien grammars grounded in concept,
culture, and constraint.
culture, and constraint, and the planned lowerer that turns an agent's
meaning packet into the reader's display language.
The project starts from a blunt rule: English words are not the source of truth.
Meaning is decomposed into an ontology, passed through a worldview, constrained
by grammar, shaped by phonology, distorted by history, and only then rendered as
surface text.
**What is valuable here.** The interlingua standard and the lowering design: a
meaning packet owns what is meant, a target-language profile owns register,
address and politeness, and a lowerer owns surface text and leaves a trace.
**The problem it solves.** An agent's outward speech today is one English
string the model chose, so meaning, register and voice are fused and nobody can
inspect or re-target it. Packets separate them, so the same meaning can be
lowered into English, other languages and conlangs, and the loss can be
measured.
**Entry points.**
- [Interlingua Standard](./docs/interlingua-standard.md): the packet contract.
- `crates/weksa`: the packet, inventory and corpus types, `validate`, the
derived split, the scorer (`score`: chrF, meaning cosine through an Ollama
embedder, whole output against whole reference with no mask, leak diagnostics) and the `weksa` CLI
(`weksa validate`, `corpus`, `entity`, `inventory`, `split`, `schema`);
published schemas in `schemas/cultnet/`.
- `data/corpus/corpus.cc`, `data/entity/entity.cc` and `data/interlingua/inventory.cc`: the English
corpus, its entities and the inventory, read with `weksa corpus dump`,
`weksa entity dump` and `weksa inventory dump`.
- [Lowering target](./docs/targets/interlingua-lowering.md) and
[its map](./docs/targets/interlingua-lowering-map.md): the design, scoring
and cut sequence.
- `data/`: the cultural ontology and target-language profile data.
- `settings/`: setting-specific proving grounds for language projects.
**Live versus planned.** Live: docs, fixtures, examples, ontology data and the
packet crate `crates/weksa`, including the scoring library. Planned: lowering,
the `weksa score` subcommand, and the golf loop that scores lowered English against an expressive corpus. No
daemon runs; the earlier Node daemon is parked at tag `parked/weksa-node-body`.
## Ownership Model
@ -40,9 +68,10 @@ for the LLM-free hot path.
See [Interlingua Standard](./docs/interlingua-standard.md) for the shared
meaning-packet contract Weksa clients should target.
See [Verse Service Contract](./docs/verse-service-contract.md) for the daemon
contract: Weksa owns typed conversational intent, pronunciation plans, utterance
handoffs, and the Eve/CultMesh surfaces that make those documents inspectable.
See [Verse Service Contract](./docs/verse-service-contract.md) for the planned
service contract: Weksa would own typed conversational intent, pronunciation
plans, utterance handoffs, and the typed CultMesh state that makes those
documents inspectable. No daemon runs yet.
See [Multilingual Utterance Lowering](./docs/multilingual-utterance-lowering.md)
for the shared lowering target: interlingua projected through speaker state into
@ -63,11 +92,12 @@ AquaSynth or expanding pronunciation coverage.
## Implementation Status
The first committed artifact is the persistent state spine: map, scratch,
schemas, docs, examples, and a read-only provider advertisement fixture. The
daemon runtime still needs implementation, but its authority is no longer
ambiguous: writing and agent state enter Weksa as intent pressure, and typed
utterance documents leave for AquaSynth.
The persistent state spine is live: map, scratch, schemas, docs, examples and
data. Packet types, validation and the corpus and inventory stores are live in
`crates/weksa`, and so is the scoring library. Lowering and the service are planned (see the cut
sequence in [the lowering map](./docs/targets/interlingua-lowering-map.md)).
Writing and agent state will enter Weksa as intent pressure, and typed utterance
documents will leave for AquaSynth.
[Eusocial Interbeing / Zyphos](./settings/eusocial-interbeing/README.md) is the
current higher-priority setting target. [Rust to Dust](./settings/rust-to-dust/README.md)

28
crates/weksa/Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
name = "weksa"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
name = "weksa"
path = "src/lib.rs"
[[bin]]
name = "weksa"
path = "src/main.rs"
[dependencies]
anyhow = "1"
caseless = "0.2"
clap = { version = "4", features = ["derive"] }
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "64b86e0a273a3936939357e9deb186b7bf3bc417", package = "cultcache-rs" }
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
unicode-normalization = "0.1"
unicode-segmentation = "1"
ureq = { version = "3", default-features = false }
[dev-dependencies]
tempfile = "3"

142
crates/weksa/src/corpus.rs Normal file
View file

@ -0,0 +1,142 @@
//! The English target corpus. An item's text is immutable; a correction is a new
//! item that supersedes the old, and an item is withdrawn, never deleted. The
//! source enum has no variant for a source whose terms forbid training.
use cultcache_rs::DatabaseEntry;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::form::{FormError, is_slug, string_form};
use crate::packet::EntityId;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Turn {
pub speaker: EntityId,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Source {
/// A line from a cast member's hand-authored fixture, with the human who
/// wrote it. A fixture that records no author is `ModelWritten` with family
/// `Unrecorded`, never this (`corpus-labels-tell-truth`).
CastAuthored {
persona: String,
fixture: String,
author: String,
},
OperatorWritten,
OperatorApproved {
note: String,
},
PublicDomain {
work: String,
author: String,
edition_url: String,
},
/// Labelled model output; never in the test split, never judged by its own family.
/// `family` is `Unrecorded` when nothing records one. `generator` is the
/// string the fixture or run wrote, kept verbatim.
ModelWritten {
family: ModelFamily,
generator: Option<String>,
prompt_digest: Option<String>,
},
}
/// The family of the model that wrote a line.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ModelFamily {
Anthropic,
OpenAi,
Google,
Alibaba,
Meta,
Mistral,
/// Nothing records which family wrote the line.
Unrecorded,
}
/// Key: `item:<source-slug>:<n>`.
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
#[cultcache(type = "weksa.corpus_item")]
pub struct CorpusItem {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub text: String,
#[cultcache(key = 2)]
pub language: String,
#[cultcache(key = 3)]
pub speaker: EntityId,
/// The unit the split hashes: a scene, a fixture, a work.
#[cultcache(key = 4)]
pub group: String,
#[cultcache(key = 5)]
pub prior_turns: Vec<Turn>,
#[cultcache(key = 6)]
pub source: Source,
#[cultcache(key = 7)]
pub admitted_on: String,
/// Key of the item this one corrects.
#[cultcache(key = 8)]
pub supersedes: Option<String>,
#[cultcache(key = 9)]
pub withdrawn: bool,
/// Which entities the text names, and where. Fixed with the text at intake.
#[cultcache(key = 10)]
pub names: Vec<NameSpan>,
}
/// A byte range `[start, end)` of an item's text that names an entity. The
/// store admits it only when both ends sit on extended grapheme cluster
/// boundaries (UAX #29) of the NFC text and the range's bytes equal one of the
/// entity's forms in the item's language.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct NameSpan {
pub entity: EntityId,
pub start: u32,
pub end: u32,
}
/// The key of a corpus item, `item:<slug>:<n>` as `key_slug` reads it. A packet
/// names the item it was minted for by this type, so the key cannot be free text.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct CorpusItemId(String);
impl CorpusItemId {
pub fn parse(s: &str) -> Result<Self, FormError> {
key_slug(s)
.map(|_| Self(s.to_string()))
.ok_or_else(|| FormError::new("CorpusItemId", s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CorpusItemId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
string_form!(CorpusItemId, { "type": "string", "pattern": "^item:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?:[1-9][0-9]*$" });
/// `item:<slug>:<n>`.
pub fn item_key(slug: &str, n: u32) -> String {
format!("item:{slug}:{n}")
}
/// The slug of an item key, if it is canonical `item:<slug>:<n>`: a slug of
/// `[a-z0-9]` and inner `-` (at most 48 bytes, no edge `-`), and `n` a decimal
/// of at least 1 without sign or leading zero that fits `u32`.
pub fn key_slug(key: &str) -> Option<&str> {
let rest = key.strip_prefix("item:")?;
let (slug, n) = rest.rsplit_once(':')?;
let canonical_n =
n.bytes().all(|b| b.is_ascii_digit()) && !n.starts_with('0') && n.parse::<u32>().is_ok();
(canonical_n && is_slug(slug)).then_some(slug)
}

View file

@ -0,0 +1,53 @@
//! The entity store's documents. An entity is a named thing a packet may cite by
//! id. Its forms per language are data, citation form first, then inflected or
//! particle-bound forms; no rule here reads what a form means, only its shape.
use cultcache_rs::DatabaseEntry;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::packet::EntityId;
/// An entity's forms in one language, as the corpus item language tag spells it.
/// The citation form comes first.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct LanguageForms {
pub language: String,
/// 1 to 64 bytes each, no control or invisible format character, no edge
/// whitespace, none repeated.
pub forms: Vec<String>,
}
/// Where an entity was minted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum EntityOrigin {
/// From a cast member's fixture: the persona and the fixture path the
/// entity came from.
Cast { persona: String, fixture: String },
/// From a public-domain work.
PublicDomain { work: String },
/// Minted live by the named persona before it names the entity.
Live { persona: String },
}
/// Key: the entity id. Forms only grow: see `EntityStore::put`.
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
#[cultcache(type = "weksa.entity")]
pub struct Entity {
#[cultcache(key = 0)]
pub id: EntityId,
#[cultcache(key = 1)]
pub forms: Vec<LanguageForms>,
#[cultcache(key = 2)]
pub origin: EntityOrigin,
}
impl Entity {
/// The forms stored for `language`, if any.
pub fn forms_in(&self, language: &str) -> Option<&[String]> {
self.forms
.iter()
.find(|f| f.language == language)
.map(|f| f.forms.as_slice())
}
}

199
crates/weksa/src/form.rs Normal file
View file

@ -0,0 +1,199 @@
//! Form types. A form lives in a newtype whose only constructor is a fallible
//! `parse`; deserializing goes through the same parse, so a malformed value can
//! neither be built in Rust nor read back from a store.
use std::fmt;
/// A value that is not of the form its type names.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FormError {
pub form: &'static str,
pub got: String,
}
impl FormError {
pub fn new(form: &'static str, got: impl fmt::Display) -> Self {
Self {
form,
got: got.to_string(),
}
}
}
impl fmt::Display for FormError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?} is not a {}", self.got, self.form)
}
}
impl std::error::Error for FormError {}
/// Wires a string form type (with `parse` and `Display`) to FromStr, serde's
/// `try_from`/`into` String and a published schema.
macro_rules! string_form {
($t:ident, $schema:tt) => {
impl std::str::FromStr for $t {
type Err = $crate::form::FormError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl TryFrom<String> for $t {
type Error = $crate::form::FormError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::parse(&s)
}
}
impl From<$t> for String {
fn from(v: $t) -> String {
v.to_string()
}
}
impl schemars::JsonSchema for $t {
fn schema_name() -> std::borrow::Cow<'static, str> {
stringify!($t).into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!($schema)
}
}
};
}
pub(crate) use string_form;
/// A BCP 47-form language tag: 1 to 35 bytes of ASCII alphanumerics in
/// hyphen-separated subtags of 1 to 8 bytes; the first is 2 to 8 letters, or the
/// single letter `x` of a private-use tag. Form only: no registry is consulted.
pub(crate) fn is_language_tag(s: &str) -> bool {
if s.len() > 35 {
return false;
}
let mut subtags = s.split('-');
let first = subtags.next().unwrap_or("");
let first_ok = first.bytes().all(|b| b.is_ascii_alphabetic())
&& ((2..=8).contains(&first.len()) || first == "x");
first_ok
&& subtags
.all(|t| (1..=8).contains(&t.len()) && t.bytes().all(|b| b.is_ascii_alphanumeric()))
}
/// A slug: `[a-z0-9]` and inner `-`, 1 to 48 bytes, no edge `-`.
pub(crate) fn is_slug(s: &str) -> bool {
let b = s.as_bytes();
(1..=48).contains(&b.len())
&& b.iter()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-')
&& b[0] != b'-'
&& b[b.len() - 1] != b'-'
}
/// A General_Category Cf code point, or the line and paragraph separators
/// U+2028 and U+2029, other than the joiners ZWNJ U+200C and ZWJ U+200D. The
/// ranges are Unicode 15.1 Cf; `tests/format_refused.txt` is the full list from
/// Python's `unicodedata` that the table is swept against.
pub(crate) fn is_format(c: char) -> bool {
matches!(
c,
'\u{ad}'
| '\u{600}'..='\u{605}'
| '\u{61c}'
| '\u{6dd}'
| '\u{70f}'
| '\u{890}'..='\u{891}'
| '\u{8e2}'
| '\u{180e}'
| '\u{200b}'
| '\u{200e}'..='\u{200f}'
| '\u{2028}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206f}'
| '\u{feff}'
| '\u{fff9}'..='\u{fffb}'
| '\u{110bd}'
| '\u{110cd}'
| '\u{13430}'..='\u{1343f}'
| '\u{1bca0}'..='\u{1bca3}'
| '\u{1d173}'..='\u{1d17a}'
| '\u{e0001}'
| '\u{e0020}'..='\u{e007f}'
)
}
/// ZWNJ and ZWJ: Cf, but scripts and emoji sequences need them inside a form.
pub(crate) fn is_joiner(c: char) -> bool {
matches!(c, '\u{200c}' | '\u{200d}')
}
/// A character a name or form may not hold: every `is_format` code point, and
/// the invisible fillers that are Default_Ignorable without being Cf (Hangul
/// U+115F, U+1160, U+3164, U+FFA0 and Khmer U+17B4, U+17B5). Joiners,
/// variation selectors, CGJ and the Mongolian selectors are not refused.
pub(crate) fn is_refused_format(c: char) -> bool {
is_format(c)
|| matches!(
c,
'\u{115f}' | '\u{1160}' | '\u{17b4}' | '\u{17b5}' | '\u{3164}' | '\u{ffa0}'
)
}
/// A joiner, a variation selector (U+FE00..U+FE0F, U+E0100..U+E01EF), CGJ
/// U+034F or a Mongolian free variation selector (U+180B..U+180D, U+180F): it
/// shapes a neighbour and shows nothing alone.
pub(crate) fn is_joining_only(c: char) -> bool {
is_joiner(c)
|| matches!(
c,
'\u{34f}'
| '\u{180b}'..='\u{180d}'
| '\u{180f}'
| '\u{fe00}'..='\u{fe0f}'
| '\u{e0100}'..='\u{e01ef}'
)
}
/// An attestation string (author, persona, fixture, note, work, url, generator,
/// digest): at most 200 bytes, at least one alphanumeric, no control, format or
/// joiner character. Whitespace between words is fine; it attests something.
pub(crate) fn is_attestation(s: &str) -> bool {
s.len() <= 200
&& s.chars().any(char::is_alphanumeric)
&& !s
.chars()
.any(|c| c.is_control() || is_refused_format(c) || is_joiner(c))
}
#[cfg(test)]
mod tests {
use super::*;
/// The rule against every code point: `is_format` is exactly the listed
/// Cf-minus-joiners-plus-U+2028/9 set, so each range has both of its
/// edges and the gaps between ranges pinned.
#[test]
fn format_rule_matches_the_unicode_list_at_every_code_point() {
let listed: std::collections::BTreeSet<u32> = include_str!("../tests/format_refused.txt")
.lines()
.filter(|l| !l.starts_with('#'))
.map(|l| u32::from_str_radix(l, 16).unwrap())
.collect();
for code in 0..=0x10ffff_u32 {
if let Some(c) = char::from_u32(code) {
assert_eq!(is_format(c), listed.contains(&code), "U+{code:X}");
}
}
assert!(!is_format('\u{200c}') && !is_format('\u{200d}'));
}
#[test]
fn an_attestation_is_bounded_readable_text() {
assert!(is_attestation("a"));
assert!(is_attestation(&"x".repeat(200)));
assert!(!is_attestation(&"x".repeat(201)));
assert!(is_attestation("two words, 2 more"));
for bad in [
"", " ", "\t", "\u{200b}", "\u{200d}", "\0", "--", "a\nb", "a\u{ad}b",
] {
assert!(!is_attestation(bad), "{bad:?}");
}
}
}

View file

@ -0,0 +1,510 @@
//! The inventory: the ids packets name, as CultCache documents under appended
//! revisions. An entry is never edited in place; a changed meaning is a retire
//! plus an add, and a retired id still resolves for packets of earlier revisions.
use std::collections::BTreeMap;
use std::fmt;
use std::num::NonZeroU32;
use cultcache_rs::DatabaseEntry;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::form::{FormError, string_form};
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
pub enum Family {
Concept,
Frame,
Role,
Relation,
SpeechAct,
Feature,
ReferentKind,
/// A game- or loop-declared packet field; the entry carries its value kind.
Extension,
}
impl Family {
pub const ALL: [Family; 8] = [
Family::Concept,
Family::Frame,
Family::Role,
Family::Relation,
Family::SpeechAct,
Family::Feature,
Family::ReferentKind,
Family::Extension,
];
pub fn prefix(self) -> &'static str {
match self {
Family::Concept => "concept",
Family::Frame => "frame",
Family::Role => "role",
Family::Relation => "relation",
Family::SpeechAct => "speech_act",
Family::Feature => "feature",
Family::ReferentKind => "referent_kind",
Family::Extension => "extension",
}
}
/// The id of the `n`th entry of this family.
pub fn id(self, n: NonZeroU32) -> InvId {
InvId { family: self, n }
}
}
/// Id of an inventory entry: `<family prefix>:<n>`, `n` a canonical decimal
/// (no sign, no leading zero) of at least 1.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct InvId {
family: Family,
n: NonZeroU32,
}
impl InvId {
pub fn parse(s: &str) -> Result<Self, FormError> {
let bad = || FormError::new("InvId", s);
let (prefix, n) = s.split_once(':').ok_or_else(bad)?;
let family = Family::ALL
.into_iter()
.find(|f| f.prefix() == prefix)
.ok_or_else(bad)?;
if n.starts_with('0') || !n.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad());
}
let n = n.parse::<NonZeroU32>().map_err(|_| bad())?;
Ok(Self { family, n })
}
pub fn family(self) -> Family {
self.family
}
}
impl fmt::Display for InvId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.family.prefix(), self.n)
}
}
string_form!(InvId, { "type": "string", "pattern": "^(concept|frame|role|relation|speech_act|feature|referent_kind|extension):[1-9][0-9]*$" });
/// A value label of an options feature: `^[a-z][a-z0-9_]{0,23}$`.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct OptionLabel(String);
impl OptionLabel {
pub fn parse(s: &str) -> Result<Self, FormError> {
let mut bytes = s.bytes();
let ok = s.len() <= 24
&& bytes.next().is_some_and(|b| b.is_ascii_lowercase())
&& bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
if ok {
Ok(Self(s.to_string()))
} else {
Err(FormError::new("OptionLabel", s))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for OptionLabel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
string_form!(OptionLabel, { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,23}$" });
/// 1 to 16 distinct labels.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "Vec<OptionLabel>", into = "Vec<OptionLabel>")]
pub struct OptionSet(Vec<OptionLabel>);
impl OptionSet {
pub const MAX: usize = 16;
pub fn new(labels: Vec<OptionLabel>) -> Result<Self, FormError> {
let mut distinct: Vec<&OptionLabel> = labels.iter().collect();
distinct.sort();
distinct.dedup();
if (1..=Self::MAX).contains(&labels.len()) && distinct.len() == labels.len() {
Ok(Self(labels))
} else {
Err(FormError::new(
"OptionSet",
format!("{} labels", labels.len()),
))
}
}
pub fn contains(&self, label: &OptionLabel) -> bool {
self.0.contains(label)
}
}
impl TryFrom<Vec<OptionLabel>> for OptionSet {
type Error = FormError;
fn try_from(v: Vec<OptionLabel>) -> Result<Self, FormError> {
Self::new(v)
}
}
impl From<OptionSet> for Vec<OptionLabel> {
fn from(s: OptionSet) -> Self {
s.0
}
}
impl JsonSchema for OptionSet {
fn schema_name() -> std::borrow::Cow<'static, str> {
"OptionSet".into()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "array",
"items": g.subschema_for::<OptionLabel>(),
"minItems": 1,
"maxItems": 16,
"uniqueItems": true
})
}
}
/// Where a feature may sit in a packet.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Slot {
Discourse,
Referent,
Predication,
Relation,
Stance,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ValueKind {
Grade,
Options(OptionSet),
Flag,
}
/// Key: the entry's id, `<family>:<n>`.
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
#[cultcache(type = "weksa.inventory_entry")]
pub struct InventoryEntry {
#[cultcache(key = 0)]
pub id: InvId,
/// 1 to 120 bytes, no control character (checked at admission).
#[cultcache(key = 1)]
pub gloss: String,
/// Present exactly on features and extensions.
#[cultcache(key = 2)]
pub value_kind: Option<ValueKind>,
/// Non-empty exactly on features.
#[cultcache(key = 3)]
pub applies_to: Vec<Slot>,
#[cultcache(key = 4)]
pub introduced_rev: u32,
#[cultcache(key = 5)]
pub retired_rev: Option<u32>,
}
impl InventoryEntry {
pub fn family(&self) -> Family {
self.id.family()
}
/// Whether the entry's shape matches its family: features carry a value kind
/// and slots, extensions a value kind and no slots, nothing else either.
pub fn well_formed(&self) -> bool {
match self.family() {
Family::Feature => self.value_kind.is_some() && !self.applies_to.is_empty(),
Family::Extension => self.value_kind.is_some() && self.applies_to.is_empty(),
_ => self.value_kind.is_none() && self.applies_to.is_empty(),
}
}
/// Whether a packet of inventory revision `rev` may name this entry.
pub fn live_at(&self, rev: u32) -> bool {
self.introduced_rev <= rev && self.retired_rev.is_none_or(|r| r > rev)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Change {
Add(InvId),
Retire(InvId),
}
/// Key: `rev:<n>`.
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
#[cultcache(type = "weksa.inventory_revision")]
pub struct InventoryRevision {
#[cultcache(key = 0)]
pub n: u32,
#[cultcache(key = 1)]
pub parent: Option<u32>,
#[cultcache(key = 2)]
pub changes: Vec<Change>,
#[cultcache(key = 3)]
pub frozen: bool,
}
pub fn revision_key(n: u32) -> String {
format!("rev:{n}")
}
/// The whole inventory as the store holds it.
#[derive(Clone, Debug, Default)]
pub struct Inventory {
entries: BTreeMap<InvId, InventoryEntry>,
revisions: BTreeMap<u32, InventoryRevision>,
}
impl Inventory {
/// An inventory exists only as `InventoryStore::inventory` returns it, so
/// no caller can validate or admit against a fabricated one.
///
/// ```compile_fail,E0624
/// // `Inventory::new` is not callable outside the crate.
/// let _ = weksa::Inventory::new(vec![], vec![]);
/// ```
pub(crate) fn new(entries: Vec<InventoryEntry>, revisions: Vec<InventoryRevision>) -> Self {
Self {
entries: entries.into_iter().map(|e| (e.id, e)).collect(),
revisions: revisions.into_iter().map(|r| (r.n, r)).collect(),
}
}
pub fn entries(&self) -> impl Iterator<Item = &InventoryEntry> {
self.entries.values()
}
pub fn entry(&self, id: &InvId) -> Option<&InventoryEntry> {
self.entries.get(id)
}
pub fn revisions(&self) -> impl Iterator<Item = &InventoryRevision> {
self.revisions.values()
}
pub fn has_revision(&self, n: u32) -> bool {
self.revisions.contains_key(&n)
}
pub fn latest(&self) -> Option<&InventoryRevision> {
self.revisions.values().next_back()
}
/// The entry `id`, if a packet of revision `rev` may name it.
pub fn resolve(&self, id: &InvId, rev: u32) -> Option<&InventoryEntry> {
self.entry(id).filter(|e| e.live_at(rev))
}
}
/// Revision 1: the standard's enumerated values and the nuance features the
/// loop starts from. Concepts start empty.
pub fn seed() -> (InventoryRevision, Vec<InventoryEntry>) {
let mut entries: Vec<InventoryEntry> = Vec::new();
let mut add = |family: Family, gloss: &str, kind: Option<ValueKind>, slots: &[Slot]| {
let taken = entries.iter().filter(|e| e.family() == family).count() as u32;
entries.push(InventoryEntry {
id: family.id(NonZeroU32::MIN.saturating_add(taken)),
gloss: gloss.to_string(),
value_kind: kind,
applies_to: slots.to_vec(),
introduced_rev: 1,
retired_rev: None,
});
};
for g in [
"person",
"group",
"organism",
"place",
"artifact",
"institution",
"ecology",
"route",
"memory",
"event",
"abstract",
] {
add(Family::ReferentKind, g, None, &[]);
}
for g in [
"coreference",
"part_of",
"member_of",
"located_at",
"near",
"before",
"after",
"causes",
"enables",
"prevents",
"owns",
"owes",
"knows",
"names",
"represents",
"contrasts_with",
] {
add(Family::Relation, g, None, &[]);
}
for g in [
"agent",
"patient",
"experiencer",
"instrument",
"source",
"goal",
"location",
"beneficiary",
] {
add(Family::Role, g, None, &[]);
}
for g in [
"assert",
"ask",
"request",
"promise",
"warn",
"greet",
"thank",
"apologise",
] {
add(Family::SpeechAct, g, None, &[]);
}
let options = |labels: &[&str]| {
let labels = labels
.iter()
.map(|l| OptionLabel::parse(l).expect("seed labels are well formed"))
.collect();
Some(ValueKind::Options(
OptionSet::new(labels).expect("seed option sets are well formed"),
))
};
let pred = [Slot::Predication];
add(
Family::Feature,
"polarity",
options(&["positive", "negative"]),
&pred,
);
add(
Family::Feature,
"tense",
options(&["past", "present", "future", "timeless"]),
&pred,
);
add(
Family::Feature,
"aspect",
options(&[
"perfective",
"imperfective",
"progressive",
"habitual",
"perfect",
]),
&pred,
);
add(
Family::Feature,
"modality",
options(&[
"actual",
"potential",
"possible",
"necessary",
"desired",
"obligated",
]),
&pred,
);
add(
Family::Feature,
"evidentiality",
options(&["direct", "inferred", "reported", "assumed"]),
&pred,
);
let nuance = [Slot::Discourse, Slot::Predication];
for g in ["hedge", "certainty"] {
add(Family::Feature, g, Some(ValueKind::Grade), &nuance);
}
add(Family::Feature, "irony", Some(ValueKind::Flag), &nuance);
// Felt affect (permille of how strongly it is felt) and the restraint the speaker
// puts on it: slot Stance only. Affection covers fondness toward the
// addressee. Displayed affect is each language's ontology's business.
let stance = [Slot::Stance];
for g in [
"anger",
"fear",
"sadness",
"joy",
"disgust",
"surprise",
"contempt",
"affection",
"shame",
"restraint",
] {
add(Family::Feature, g, Some(ValueKind::Grade), &stance);
}
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
(
InventoryRevision {
n: 1,
parent: None,
changes,
frozen: false,
},
entries,
)
}
/// Revision 2: the concepts and frames the `nibu-proof-001` packet names, which
/// the seed leaves open. Hand-seeded like [`seed`] until the golf loop's
/// minting round owns concepts and frames.
pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
let mut entries: Vec<InventoryEntry> = Vec::new();
for (family, glosses) in [
(
Family::Concept,
["endanger", "prevent", "player", "control", "ship_mind"].as_slice(),
),
(Family::Frame, ["hazard_warning", "intervention"].as_slice()),
] {
for (i, gloss) in glosses.iter().enumerate() {
entries.push(InventoryEntry {
id: family.id(NonZeroU32::MIN.saturating_add(i as u32)),
gloss: gloss.to_string(),
value_kind: None,
applies_to: vec![],
introduced_rev: 2,
retired_rev: None,
});
}
}
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
(
InventoryRevision {
n: 2,
parent: Some(1),
changes,
frozen: false,
},
entries,
)
}

35
crates/weksa/src/lib.rs Normal file
View file

@ -0,0 +1,35 @@
//! Weksa's typed interlingua state: packet, inventory and corpus documents in
//! CultCache, the validator that decides whether a packet exists, the derived
//! train/validation/test split, the stores that admit them, and the scorer
//! (chrF, meaning cosine, leak diagnostics).
pub mod corpus;
pub mod entity;
pub mod form;
pub mod inventory;
pub mod lower;
pub mod packet;
pub mod schema;
pub mod score;
pub mod split;
pub mod store;
pub mod validate;
pub use corpus::{CorpusItem, CorpusItemId, NameSpan, Source, Turn};
pub use entity::{Entity, EntityOrigin, LanguageForms};
pub use form::FormError;
pub use inventory::{
Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet,
Slot, ValueKind,
};
pub use lower::{LowerError, lower};
pub use packet::{
EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance,
};
pub use score::{Embedder, ItemScore, OllamaEmbedder, Scorer, chrf};
pub use split::{Split, SplitScheme};
pub use store::{
AddOutcome, CorpusStore, EntityBound, EntityStore, InventoryStore, PacketStore, StoreError,
StoreRefusal,
};
pub use validate::{LoweringInput, Refusal, validate};

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

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

553
crates/weksa/src/main.rs Normal file
View file

@ -0,0 +1,553 @@
//! The `weksa` command line: the same validator and store admission the library
//! exposes, plus text projections (`dump`) that are for review and are not stores.
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use unicode_normalization::UnicodeNormalization;
use weksa::corpus::{CorpusItem, ModelFamily, NameSpan, Source, Turn};
use weksa::entity::{Entity, EntityOrigin, LanguageForms};
use weksa::inventory::{proof_revision, seed};
use weksa::packet::proof_packet;
use weksa::score::ScoreInput;
use weksa::{
CorpusStore, EntityId, EntityStore, InventoryStore, OllamaEmbedder, PacketStore, Scorer,
SplitScheme, StoreError, lower, validate,
};
#[derive(Parser)]
#[command(
name = "weksa",
about = "Weksa interlingua packet, inventory and corpus tools"
)]
struct Cli {
/// The corpus store.
#[arg(long, global = true, default_value = "data/corpus/corpus.cc")]
corpus: PathBuf,
/// The inventory store.
#[arg(long, global = true, default_value = "data/interlingua/inventory.cc")]
inventory: PathBuf,
/// The entity store.
#[arg(long, global = true, default_value = "data/entity/entity.cc")]
entities: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Validate the packets in a store (or one packet) against the inventory.
Validate { store: PathBuf, key: Option<String> },
#[command(subcommand)]
Corpus(CorpusCommand),
#[command(subcommand)]
Inventory(InventoryCommand),
#[command(subcommand)]
Entity(EntityCommand),
#[command(subcommand)]
Packet(PacketCommand),
/// Lower a stored packet to English text.
Lower { store: PathBuf, key: String },
/// Score output text (stdin, or --output) against corpus items, one embed call.
Score(ScoreArgs),
/// Print the split of a corpus item.
Split { item_key: String },
/// Write the published JSON Schemas.
Schema {
#[arg(long, default_value = "schemas/cultnet")]
out: PathBuf,
},
}
#[derive(Subcommand)]
enum PacketCommand {
/// Admit the nibu-proof-001 packet through `PacketStore::admit`.
Proof { store: PathBuf },
}
#[derive(Args)]
struct ScoreArgs {
/// Corpus item keys, the references.
#[arg(required = true)]
items: Vec<String>,
/// The text to score; stdin when absent.
#[arg(long)]
output: Option<PathBuf>,
#[arg(long, default_value = "http://127.0.0.1:11434")]
ollama_url: String,
#[arg(long, default_value = "qwen3-embedding:0.6b")]
model: String,
}
#[derive(Subcommand)]
enum CorpusCommand {
Add(Box<AddArgs>),
/// Text projection of the corpus for review; not a store.
Dump,
Withdraw {
key: String,
},
}
#[derive(Subcommand)]
enum InventoryCommand {
/// Text projection of the inventory for review; not a store.
Dump,
/// Admit revision 1, the standard's enumerated values and the nuance features.
Seed,
/// Admit revision 2, the concepts and frames the nibu-proof-001 packet names.
Proof,
}
#[derive(Subcommand)]
enum EntityCommand {
/// Admit an entity, or append forms to a stored one.
Put(PutArgs),
/// Text projection of the entity store for review; not a store.
Dump,
}
#[derive(Args)]
struct PutArgs {
/// `entity:<slug>`.
id: String,
/// `language=form`, repeated; a language's forms keep the order given,
/// citation form first. Forms are NFC-normalized here.
#[arg(long = "form", required = true)]
forms: Vec<String>,
#[arg(long, value_enum)]
origin: OriginKind,
/// The persona of a `cast` or `live` origin; refused with any other origin.
#[arg(long)]
persona: Option<String>,
/// The fixture path a `cast` entity came from; refused with any other origin.
#[arg(long)]
fixture: Option<String>,
/// The work of a `public-domain` origin; refused with any other origin.
#[arg(long)]
work: Option<String>,
}
#[derive(Clone, Copy, ValueEnum)]
enum OriginKind {
Cast,
PublicDomain,
Live,
}
impl PutArgs {
fn entity(self) -> Result<Entity> {
let mut forms: Vec<LanguageForms> = Vec::new();
for f in &self.forms {
let (language, form) = f.split_once('=').context("--form is not language=form")?;
match forms.iter_mut().find(|lf| lf.language == language) {
Some(lf) => lf.forms.push(form.nfc().collect()),
None => forms.push(LanguageForms {
language: language.to_string(),
forms: vec![form.nfc().collect()],
}),
}
}
let origin = match (self.origin, self.persona, self.fixture, self.work) {
(OriginKind::Cast, persona, fixture, None) => EntityOrigin::Cast {
persona: need(persona, "persona")?,
fixture: need(fixture, "fixture")?,
},
(OriginKind::PublicDomain, None, None, work) => EntityOrigin::PublicDomain {
work: need(work, "work")?,
},
(OriginKind::Live, persona, None, None) => EntityOrigin::Live {
persona: need(persona, "persona")?,
},
_ => bail!(
"--fixture is only for --origin cast, --persona only for --origin cast or live and --work only for --origin public-domain"
),
};
Ok(Entity {
id: self.id.parse()?,
forms,
origin,
})
}
}
#[derive(Clone, Copy, ValueEnum)]
enum SourceKind {
CastAuthored,
OperatorWritten,
OperatorApproved,
PublicDomain,
ModelWritten,
}
#[derive(Clone, Copy, ValueEnum)]
enum ModelFamilyArg {
Anthropic,
OpenAi,
Google,
Alibaba,
Meta,
Mistral,
Unrecorded,
}
impl From<ModelFamilyArg> for ModelFamily {
fn from(f: ModelFamilyArg) -> Self {
match f {
ModelFamilyArg::Anthropic => ModelFamily::Anthropic,
ModelFamilyArg::OpenAi => ModelFamily::OpenAi,
ModelFamilyArg::Google => ModelFamily::Google,
ModelFamilyArg::Alibaba => ModelFamily::Alibaba,
ModelFamilyArg::Meta => ModelFamily::Meta,
ModelFamilyArg::Mistral => ModelFamily::Mistral,
ModelFamilyArg::Unrecorded => ModelFamily::Unrecorded,
}
}
}
#[derive(Args)]
struct AddArgs {
#[arg(long)]
slug: String,
/// NFC-normalized here; `--name` byte offsets index the normalized text.
#[arg(long)]
text: String,
#[arg(long, default_value = "en-US")]
language: String,
/// An entity id.
#[arg(long)]
speaker: EntityId,
/// `start..end=entity:<slug>`, repeated: a byte range of the text naming an entity.
#[arg(long = "name")]
names: Vec<String>,
#[arg(long)]
group: String,
/// `speaker=text`, repeated, oldest first.
#[arg(long = "prior-turn")]
prior_turns: Vec<String>,
#[arg(long, value_enum)]
source: SourceKind,
#[arg(long)]
persona: Option<String>,
#[arg(long)]
fixture: Option<String>,
#[arg(long)]
note: Option<String>,
#[arg(long)]
work: Option<String>,
/// Who wrote the line: for `cast-authored` and `public-domain`.
#[arg(long)]
author: Option<String>,
#[arg(long)]
edition_url: Option<String>,
#[arg(long = "model-family", value_enum)]
family: Option<ModelFamilyArg>,
/// The generator string the fixture or run wrote, verbatim.
#[arg(long)]
generator: Option<String>,
#[arg(long)]
prompt_digest: Option<String>,
#[arg(long)]
admitted_on: String,
#[arg(long)]
supersedes: Option<String>,
}
fn need(value: Option<String>, flag: &str) -> Result<String> {
value.with_context(|| format!("this source needs --{flag}"))
}
impl AddArgs {
fn source(&self) -> Result<Source> {
let get = |v: &Option<String>, flag: &str| need(v.clone(), flag);
let applies: &[&str] = match self.source {
SourceKind::CastAuthored => &["persona", "fixture", "author"],
SourceKind::OperatorWritten => &[],
SourceKind::OperatorApproved => &["note"],
SourceKind::PublicDomain => &["work", "author", "edition-url"],
SourceKind::ModelWritten => &["model-family", "generator", "prompt-digest"],
};
let given = [
("persona", self.persona.is_some()),
("fixture", self.fixture.is_some()),
("author", self.author.is_some()),
("note", self.note.is_some()),
("work", self.work.is_some()),
("edition-url", self.edition_url.is_some()),
("model-family", self.family.is_some()),
("generator", self.generator.is_some()),
("prompt-digest", self.prompt_digest.is_some()),
];
let stray: Vec<String> = given
.into_iter()
.filter(|(flag, given)| *given && !applies.contains(flag))
.map(|(flag, _)| format!("--{flag}"))
.collect();
if !stray.is_empty() {
bail!("{} does not apply to this --source", stray.join(", "));
}
Ok(match self.source {
SourceKind::CastAuthored => Source::CastAuthored {
persona: get(&self.persona, "persona")?,
fixture: get(&self.fixture, "fixture")?,
author: get(&self.author, "author")?,
},
SourceKind::OperatorWritten => Source::OperatorWritten,
SourceKind::OperatorApproved => Source::OperatorApproved {
note: get(&self.note, "note")?,
},
SourceKind::PublicDomain => Source::PublicDomain {
work: get(&self.work, "work")?,
author: get(&self.author, "author")?,
edition_url: get(&self.edition_url, "edition-url")?,
},
SourceKind::ModelWritten => Source::ModelWritten {
family: self
.family
.map(ModelFamily::from)
.context("this source needs --model-family")?,
generator: self.generator.clone(),
prompt_digest: self.prompt_digest.clone(),
},
})
}
fn item(self, id: String) -> Result<CorpusItem> {
let prior_turns = self
.prior_turns
.iter()
.map(|t| {
let (speaker, text) = t
.split_once('=')
.context("--prior-turn is not speaker=text")?;
Ok(Turn {
speaker: speaker.parse().map_err(|_| {
anyhow::anyhow!("--prior-turn speaker is not entity:<slug>")
})?,
text: text.nfc().collect(),
})
})
.collect::<Result<Vec<_>>>()?;
let names = self
.names
.iter()
.map(|n| {
let bad = || anyhow::anyhow!("--name is not start..end=entity:<slug>");
let (range, entity) = n.split_once('=').ok_or_else(bad)?;
let (start, end) = range.split_once("..").ok_or_else(bad)?;
Ok(NameSpan {
entity: entity.parse().map_err(|_| bad())?,
start: start.parse().map_err(|_| bad())?,
end: end.parse().map_err(|_| bad())?,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(CorpusItem {
id,
source: self.source()?,
text: self.text.nfc().collect(),
language: self.language,
speaker: self.speaker,
group: self.group,
prior_turns,
admitted_on: self.admitted_on,
supersedes: self.supersedes,
withdrawn: false,
names,
})
}
}
/// The split of an item as `Train`, `Validation`, `Test` or `excluded`.
fn split_text(item: &CorpusItem) -> String {
SplitScheme::V1
.split(item)
.map_or_else(|| "excluded".to_string(), |s| format!("{s:?}"))
}
fn refused(e: StoreError) -> anyhow::Error {
anyhow::anyhow!("{e}")
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Validate { store, key } => {
let inventory = InventoryStore::open(&cli.inventory)?.inventory()?;
let packets = PacketStore::open(&store)?;
let packets = match key {
Some(key) => vec![
packets
.get(&key)?
.with_context(|| format!("no packet {key}"))?,
],
None => packets.all()?,
};
let mut bad = 0;
for p in &packets {
match validate(p, &inventory) {
Ok(_) => println!("ok {}", p.id),
Err(refusals) => {
bad += 1;
for r in refusals {
println!("refused {} {r}", p.id);
}
}
}
}
if bad > 0 {
bail!("{bad} of {} packets refused", packets.len());
}
}
Command::Corpus(CorpusCommand::Add(args)) => {
let mut store = CorpusStore::open(&cli.corpus)?;
let key = store.next_key(&args.slug).map_err(refused)?;
let entities = EntityStore::open(&cli.entities)?;
let outcome = store
.add((*args).item(key.clone())?, &entities)
.map_err(refused)?;
println!("{outcome:?} {key}");
}
Command::Corpus(CorpusCommand::Dump) => {
for i in CorpusStore::open(&cli.corpus)?.all()? {
let split = split_text(&i);
let withdrawn = if i.withdrawn { " withdrawn" } else { "" };
println!(
"{} [{} {} group={} {split}{withdrawn}]\n {:?}\n names: {:?}\n source: {:?}",
i.id, i.language, i.speaker, i.group, i.text, i.names, i.source
);
}
}
Command::Corpus(CorpusCommand::Withdraw { key }) => {
CorpusStore::open(&cli.corpus)?
.withdraw(&key)
.map_err(refused)?;
println!("withdrawn {key}");
}
Command::Inventory(InventoryCommand::Dump) => {
let inv = InventoryStore::open(&cli.inventory)?.inventory()?;
for r in inv.revisions() {
println!(
"rev:{} parent={:?} frozen={} changes={}",
r.n,
r.parent,
r.frozen,
r.changes.len()
);
}
for e in inv.entries() {
println!(
"{} introduced={} retired={:?} {:?} {:?} {}",
e.id, e.introduced_rev, e.retired_rev, e.value_kind, e.applies_to, e.gloss
);
}
}
Command::Entity(EntityCommand::Put(args)) => {
let entity = args.entity()?;
let id = entity.id.clone();
let outcome = EntityStore::open(&cli.entities)?
.put(entity)
.map_err(refused)?;
println!("{outcome:?} {id}");
}
Command::Entity(EntityCommand::Dump) => {
for e in EntityStore::open(&cli.entities)?.all()? {
println!("{} {:?}\n forms: {:?}", e.id, e.origin, e.forms);
}
}
Command::Inventory(InventoryCommand::Seed) => {
let (rev, entries) = seed();
let count = entries.len();
InventoryStore::open(&cli.inventory)?
.admit_revision(rev, entries)
.map_err(refused)?;
println!("seeded rev:1 with {count} entries");
}
Command::Inventory(InventoryCommand::Proof) => {
let (rev, entries) = proof_revision();
let count = entries.len();
InventoryStore::open(&cli.inventory)?
.admit_revision(rev, entries)
.map_err(refused)?;
println!("admitted rev:2 with {count} entries");
}
Command::Packet(PacketCommand::Proof { store }) => {
let packet = proof_packet();
let key = packet.id.to_string();
PacketStore::open(&store)?
.admit(
packet,
&InventoryStore::open(&cli.inventory)?,
&CorpusStore::open(&cli.corpus)?,
&EntityStore::open(&cli.entities)?,
)
.map_err(refused)?;
println!("admitted {key}");
}
Command::Lower { store, key } => {
let inventory_store = InventoryStore::open(&cli.inventory)?;
let inventory = inventory_store.inventory()?;
let mut packets = PacketStore::open(&store)?;
let packet = packets
.get(&key)?
.with_context(|| format!("no packet {key}"))?;
// Admission is the only source of a LoweringInput; re-admitting the
// stored packet is a no-op write that re-runs every door check.
let view = packets
.admit(
packet,
&inventory_store,
&CorpusStore::open(&cli.corpus)?,
&EntityStore::open(&cli.entities)?,
)
.map_err(refused)?;
println!("{}", lower(&view, &inventory)?);
}
Command::Score(args) => {
let output = match &args.output {
Some(path) => std::fs::read_to_string(path)?,
None => std::io::read_to_string(std::io::stdin())?,
};
let corpus = CorpusStore::open(&cli.corpus)?;
let references = args
.items
.iter()
.map(|key| {
Ok(corpus
.get(key)?
.with_context(|| format!("no item {key}"))?
.text)
})
.collect::<Result<Vec<_>>>()?;
let inputs: Vec<ScoreInput> = references
.iter()
.map(|reference| ScoreInput {
reference,
output: output.trim(),
})
.collect();
let scorer = Scorer::new(OllamaEmbedder::new(&args.ollama_url, &args.model));
for (key, score) in args.items.iter().zip(scorer.score(&inputs)?) {
match score.meaning {
Some(m) => println!("{key} chrf={:.2} meaning={m:.4}", score.chrf),
None => println!("{key} chrf={:.2} meaning=none", score.chrf),
}
}
}
Command::Split { item_key } => {
let item = CorpusStore::open(&cli.corpus)?
.get(&item_key)?
.with_context(|| format!("no item {item_key}"))?;
println!("{}", split_text(&item));
}
Command::Schema { out } => {
std::fs::create_dir_all(&out)?;
for (name, json) in weksa::schema::published() {
std::fs::write(out.join(&name), json)?;
println!("wrote {name}");
}
}
}
Ok(())
}

432
crates/weksa/src/packet.rs Normal file
View file

@ -0,0 +1,432 @@
//! The interlingua packet. Every id is an opaque form type: a node id `n<k>`, a
//! packet id `packet:<hex16>`, an encoder id `encoder:<hex16>`, an inventory id
//! `<family>:<n>`. The only free text is the trace, which is stripped before
//! lowering. A malformed id cannot be built, and cannot be read back from a
//! store, so a packet cannot carry target-language wording through an id slot.
use std::fmt;
use cultcache_rs::DatabaseEntry;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::corpus::CorpusItemId;
use crate::form::{FormError, is_slug, string_form};
pub use crate::inventory::InvId;
use crate::inventory::OptionLabel;
/// Bumped by a commit that changes these types; the published schema moves with it.
pub const SCHEMA_VERSION: u32 = 4;
/// Id of a referent or predication within one packet: `n<k>`, `k` a canonical
/// decimal 1..=9999.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct NodeId(String);
impl NodeId {
pub fn parse(s: &str) -> Result<Self, FormError> {
let k = s
.strip_prefix('n')
.filter(|d| !d.starts_with('0') && d.bytes().all(|b| b.is_ascii_digit()))
.and_then(|d| d.parse::<u16>().ok())
.filter(|k| (1..=9999).contains(k));
k.map(|_| Self(s.to_string()))
.ok_or_else(|| FormError::new("NodeId", s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for NodeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
string_form!(NodeId, { "type": "string", "pattern": "^n[1-9][0-9]{0,3}$" });
/// `<prefix>:<16 lowercase hex>`.
fn hex16(prefix: &str, s: &str) -> bool {
s.strip_prefix(prefix)
.is_some_and(|h| h.len() == 16 && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
}
/// Key of a packet: `packet:<hex16>`, a digest the loop derives from the item,
/// the inventory revision and the encoder.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PacketId(String);
impl PacketId {
pub fn parse(s: &str) -> Result<Self, FormError> {
if hex16("packet:", s) {
Ok(Self(s.to_string()))
} else {
Err(FormError::new("PacketId", s))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for PacketId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
string_form!(PacketId, { "type": "string", "pattern": "^packet:[0-9a-f]{16}$" });
/// `encoder:<hex16>`, a digest of the model id and the prompt digest.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct EncoderId(String);
impl EncoderId {
pub fn parse(s: &str) -> Result<Self, FormError> {
if hex16("encoder:", s) {
Ok(Self(s.to_string()))
} else {
Err(FormError::new("EncoderId", s))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for EncoderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
string_form!(EncoderId, { "type": "string", "pattern": "^encoder:[0-9a-f]{16}$" });
/// Id of an entity: `entity:<slug>`, a slug as an item key's (`[a-z0-9]` and
/// inner `-`, 1 to 48 bytes, no edge `-`).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct EntityId(String);
impl EntityId {
pub fn parse(s: &str) -> Result<Self, FormError> {
match s.strip_prefix("entity:") {
Some(slug) if is_slug(slug) => Ok(Self(s.to_string())),
_ => Err(FormError::new("EntityId", s)),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for EntityId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
string_form!(EntityId, { "type": "string", "pattern": "^entity:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$" });
/// Permille, 0..=1000.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "u16", into = "u16")]
pub struct Grade(u16);
impl Grade {
pub const MAX: u16 = 1000;
pub fn new(permille: u16) -> Result<Self, FormError> {
if permille <= Self::MAX {
Ok(Self(permille))
} else {
Err(FormError::new("Grade", permille))
}
}
pub fn get(self) -> u16 {
self.0
}
}
impl TryFrom<u16> for Grade {
type Error = FormError;
fn try_from(v: u16) -> Result<Self, FormError> {
Self::new(v)
}
}
impl From<Grade> for u16 {
fn from(g: Grade) -> u16 {
g.0
}
}
impl JsonSchema for Grade {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Grade".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({ "type": "integer", "minimum": 0, "maximum": 1000 })
}
}
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
#[cultcache(type = "weksa.interlingua_packet")]
pub struct Packet {
#[cultcache(key = 0)]
pub id: PacketId,
#[cultcache(key = 1)]
pub schema_version: u32,
#[cultcache(key = 2)]
pub inventory_rev: u32,
#[cultcache(key = 3)]
pub kind: PacketKind,
#[cultcache(key = 4)]
pub provenance: Provenance,
#[cultcache(key = 5)]
pub context: Context,
#[cultcache(key = 6)]
pub discourse: Discourse,
#[cultcache(key = 7)]
pub referents: Vec<Referent>,
#[cultcache(key = 8)]
pub predications: Vec<Predication>,
#[cultcache(key = 9)]
pub relations: Vec<Relation>,
#[cultcache(key = 10)]
pub constraints: Constraints,
#[cultcache(key = 11)]
pub extensions: Vec<Extension>,
#[cultcache(key = 12)]
pub trace: Trace,
#[cultcache(key = 13)]
pub stance: Stance,
}
impl Packet {
/// The most referents, predications and relations one packet holds: a
/// calibration constant of ours, capacity and not meaning.
pub const MAX_NODES: usize = 64;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum PacketKind {
Utterance,
NameRequest,
CommentaryIntent,
Narration,
DialogueLine,
CorpusExample,
DebugProbe,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ProvenanceSource {
Authored,
ModelDecomposition,
GameTelemetry,
ImportedCorpus,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Provenance {
pub source: ProvenanceSource,
/// The corpus item this packet was minted for. Required: the packet store
/// admits only packets that name their item.
pub corpus_item: CorpusItemId,
pub encoder: EncoderId,
pub confidence: Grade,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Context {
/// Concept ids.
pub setting: Option<InvId>,
pub medium: Option<InvId>,
pub participants: Vec<NodeId>,
pub audience: Vec<NodeId>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Discourse {
/// Speech-act id.
pub speech_act: Option<InvId>,
pub addressee: Option<NodeId>,
pub features: Vec<FeatureValue>,
}
/// The speaker's felt affect and restraint, never displayed affect: how the
/// speaker shows a feeling is each language's ontology's business. A
/// held-back feeling is not a claim, so it is never a predication.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Stance {
pub features: Vec<FeatureValue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct FeatureValue {
/// Feature id.
pub feature: InvId,
pub value: FeatValue,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum FeatValue {
Grade(Grade),
/// A label the feature's inventory entry declares.
Option(OptionLabel),
Flag(bool),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Referent {
pub id: NodeId,
/// Referent-kind id.
pub kind: InvId,
/// Concept id.
pub concept: InvId,
/// The entity this referent names, when it has one. The entity store holds
/// its wording; the packet holds only the id.
pub entity: Option<EntityId>,
pub features: Vec<FeatureValue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Predication {
pub id: NodeId,
/// Concept id.
pub concept: InvId,
/// Frame id.
pub frame: InvId,
pub roles: Vec<RoleFill>,
pub features: Vec<FeatureValue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct RoleFill {
/// Role id.
pub role: InvId,
pub filler: NodeId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Relation {
/// Relation id.
pub relation: InvId,
pub from: NodeId,
pub to: NodeId,
pub features: Vec<FeatureValue>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Constraints {
pub preserve: Vec<NodeId>,
pub deterministic: bool,
pub max_latency_ms: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Extension {
/// Extension-entry id; the inventory declares its value kind.
pub key: InvId,
pub value: FeatValue,
}
/// Authoring and debug notes. Stripped before lowering.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Trace {
pub notes: Vec<String>,
pub rejected_readings: Vec<String>,
}
/// The `nibu-proof-001` packet: the meaning of `examples/english-lowering/nibu-proof-001/interlingua.yaml`
/// (warn the player off the damaged control; Nibu prevents it for them) as typed
/// ids of inventory revision 2, for the corpus item `item:nibu:1`. No features:
/// nothing here is style.
pub fn proof_packet() -> Packet {
fn id(s: &str) -> InvId {
InvId::parse(s).expect("proof ids are well formed")
}
fn node(s: &str) -> NodeId {
NodeId::parse(s).expect("proof nodes are well formed")
}
let referent = |n: &str, kind: &str, concept: &str, entity: Option<&str>| Referent {
id: node(n),
kind: id(kind),
concept: id(concept),
entity: entity.map(|e| EntityId::parse(e).expect("proof entity is well formed")),
features: vec![],
};
let fill = |role: &str, filler: &str| RoleFill {
role: id(role),
filler: node(filler),
};
Packet {
id: PacketId::parse("packet:6e6962752d303031").expect("proof id"),
schema_version: SCHEMA_VERSION,
inventory_rev: 2,
kind: PacketKind::DialogueLine,
provenance: Provenance {
source: ProvenanceSource::Authored,
corpus_item: CorpusItemId::parse("item:nibu:1").expect("proof item"),
encoder: EncoderId::parse("encoder:6f70657261746f72").expect("proof encoder"),
confidence: Grade::new(900).expect("proof grade"),
},
context: Context::default(),
discourse: Discourse {
speech_act: Some(id("speech_act:5")),
addressee: Some(node("n1")),
features: vec![],
},
referents: vec![
referent("n1", "referent_kind:1", "concept:3", None),
referent("n2", "referent_kind:5", "concept:4", None),
referent("n3", "referent_kind:1", "concept:5", Some("entity:nibu")),
],
predications: vec![
Predication {
id: node("n4"),
concept: id("concept:1"),
frame: id("frame:1"),
roles: vec![
fill("role:1", "n1"),
fill("role:4", "n2"),
fill("role:2", "n1"),
],
features: vec![],
},
Predication {
id: node("n5"),
concept: id("concept:2"),
frame: id("frame:2"),
roles: vec![
fill("role:1", "n3"),
fill("role:2", "n4"),
fill("role:8", "n1"),
],
features: vec![],
},
],
relations: vec![],
constraints: Constraints {
preserve: vec![node("n1"), node("n2")],
deterministic: true,
max_latency_ms: None,
},
extensions: vec![],
trace: Trace {
notes: vec!["First proof packet for Nibu flavored-English lowering.".to_string()],
rejected_readings: vec![],
},
stance: Stance::default(),
}
}

View file

@ -0,0 +1,25 @@
//! JSON Schema publication. JSON is only the xenos-boundary export of the typed
//! documents; nothing in this crate reads JSON.
use crate::corpus::CorpusItem;
use crate::entity::Entity;
use crate::inventory::{InventoryEntry, InventoryRevision};
use crate::packet::Packet;
use cultcache_rs::DatabaseEntry;
use schemars::{JsonSchema, schema_for};
fn published_for<T: DatabaseEntry + JsonSchema>() -> (String, String) {
let json = serde_json::to_string_pretty(&schema_for!(T)).expect("a schema serializes");
(format!("{}.schema.json", T::TYPE), format!("{json}\n"))
}
/// `(file name, contents)` for every document type, as `schemas/cultnet/` holds them.
pub fn published() -> Vec<(String, String)> {
vec![
published_for::<Packet>(),
published_for::<InventoryEntry>(),
published_for::<InventoryRevision>(),
published_for::<CorpusItem>(),
published_for::<Entity>(),
]
}

View file

@ -0,0 +1,60 @@
//! chrF as sacrebleu 2.4.3 computes it by default: character n-gram orders 1 to
//! 6, whitespace removed, case kept, beta 2.
use std::collections::HashMap;
/// The metric this module implements; a score stored beside it names its definition.
pub const CHRF_VERSION: &str = "chrF2|nc:6|nw:0|space:no|sacrebleu:2.4.3";
const ORDER: usize = 6;
const BETA_SQ: f64 = 4.0;
fn grams(chars: &[char], n: usize) -> HashMap<&[char], u32> {
let mut counts = HashMap::new();
for window in chars.windows(n) {
*counts.entry(window).or_insert(0) += 1;
}
counts
}
/// Python `str.split`'s whitespace, which sacrebleu strips before gramming:
/// Unicode White_Space plus U+001C..=U+001F.
pub(super) fn is_split_space(c: char) -> bool {
c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
}
/// chrF of `hypothesis` against `reference`, 0 to 100. Beta 2 weights recall,
/// so the arguments are not interchangeable. 0 when no order has n-grams on
/// both sides.
pub fn chrf(hypothesis: &str, reference: &str) -> f64 {
let strip = |s: &str| {
s.chars()
.filter(|c| !is_split_space(*c))
.collect::<Vec<char>>()
};
let (hyp, reference) = (strip(hypothesis), strip(reference));
let (mut precision, mut recall, mut orders) = (0.0, 0.0, 0u32);
for n in 1..=ORDER {
let (hyp_grams, ref_grams) = (grams(&hyp, n), grams(&reference, n));
let (hyp_total, ref_total): (u32, u32) =
(hyp_grams.values().sum(), ref_grams.values().sum());
if hyp_total == 0 || ref_total == 0 {
continue;
}
let matched: u32 = hyp_grams
.iter()
.map(|(gram, count)| (*count).min(ref_grams.get(gram).copied().unwrap_or(0)))
.sum();
precision += f64::from(matched) / f64::from(hyp_total);
recall += f64::from(matched) / f64::from(ref_total);
orders += 1;
}
if orders == 0 {
return 0.0;
}
let (p, r) = (precision / f64::from(orders), recall / f64::from(orders));
if p + r == 0.0 {
return 0.0;
}
100.0 * (1.0 + BETA_SQ) * p * r / (BETA_SQ * p + r)
}

View file

@ -0,0 +1,169 @@
//! Leak diagnostics: values reported beside scores, never refusals. Pooled
//! coverage at n >= 2 above zero beside single-word glosses, or a high pairwise
//! chrF, means a flow bug or a corpus duplicate across splits.
use std::collections::HashSet;
use super::chrf::chrf;
use super::words::words;
use crate::corpus::CorpusItem;
use crate::split::{Split, SplitScheme};
/// Word n-gram orders read, 1 to this.
const ORDERS: usize = 3;
/// The pair that reached the highest chrF.
#[derive(Clone, Debug, PartialEq)]
pub struct PairChrf {
pub chrf: f64,
pub minted: String,
pub item: String,
}
/// One word-order's pooled novel coverage over a split's items.
#[derive(Clone, Debug, PartialEq)]
pub struct OrderCoverage {
pub n: usize,
/// Distinct n-grams of the split's items absent from Train, summed over items.
pub novel: usize,
/// Of those, the ones that occur in the minted text.
pub covered: usize,
/// `covered / novel`; `None` when `novel` is 0, never NaN.
pub share: Option<f64>,
}
/// The item that read most at `n` >= 2.
#[derive(Clone, Debug, PartialEq)]
pub struct WorstItem {
pub item: String,
pub n: usize,
pub covered: usize,
pub novel: usize,
}
/// One held-out split's readings.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct SplitLeak {
/// Items of the split.
pub items: usize,
/// Max chrF of any minted string (hypothesis) against any item (reference).
pub max_chrf: Option<PairChrf>,
/// Pooled novel coverage for n in 1..=3, in order.
pub coverage_by_n: Vec<OrderCoverage>,
/// The item and order (n >= 2) with the largest covered count, then the
/// larger share; the first item, then the smaller n, on ties. `None` when
/// no item has a covered n-gram at n >= 2.
pub worst: Option<WorstItem>,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct LeakDiagnostic {
pub validation: SplitLeak,
pub test: SplitLeak,
}
/// The distinct word n-grams of a text for each order 1..=ORDERS, over its
/// normalised word segments.
fn grams(text: &str) -> Vec<HashSet<String>> {
let keys = words(text);
(1..=ORDERS)
.map(|n| keys.windows(n).map(|w| w.join(" ")).collect())
.collect()
}
fn union<'a>(texts: impl Iterator<Item = &'a str>) -> Vec<HashSet<String>> {
let mut all = vec![HashSet::new(); ORDERS];
for text in texts {
for (into, from) in all.iter_mut().zip(grams(text)) {
into.extend(from);
}
}
all
}
fn split_leak(
minted: &[&str],
train: &[HashSet<String>],
minted_grams: &[HashSet<String>],
items: &[&CorpusItem],
) -> SplitLeak {
let mut leak = SplitLeak {
items: items.len(),
..SplitLeak::default()
};
let mut totals = [(0usize, 0usize); ORDERS];
for item in items {
for m in minted {
let score = chrf(m, &item.text);
if leak.max_chrf.as_ref().is_none_or(|best| score > best.chrf) {
leak.max_chrf = Some(PairChrf {
chrf: score,
minted: (*m).to_string(),
item: item.id.clone(),
});
}
}
for (i, item_grams) in grams(&item.text).iter().enumerate() {
let novel: Vec<&String> = item_grams
.iter()
.filter(|g| !train[i].contains(*g))
.collect();
let covered = novel
.iter()
.filter(|g| minted_grams[i].contains(**g))
.count();
totals[i].0 += novel.len();
totals[i].1 += covered;
if i == 0 || covered == 0 {
continue;
}
let share = covered as f64 / novel.len() as f64;
let better = leak.worst.as_ref().is_none_or(|w| {
covered > w.covered
|| (covered == w.covered && share > w.covered as f64 / w.novel as f64)
});
if better {
leak.worst = Some(WorstItem {
item: item.id.clone(),
n: i + 1,
covered,
novel: novel.len(),
});
}
}
}
leak.coverage_by_n = totals
.iter()
.enumerate()
.map(|(i, (novel, covered))| OrderCoverage {
n: i + 1,
novel: *novel,
covered: *covered,
share: (*novel > 0).then(|| *covered as f64 / *novel as f64),
})
.collect();
leak
}
/// Reads minted text (inventory glosses, option labels, later ontology text)
/// against the held-out items. Per held-out item and for n in 1..=3, its word
/// n-grams that occur in no Train item are its novel n-grams; coverage at each
/// n is the covered over the novel n-grams summed across the split's items
/// (unigrams over all minted strings, longer grams within one string). Honest
/// single-word glosses saturate n = 1, so the leak signature is pooled n >= 2
/// and the worst item at n >= 2. Train items are not measured; items in no
/// split are dropped.
pub fn leak_diagnostic(minted: &[&str], items: &[CorpusItem]) -> LeakDiagnostic {
let of = |want: Split| -> Vec<&CorpusItem> {
items
.iter()
.filter(|i| SplitScheme::V1.split(i) == Some(want))
.collect()
};
let train = union(of(Split::Train).iter().map(|i| i.text.as_str()));
let minted_grams = union(minted.iter().copied());
LeakDiagnostic {
validation: split_leak(minted, &train, &minted_grams, &of(Split::Validation)),
test: split_leak(minted, &train, &minted_grams, &of(Split::Test)),
}
}

View file

@ -0,0 +1,113 @@
//! The meaning metric's embedding port and its Ollama adapter.
use std::time::Duration;
use anyhow::{Context, Result, bail, ensure};
use serde::Deserialize;
use serde_json::json;
/// Sentence embeddings. Every embedder (Ollama now, a style embedder later)
/// enters the scorer through this port.
pub trait Embedder {
fn model(&self) -> &str;
/// One vector per text, in order, all of one dimension.
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
}
/// Cosine similarity. Precondition: `a` and `b` have one equal nonzero length
/// and nonzero norm; `Scorer::score` refuses embedder output that does not.
/// Violating it panics rather than reading as a score.
pub fn cosine(a: &[f32], b: &[f32]) -> f64 {
assert!(
!a.is_empty() && a.len() == b.len(),
"cosine needs vectors of one nonzero length, got {} and {}",
a.len(),
b.len()
);
let dot: f64 = a
.iter()
.zip(b)
.map(|(x, y)| f64::from(*x) * f64::from(*y))
.sum();
let norm = |v: &[f32]| v.iter().map(|x| f64::from(*x).powi(2)).sum::<f64>().sqrt();
let denom = norm(a) * norm(b);
assert!(denom > 0.0, "cosine of a zero vector");
dot / denom
}
/// Ollama's `/api/embed` over plain HTTP. One server serves Huginn and the
/// loop, so batches stay small and `num_thread` is sent (without it a batch of
/// 32 timed out at 120 s under host load).
#[derive(Clone, Debug)]
pub struct OllamaEmbedder {
pub url: String,
pub model: String,
/// Sent as `options.num_thread`; omitted when `None`.
pub num_thread: Option<u32>,
/// Texts per request, clamped to 1..=32.
pub batch: usize,
}
impl OllamaEmbedder {
pub const MAX_BATCH: usize = 32;
pub fn new(url: impl Into<String>, model: impl Into<String>) -> Self {
Self {
url: url.into(),
model: model.into(),
num_thread: Some(8),
batch: Self::MAX_BATCH,
}
}
fn request(&self, agent: &ureq::Agent, batch: &[&str]) -> Result<Vec<Vec<f32>>> {
let mut body = json!({ "model": self.model, "input": batch });
if let Some(threads) = self.num_thread {
body["options"] = json!({ "num_thread": threads });
}
let endpoint = format!("{}/api/embed", self.url.trim_end_matches('/'));
let mut response = agent
.post(&endpoint)
.header("Content-Type", "application/json")
.send(body.to_string())
.with_context(|| format!("POST {endpoint}"))?;
#[derive(Deserialize)]
struct Reply {
embeddings: Vec<Vec<f32>>,
}
let reply: Reply = serde_json::from_str(&response.body_mut().read_to_string()?)
.context("ollama /api/embed reply")?;
ensure!(
reply.embeddings.len() == batch.len(),
"asked for {} embeddings, got {}",
batch.len(),
reply.embeddings.len()
);
Ok(reply.embeddings)
}
}
impl Embedder for OllamaEmbedder {
fn model(&self) -> &str {
&self.model
}
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(120)))
.build()
.into();
let mut out: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
for batch in texts.chunks(self.batch.clamp(1, Self::MAX_BATCH)) {
out.extend(self.request(&agent, batch)?);
}
let dim = out[0].len();
if dim == 0 || out.iter().any(|v| v.len() != dim) {
bail!("embeddings disagree on dimension");
}
Ok(out)
}
}

View file

@ -0,0 +1,144 @@
//! What a score counts: chrF and the meaning cosine of the whole lowered
//! output against the whole reference. Nothing is deleted, hidden or replaced
//! before either is read, and the scorer takes no input from a packet, so no
//! text the output carries can raise a score except by resembling the
//! reference. No door judges text; the diagnostics report.
mod chrf;
mod diagnostics;
mod embed;
mod words;
use anyhow::{Result, ensure};
pub use chrf::{CHRF_VERSION, chrf};
pub use diagnostics::{
LeakDiagnostic, OrderCoverage, PairChrf, SplitLeak, WorstItem, leak_diagnostic,
};
pub use embed::{Embedder, OllamaEmbedder, cosine};
use crate::corpus::CorpusItem;
use crate::split::{Split, SplitScheme};
/// One reference/output pair; both are scored whole.
pub struct ScoreInput<'a> {
pub reference: &'a str,
pub output: &'a str,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ItemScore {
/// chrF of the output against the reference.
pub chrf: f64,
/// Embedding cosine of the output and the reference; `None` when either
/// is empty after trimming.
pub meaning: Option<f64>,
}
pub struct Scorer<E: Embedder> {
embedder: E,
}
/// Every vector has one nonzero length and a nonzero norm.
fn check_vectors(vectors: &[Vec<f32>]) -> Result<()> {
let dim = vectors.first().map_or(0, Vec::len);
for vector in vectors {
ensure!(
dim > 0 && vector.len() == dim,
"embedder returned vectors of length {} and {}",
dim,
vector.len()
);
let norm: f64 = vector.iter().map(|x| f64::from(*x).powi(2)).sum();
ensure!(
norm > 0.0 && norm.is_finite(),
"embedder returned a zero or non-finite vector"
);
}
Ok(())
}
impl<E: Embedder> Scorer<E> {
pub fn new(embedder: E) -> Self {
Self { embedder }
}
pub fn embedder(&self) -> &E {
&self.embedder
}
/// Scores every input with one embed call; the caller chunks `inputs`.
/// Errs when the embedder's vectors disagree in length or are empty or zero.
pub fn score(&self, inputs: &[ScoreInput<'_>]) -> Result<Vec<ItemScore>> {
let mut texts: Vec<&str> = Vec::new();
let slots: Vec<Option<usize>> = inputs
.iter()
.map(|input| {
if input.output.trim().is_empty() || input.reference.trim().is_empty() {
return None;
}
texts.push(input.reference);
texts.push(input.output);
Some(texts.len() - 2)
})
.collect();
let vectors = self.embedder.embed(&texts)?;
ensure!(
vectors.len() == texts.len(),
"embedder returned {} vectors for {} texts",
vectors.len(),
texts.len()
);
check_vectors(&vectors)?;
Ok(inputs
.iter()
.zip(slots)
.map(|(input, slot)| ItemScore {
chrf: chrf(input.output, input.reference),
meaning: slot.map(|i| cosine(&vectors[i], &vectors[i + 1])),
})
.collect())
}
}
/// Mean scores of one derived split.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct SplitMean {
pub count: usize,
/// `None` when the split has no items.
pub chrf: Option<f64>,
/// Mean over the items that have a meaning score; `None` when none do.
pub meaning: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Aggregate {
pub train: SplitMean,
pub validation: SplitMean,
pub test: SplitMean,
}
/// Groups scores by the item's derived split. Items in no split are counted
/// nowhere. The scorer only groups: the loop decides when Test is read.
pub fn aggregate(scored: &[(&CorpusItem, ItemScore)]) -> Aggregate {
let mean = |want: Split| {
let of: Vec<&ItemScore> = scored
.iter()
.filter(|(item, _)| SplitScheme::V1.split(item) == Some(want))
.map(|(_, score)| score)
.collect();
let average = |values: Vec<f64>| {
(!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
};
SplitMean {
count: of.len(),
chrf: average(of.iter().map(|s| s.chrf).collect()),
meaning: average(of.iter().filter_map(|s| s.meaning).collect()),
}
};
Aggregate {
train: mean(Split::Train),
validation: mean(Split::Validation),
test: mean(Split::Test),
}
}

View file

@ -0,0 +1,44 @@
//! The one way the scorer reads words: UAX #29 word segmentation, then NFC and
//! full Unicode case folding. The novel-coverage diagnostic reads text through
//! here, so every script is treated by one rule.
use caseless::default_case_fold_str;
use unicode_normalization::UnicodeNormalization;
use unicode_segmentation::UnicodeSegmentation;
/// NFC, then full default case folding.
pub(super) fn normalise(s: &str) -> String {
default_case_fold_str(&s.nfc().collect::<String>())
}
/// The pieces of one segment: split at `_` and `.` (which are dropped) and
/// at every lowercase-to-uppercase transition, so `kick_the_bucket`,
/// `kick.the.bucket` and `kickTheBucket` read as `kick the bucket`.
fn pieces(segment: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut previous: Option<char> = None;
for (at, c) in segment.char_indices() {
let separator = c == '_' || c == '.';
let camel = previous.is_some_and(char::is_lowercase) && c.is_uppercase();
if separator || camel {
out.push(&segment[start..at]);
start = if separator { at + c.len_utf8() } else { at };
}
previous = Some(c);
}
out.push(&segment[start..]);
out
}
/// The normalised words of `text` in order: UAX #29 segments containing an
/// alphanumeric char (digits kept), each split by `pieces`, empty or
/// non-alphanumeric pieces dropped.
pub(super) fn words(text: &str) -> Vec<String> {
text.split_word_bounds()
.filter(|segment| segment.chars().any(char::is_alphanumeric))
.flat_map(pieces)
.filter(|piece| piece.chars().any(char::is_alphanumeric))
.map(normalise)
.collect()
}

54
crates/weksa/src/split.rs Normal file
View file

@ -0,0 +1,54 @@
//! The train/validation/test split is derived from an item's group and nothing
//! else. No stored assignment exists, so no writer can reassign one.
use crate::corpus::{CorpusItem, Source};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Split {
Train,
Validation,
Test,
}
/// A scheme is a constant; a new scheme is a new variant, never a reassignment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SplitScheme {
V1,
}
pub fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
impl SplitScheme {
/// 0..100, FNV-1a 64 of `weksa-split-v1:` + group, mod 100.
pub fn bucket(self, group: &str) -> u64 {
match self {
SplitScheme::V1 => fnv1a64(format!("weksa-split-v1:{group}").as_bytes()) % 100,
}
}
/// <40 Train, <80 Validation, else Test.
pub fn of_bucket(self, bucket: u64) -> Split {
match self {
SplitScheme::V1 if bucket < 40 => Split::Train,
SplitScheme::V1 if bucket < 80 => Split::Validation,
SplitScheme::V1 => Split::Test,
}
}
/// The split of an item: its group's, except that a model-written item of a
/// test group is in no split. It is never moved to train: a model line of
/// the test meaning would teach the test.
pub fn split(self, item: &CorpusItem) -> Option<Split> {
match self.of_bucket(self.bucket(&item.group)) {
Split::Test if matches!(item.source, Source::ModelWritten { .. }) => None,
split => Some(split),
}
}
}

722
crates/weksa/src/store.rs Normal file
View file

@ -0,0 +1,722 @@
//! Admission to the corpus, inventory and packet stores. Each store is a
//! CultCache over one `SingleFileMessagePackBackingStore` file. These functions
//! are the only writers, so the rules S1..S4 hold for every caller.
use std::collections::BTreeSet;
use std::fmt;
use std::path::Path;
use caseless::default_case_fold_str;
use cultcache_rs::{CultCache, SingleFileMessagePackBackingStore};
use unicode_normalization::{UnicodeNormalization, char::is_combining_mark, is_nfc};
use unicode_segmentation::UnicodeSegmentation;
use crate::corpus::{CorpusItem, CorpusItemId, Source, item_key, key_slug};
use crate::entity::{Entity, EntityOrigin};
use crate::form::{is_attestation, is_joining_only, is_language_tag, is_refused_format};
use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key};
use crate::packet::{EntityId, Packet};
use crate::validate::{LoweringInput, Refusal, admissible};
/// A broken store rule, named.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StoreRefusal {
/// S1: an existing item key re-added with any field different.
S1ItemImmutable { key: String },
/// S2: a revision that is not latest+1 with parent latest.
S2RevisionChain { expected: u32, got: u32 },
/// S3: a revision after a frozen one.
S3Frozen { frozen: u32 },
/// S4: an entry whose introduced_rev is not the revision adding it.
S4IntroducedRev {
id: InvId,
introduced: u32,
revision: u32,
},
/// S5: a superseding item's group differs from its predecessor's.
S5SupersedeGroup {
key: String,
expected: String,
got: String,
},
/// S6: an item's normalized text equals another key's.
S6TextHeld { key: String, held_by: String },
/// A `provenance.corpus_item` key names no item.
UnknownCorpusItem { key: String },
/// A packet key already holds a different packet.
PacketImmutable { key: String },
/// An entity id the entity store does not hold: a speaker, a prior-turn
/// speaker, a name span's entity or a referent's entity.
UnknownEntity { entity: EntityId },
/// A name span with `start >= end`, `end` past the text, or an end off a
/// character boundary.
NameSpanRange { key: String, start: u32, end: u32 },
/// A name span that starts or ends inside an extended grapheme cluster
/// (UAX #29) of the item's text.
NameSpanSplitsCluster { key: String, start: u32, end: u32 },
/// An item text or prior-turn text, or an entity form, that is not NFC.
/// Callers normalize at intake; the stores refuse what they were handed.
NotNfc { key: String },
/// An entity beyond a capacity bound.
EntityBounds { id: EntityId, bound: EntityBound },
/// Two name spans of one item that overlap.
NameSpanOverlap { key: String },
/// A name span whose bytes equal none of its entity's forms in the item's
/// language (an entity with no forms in that language included).
NameSpanNotAForm {
key: String,
entity: EntityId,
start: u32,
end: u32,
},
/// A `put` that drops, reorders or edits a stored language, form or origin.
EntityNotAppendOnly { id: EntityId },
/// A corpus packet's referent cites an entity that is neither named on its
/// item nor the item's speaker or a prior-turn speaker.
EntityNotOnItem { entity: EntityId, item: String },
/// A new packet names a corpus item already withdrawn. Packets admitted
/// before the withdrawal stay stored and readable.
WithdrawnCorpusItem { key: String },
/// Any other malformed document or change.
Malformed { key: String, why: &'static str },
}
/// The capacity bound an entity broke.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityBound {
/// A language that is not a BCP 47-form tag (1 to 35 bytes).
LanguageTag,
/// More than 8 languages.
Languages,
/// More than 8 forms in one language.
Forms,
/// A form of more than 64 bytes.
FormBytes,
}
impl EntityBound {
pub const MAX_LANGUAGES: usize = 8;
pub const MAX_FORMS: usize = 8;
pub const MAX_FORM_BYTES: usize = 64;
}
#[derive(Debug)]
pub enum StoreError {
Refused(StoreRefusal),
/// The packet broke validator rules.
Invalid(Vec<Refusal>),
Io(anyhow::Error),
}
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StoreError::Refused(r) => write!(f, "refused: {r:?}"),
StoreError::Invalid(rs) => {
write!(f, "invalid packet:")?;
rs.iter().try_for_each(|r| write!(f, " [{r}]"))
}
StoreError::Io(e) => write!(f, "{e:#}"),
}
}
}
impl std::error::Error for StoreError {}
impl From<anyhow::Error> for StoreError {
fn from(e: anyhow::Error) -> Self {
StoreError::Io(e)
}
}
impl From<StoreRefusal> for StoreError {
fn from(r: StoreRefusal) -> Self {
StoreError::Refused(r)
}
}
fn malformed(key: &str, why: &'static str) -> StoreError {
StoreRefusal::Malformed {
key: key.to_string(),
why,
}
.into()
}
fn open_cache(
path: &Path,
register: impl FnOnce(&mut CultCache) -> anyhow::Result<()>,
) -> anyhow::Result<CultCache> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut cache = CultCache::new();
register(&mut cache)?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
cache.pull_all_backing_stores()?;
Ok(cache)
}
#[derive(Debug, PartialEq, Eq)]
pub enum AddOutcome {
Added,
/// The key already held this text; nothing was written.
Unchanged,
}
pub struct CorpusStore {
cache: CultCache,
}
impl CorpusStore {
pub fn open(path: &Path) -> anyhow::Result<Self> {
Ok(Self {
cache: open_cache(path, |c| c.register_entry_type::<CorpusItem>())?,
})
}
pub fn all(&self) -> anyhow::Result<Vec<CorpusItem>> {
let mut items = self.cache.get_all::<CorpusItem>()?;
items.sort_by(|a, b| a.id.cmp(&b.id));
Ok(items)
}
pub fn get(&self, key: &str) -> anyhow::Result<Option<CorpusItem>> {
self.cache.get::<CorpusItem>(key)
}
/// The next free key for `slug`; withdrawn items keep their numbers.
pub fn next_key(&self, slug: &str) -> Result<String, StoreError> {
let highest = self
.all()?
.iter()
.filter(|i| key_slug(&i.id) == Some(slug))
.filter_map(|i| i.id.rsplit_once(':')?.1.parse::<u32>().ok())
.max()
.unwrap_or(0);
let next = highest
.checked_add(1)
.ok_or_else(|| malformed(slug, "slug has no free key"))?;
Ok(item_key(slug, next))
}
/// The item `id` names, or `UnknownCorpusItem` when it holds nothing.
fn resolve(&self, id: &CorpusItemId) -> Result<CorpusItem, StoreError> {
self.get(id.as_str())?.ok_or_else(|| {
StoreRefusal::UnknownCorpusItem {
key: id.to_string(),
}
.into()
})
}
/// S1: an item key holds its whole item for good. S6: a text held under
/// one key (live or withdrawn) is not re-added under another unless the new
/// item supersedes that key. S5: a correction stays in its predecessor's
/// group, so it cannot move across the split.
pub fn add(
&mut self,
item: CorpusItem,
entities: &EntityStore,
) -> Result<AddOutcome, StoreError> {
if key_slug(&item.id).is_none() {
return Err(malformed(&item.id, "item key is not item:<slug>:<n>"));
}
if item.withdrawn {
return Err(malformed(
&item.id,
"an item is admitted live, then withdrawn",
));
}
if let Some(existing) = self.get(&item.id)? {
if existing == item {
return Ok(AddOutcome::Unchanged);
}
return Err(StoreRefusal::S1ItemImmutable { key: item.id }.into());
}
if !is_language_tag(&item.language) {
return Err(malformed(&item.id, "language is not a BCP 47-form tag"));
}
if !is_nfc(&item.text) || item.prior_turns.iter().any(|t| !is_nfc(&t.text)) {
return Err(StoreRefusal::NotNfc { key: item.id }.into());
}
let text = normalized(&item.text);
if !text.chars().any(char::is_alphanumeric) {
return Err(malformed(&item.id, "text has no alphanumeric character"));
}
if let Some(why) = empty_attestation(&item.source) {
return Err(malformed(&item.id, why));
}
check_entities(&item, entities)?;
for held in self.all()? {
if normalized(&held.text) == text && item.supersedes.as_deref() != Some(&held.id) {
return Err(StoreRefusal::S6TextHeld {
key: item.id,
held_by: held.id,
}
.into());
}
}
if let Some(old) = &item.supersedes {
let Some(old) = self.get(old)? else {
return Err(malformed(
&item.id,
"supersedes an item that does not exist",
));
};
if old.group != item.group {
return Err(StoreRefusal::S5SupersedeGroup {
key: item.id,
expected: old.group,
got: item.group,
}
.into());
}
}
self.cache.put(item.id.clone(), &item)?;
Ok(AddOutcome::Added)
}
/// Marks an item withdrawn; its text stays.
pub fn withdraw(&mut self, key: &str) -> Result<(), StoreError> {
let Some(mut item) = self.get(key)? else {
return Err(malformed(key, "no such item"));
};
if !item.withdrawn {
item.withdrawn = true;
self.cache.put(key, &item)?;
}
Ok(())
}
}
/// Every entity an item cites exists, and every name span is a well-formed,
/// non-overlapping byte range of the text, starting and ending on extended
/// grapheme cluster boundaries, equal to one of its entity's forms in the
/// item's language. Id lookup, UAX #29 boundaries and byte equality: no reading
/// of meaning, so every script is held to the same check.
fn check_entities(item: &CorpusItem, entities: &EntityStore) -> Result<(), StoreError> {
let known = |id: &EntityId| -> Result<Entity, StoreError> {
entities
.get(id)?
.ok_or_else(|| StoreRefusal::UnknownEntity { entity: id.clone() }.into())
};
known(&item.speaker)?;
for turn in &item.prior_turns {
known(&turn.speaker)?;
}
let mut ranges = Vec::with_capacity(item.names.len());
let cuts: BTreeSet<usize> = item
.text
.grapheme_indices(true)
.map(|(i, _)| i)
.chain([item.text.len()])
.collect();
for span in &item.names {
let entity = known(&span.entity)?;
let (start, end) = (span.start as usize, span.end as usize);
let Some(bytes) = item
.text
.get(start..end)
.filter(|_| start < end)
.map(str::as_bytes)
else {
return Err(StoreRefusal::NameSpanRange {
key: item.id.clone(),
start: span.start,
end: span.end,
}
.into());
};
if !cuts.contains(&start) || !cuts.contains(&end) {
return Err(StoreRefusal::NameSpanSplitsCluster {
key: item.id.clone(),
start: span.start,
end: span.end,
}
.into());
}
let is_form = entity
.forms_in(&item.language)
.is_some_and(|forms| forms.iter().any(|f| f.as_bytes() == bytes));
if !is_form {
return Err(StoreRefusal::NameSpanNotAForm {
key: item.id.clone(),
entity: span.entity.clone(),
start: span.start,
end: span.end,
}
.into());
}
ranges.push((span.start, span.end));
}
ranges.sort_unstable();
if ranges.windows(2).any(|w| w[1].0 < w[0].1) {
return Err(StoreRefusal::NameSpanOverlap {
key: item.id.clone(),
}
.into());
}
Ok(())
}
/// The S6 key of a text: Unicode compatibility caseless matching (D146: NFKC,
/// default case fold, NFKC) keeping letters, digits and combining marks
/// (General_Category L, N, M) so tone marks and viramas keep words apart. Composed and decomposed,
/// fullwidth, ligature and case-fold variants (straße, STRASSE) and spacing and
/// punctuation variants compare equal. Homoglyphs across scripts (Cyrillic a for
/// Latin a) stay apart: no key here reads what a character looks like.
fn normalized(text: &str) -> String {
let folded = default_case_fold_str(&text.nfkc().collect::<String>());
folded
.nfkc()
.filter(|c| c.is_alphanumeric() || is_combining_mark(*c))
.collect()
}
/// The field that holds an unattested provenance string, if any: a string with
/// no alphanumeric, a control or format character, or over 200 bytes attests
/// nothing.
fn empty_attestation(source: &Source) -> Option<&'static str> {
let empty = |s: &String| !is_attestation(s);
let empty_some = |s: &Option<String>| s.as_ref().is_some_and(empty);
match source {
Source::CastAuthored { persona, .. } if empty(persona) => {
Some("a cast-authored source names its persona")
}
Source::CastAuthored { fixture, .. } if empty(fixture) => {
Some("a cast-authored source names its fixture")
}
Source::CastAuthored { author, .. } if empty(author) => {
Some("a cast-authored source names its author")
}
Source::OperatorApproved { note } if empty(note) => {
Some("an operator-approved source carries its note")
}
Source::PublicDomain { work, .. } if empty(work) => {
Some("a public-domain source names its work")
}
Source::PublicDomain { author, .. } if empty(author) => {
Some("a public-domain source names its author")
}
Source::PublicDomain { edition_url, .. } if empty(edition_url) => {
Some("a public-domain source names its edition url")
}
Source::ModelWritten { generator, .. } if empty_some(generator) => {
Some("a model-written source names a non-empty generator")
}
Source::ModelWritten { prompt_digest, .. } if empty_some(prompt_digest) => {
Some("a model-written source carries a non-empty prompt digest")
}
_ => None,
}
}
pub struct EntityStore {
cache: CultCache,
}
impl EntityStore {
pub fn open(path: &Path) -> anyhow::Result<Self> {
Ok(Self {
cache: open_cache(path, |c| c.register_entry_type::<Entity>())?,
})
}
pub fn all(&self) -> anyhow::Result<Vec<Entity>> {
let mut entities = self.cache.get_all::<Entity>()?;
entities.sort_by(|a, b| a.id.cmp(&b.id));
Ok(entities)
}
pub fn get(&self, id: &EntityId) -> anyhow::Result<Option<Entity>> {
self.cache.get::<Entity>(id.as_str())
}
/// Admits a new entity, an identical replay, or a replacement that keeps
/// every stored language and form in order and origin as it was, and only
/// appends: forms grow, never shrink, reorder or change. An entity has 1 to
/// 8 languages, each a BCP 47-form tag named once, with 1 to 8 NFC forms of
/// 1 to 64 bytes, no control or invisible format character (joiners and
/// variation selectors stay), no edge whitespace, none repeated; the origin
/// attests where it came from with strings that hold an alphanumeric, no control or format character and at most 200 bytes.
pub fn put(&mut self, entity: Entity) -> Result<AddOutcome, StoreError> {
let key = entity.id.to_string();
check_entity_shape(&entity)?;
if let Some(old) = self.get(&entity.id)? {
if old == entity {
return Ok(AddOutcome::Unchanged);
}
let grows = old.origin == entity.origin
&& old.forms.len() <= entity.forms.len()
&& old.forms.iter().zip(&entity.forms).all(|(o, n)| {
o.language == n.language
&& o.forms.len() <= n.forms.len()
&& n.forms.starts_with(&o.forms)
});
if !grows {
return Err(StoreRefusal::EntityNotAppendOnly { id: entity.id }.into());
}
}
self.cache.put(key, &entity)?;
Ok(AddOutcome::Added)
}
}
fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> {
let bad = |why| malformed(entity.id.as_str(), why);
let bound = |bound| -> StoreError {
StoreRefusal::EntityBounds {
id: entity.id.clone(),
bound,
}
.into()
};
let (persona, fixture, work) = match &entity.origin {
EntityOrigin::Cast { persona, fixture } => (Some(persona), Some(fixture), None),
EntityOrigin::Live { persona } => (Some(persona), None, None),
EntityOrigin::PublicDomain { work } => (None, None, Some(work)),
};
if persona.is_some_and(|s| !is_attestation(s)) {
return Err(bad("an origin names its persona"));
}
if fixture.is_some_and(|s| !is_attestation(s)) {
return Err(bad("a cast origin names its fixture"));
}
if work.is_some_and(|s| !is_attestation(s)) {
return Err(bad("a public-domain origin names its work"));
}
if entity.forms.is_empty() {
return Err(bad("an entity has forms in at least one language"));
}
if entity.forms.len() > EntityBound::MAX_LANGUAGES {
return Err(bound(EntityBound::Languages));
}
let mut languages = BTreeSet::new();
for lf in &entity.forms {
if !is_language_tag(&lf.language) {
return Err(bound(EntityBound::LanguageTag));
}
if !languages.insert(&lf.language) {
return Err(bad("a language is named once"));
}
if lf.forms.is_empty() {
return Err(bad("a language has at least one form"));
}
if lf.forms.len() > EntityBound::MAX_FORMS {
return Err(bound(EntityBound::Forms));
}
let mut seen = BTreeSet::new();
for f in &lf.forms {
if f.len() > EntityBound::MAX_FORM_BYTES {
return Err(bound(EntityBound::FormBytes));
}
if !is_nfc(f) {
return Err(StoreRefusal::NotNfc {
key: entity.id.to_string(),
}
.into());
}
if f.is_empty() || f.chars().any(char::is_control) || f.trim() != f.as_str() {
return Err(bad(
"a form is not empty and has no control character or edge whitespace",
));
}
if f.chars().any(is_refused_format) {
return Err(bad("a form holds an invisible format character"));
}
if f.chars().all(is_joining_only) {
return Err(bad("a form is more than joiners and selectors"));
}
if !seen.insert(f) {
return Err(bad("a form is not repeated within its language"));
}
}
}
Ok(())
}
pub struct InventoryStore {
cache: CultCache,
}
impl InventoryStore {
pub fn open(path: &Path) -> anyhow::Result<Self> {
let cache = open_cache(path, |c| {
c.register_entry_type::<InventoryEntry>()?;
c.register_entry_type::<InventoryRevision>()
})?;
Ok(Self { cache })
}
pub fn inventory(&self) -> anyhow::Result<Inventory> {
Ok(Inventory::new(
self.cache.get_all::<InventoryEntry>()?,
self.cache.get_all::<InventoryRevision>()?,
))
}
/// Admits revision `rev` and the entries it adds, whole or not at all.
/// S2: latest+1 with parent latest. S3: nothing follows a frozen revision.
/// S4: an added entry's introduced_rev is this revision.
/// An added entry's gloss is 1 to 120 bytes without a control character.
/// No door judges text against the corpus: leak is owned by the loop's
/// context builder and the scorer.
pub fn admit_revision(
&mut self,
rev: InventoryRevision,
entries: Vec<InventoryEntry>,
) -> Result<(), StoreError> {
let inv = self.inventory()?;
let latest = inv.latest();
if let Some(l) = latest.filter(|l| l.frozen) {
return Err(StoreRefusal::S3Frozen { frozen: l.n }.into());
}
let (expected, parent) = latest.map_or((1, None), |l| (l.n + 1, Some(l.n)));
if rev.n != expected || rev.parent != parent {
return Err(StoreRefusal::S2RevisionChain {
expected,
got: rev.n,
}
.into());
}
let mut touched: BTreeSet<InvId> = BTreeSet::new();
let mut staged = Vec::new();
for change in &rev.changes {
match change {
Change::Add(id) => {
let Some(entry) = entries.iter().find(|e| &e.id == id) else {
return Err(malformed(&id.to_string(), "added id has no entry"));
};
if entry.introduced_rev != rev.n {
let (id, introduced, revision) = (*id, entry.introduced_rev, rev.n);
return Err(StoreRefusal::S4IntroducedRev {
id,
introduced,
revision,
}
.into());
}
let key = id.to_string();
if !entry.well_formed() || entry.retired_rev.is_some() {
return Err(malformed(&key, "entry shape does not match its family"));
}
if inv.entry(id).is_some() || !touched.insert(*id) {
return Err(malformed(&key, "id is already in the inventory"));
}
check_entry_text(entry)?;
staged.push(self.cache.prepare_entry_named(key, entry)?.0);
}
Change::Retire(id) => {
let key = id.to_string();
let Some(current) = inv.entry(id).filter(|e| e.retired_rev.is_none()) else {
return Err(malformed(
&key,
"retire target is missing or already retired",
));
};
if !touched.insert(*id) {
return Err(malformed(&key, "id changes twice in one revision"));
}
let retired = InventoryEntry {
retired_rev: Some(rev.n),
..current.clone()
};
staged.push(self.cache.prepare_entry_named(key, &retired)?.0);
}
}
}
if let Some(stray) = entries.iter().find(|e| !touched.contains(&e.id)) {
return Err(malformed(
&stray.id.to_string(),
"entry is not in the revision's changes",
));
}
staged.push(self.cache.prepare_entry_named(revision_key(rev.n), &rev)?.0);
self.cache.put_prepared_batch(staged)?;
Ok(())
}
}
fn check_entry_text(entry: &InventoryEntry) -> Result<(), StoreError> {
if !(1..=120).contains(&entry.gloss.len()) || entry.gloss.chars().any(char::is_control) {
return Err(malformed(
&entry.id.to_string(),
"gloss is 1 to 120 bytes with no control character",
));
}
Ok(())
}
pub struct PacketStore {
cache: CultCache,
}
impl PacketStore {
pub fn open(path: &Path) -> anyhow::Result<Self> {
Ok(Self {
cache: open_cache(path, |c| c.register_entry_type::<Packet>())?,
})
}
pub fn all(&self) -> anyhow::Result<Vec<Packet>> {
let mut packets = self.cache.get_all::<Packet>()?;
packets.sort_by(|a, b| a.id.cmp(&b.id));
Ok(packets)
}
pub fn get(&self, key: &str) -> anyhow::Result<Option<Packet>> {
self.cache.get::<Packet>(key)
}
/// The only door, and the only source of a [`LoweringInput`]: a packet
/// exists only if the validator's rules pass against the inventory the
/// store holds at the packet's revision, its `corpus_item` resolves to an
/// item that is not withdrawn, every referent's entity is in the entity store and is named on
/// that item or is its speaker or a prior-turn speaker (an id-set check). A
/// key holds its packet for good. A packet that names no corpus item has no
/// door here.
pub fn admit(
&mut self,
packet: Packet,
inventory: &InventoryStore,
corpus: &CorpusStore,
entities: &EntityStore,
) -> Result<LoweringInput, StoreError> {
let view = admissible(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?;
let item = corpus.resolve(&packet.provenance.corpus_item)?;
if item.withdrawn {
return Err(StoreRefusal::WithdrawnCorpusItem {
key: item.id.clone(),
}
.into());
}
for entity in packet.referents.iter().filter_map(|r| r.entity.as_ref()) {
if entities.get(entity)?.is_none() {
return Err(StoreRefusal::UnknownEntity {
entity: entity.clone(),
}
.into());
}
let on_item = item.names.iter().any(|n| &n.entity == entity)
|| &item.speaker == entity
|| item.prior_turns.iter().any(|t| &t.speaker == entity);
if !on_item {
return Err(StoreRefusal::EntityNotOnItem {
entity: entity.clone(),
item: item.id.clone(),
}
.into());
}
}
let key = packet.id.to_string();
if let Some(existing) = self.get(&key)? {
if existing == packet {
return Ok(view);
}
return Err(StoreRefusal::PacketImmutable { key }.into());
}
self.cache.put(key, &packet)?;
Ok(view)
}
}

View file

@ -0,0 +1,525 @@
//! The validator owns the inventory and form rules a packet must meet. Every
//! path that admits a packet applies them, but `validate` only judges: it
//! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a
//! lowerer sees, comes from `PacketStore::admit` alone, which builds it through
//! the crate-private `admissible`. The stance is checked like every feature
//! list, in slot `Stance`. The rules are V1..V8 and V10..V12: the shape of an id
//! or a grade is its type's parse, not a rule, so V9 is retired and not
//! renumbered.
//!
//! `validate` owns form and capacity and makes no leak decision. A packet's one
//! free text is its trace (`notes` and `rejected_readings`): `validate` does not
//! bound or read it, and [`LoweringInput`] drops it, so no lowerer sees it.
//! Every other slot is an id type, so the lists are the bounded part. What bounds
//! each list (V11, V12): a feature list holds at
//! most one value per feature id and `extensions` at most one per extension id,
//! so each is at most the inventory's ids of that kind; `participants`,
//! `audience` and `preserve` hold each node id at most once and every id names
//! a node (V4), so each is at most `Packet::MAX_NODES`; a predication's `roles`
//! hold each role id at most once, so at most the inventory's roles; and the
//! nodes themselves are at most `Packet::MAX_NODES` (V12). Whether a score may
//! rise because corpus text reached a packet is owned by the golf loop's
//! context builder and the scorer (ruling `leak-owned-by-flow-and-scorer-3`),
//! never by a text rule here, in `admit_revision` or in `PacketStore::admit`.
use std::collections::BTreeSet;
use std::fmt;
use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind};
use crate::packet::{
Constraints, Context, Discourse, Extension, FeatValue, FeatureValue, NodeId, Packet,
PacketKind, Predication, Referent, Relation, SCHEMA_VERSION, Stance,
};
/// A broken rule, named.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
/// V1: the packet's schema_version is not the one these types define.
V1SchemaVersion { found: u32 },
/// V2: no referents, predications or relations.
V2PayloadRequired,
/// V3: a node id repeats across referents and predications.
V3DuplicateNode { id: NodeId },
/// V4: a reference names no node.
V4DanglingRef { id: NodeId, at: &'static str },
/// V5: an inventory id does not resolve at the packet's revision.
V5Unresolved { id: InvId },
/// V6: an inventory id is of the wrong family for its slot.
V6WrongFamily {
id: InvId,
expected: Family,
at: &'static str,
},
/// V7: a feature or extension value does not fit its value kind.
V7ValueKind { what: String },
/// V8: a feature is not declared for the slot it sits on.
V8NotApplicable { feature: InvId, slot: Slot },
/// V10: the packet's inventory revision has no record.
V10UnknownRevision { rev: u32 },
/// V11: a feature id repeats in one slot's list (`slot` is `None` for an
/// extension id repeated in the packet).
V11DuplicateFeature { feature: InvId, slot: Option<Slot> },
/// V11: a node id repeats in `participants`, `audience` or `preserve`
/// (`at` names the list).
V11DuplicateNodeRef { id: NodeId, at: &'static str },
/// V11: a role id repeats in one predication's roles.
V11DuplicateRole { role: InvId, predication: NodeId },
/// V12: more referents, predications and relations than `Packet::MAX_NODES`.
V12TooManyNodes { count: usize, max: usize },
}
impl Refusal {
pub fn rule(&self) -> &'static str {
match self {
Refusal::V1SchemaVersion { .. } => "V1",
Refusal::V2PayloadRequired => "V2",
Refusal::V3DuplicateNode { .. } => "V3",
Refusal::V4DanglingRef { .. } => "V4",
Refusal::V5Unresolved { .. } => "V5",
Refusal::V6WrongFamily { .. } => "V6",
Refusal::V7ValueKind { .. } => "V7",
Refusal::V8NotApplicable { .. } => "V8",
Refusal::V10UnknownRevision { .. } => "V10",
Refusal::V11DuplicateFeature { .. }
| Refusal::V11DuplicateNodeRef { .. }
| Refusal::V11DuplicateRole { .. } => "V11",
Refusal::V12TooManyNodes { .. } => "V12",
}
}
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {self:?}", self.rule())
}
}
/// Whether `packet` meets the rules under `inventory`. All broken rules are
/// returned. A pass carries no lowering view: that comes from
/// `PacketStore::admit` only.
///
/// ```
/// use weksa::{Inventory, Refusal, packet::Packet};
/// fn judge(p: &Packet, i: &Inventory) -> Result<(), Vec<Refusal>> { weksa::validate(p, i) }
/// ```
///
/// ```compile_fail
/// use weksa::{Inventory, LoweringInput, Refusal, packet::Packet};
/// fn lower(p: &Packet, i: &Inventory) -> Result<LoweringInput, Vec<Refusal>> { weksa::validate(p, i) }
/// ```
pub fn validate(packet: &Packet, inventory: &Inventory) -> Result<(), Vec<Refusal>> {
admissible(packet, inventory).map(|_| ())
}
/// The one implementation of the rules. Crate-private: `PacketStore::admit` is
/// the only caller that keeps the view.
///
/// The first block is the same call through the public name, so the second
/// fails for the private path and nothing else.
///
/// ```
/// use weksa::{Inventory, packet::Packet};
/// fn judge(p: &Packet, i: &Inventory) { let _ = weksa::validate::validate(p, i); }
/// ```
///
/// ```compile_fail
/// use weksa::{Inventory, packet::Packet};
/// fn lower(p: &Packet, i: &Inventory) { let _ = weksa::validate::admissible(p, i); }
/// ```
pub(crate) fn admissible(
packet: &Packet,
inventory: &Inventory,
) -> Result<LoweringInput, Vec<Refusal>> {
let mut out = Vec::new();
if packet.schema_version != SCHEMA_VERSION {
out.push(Refusal::V1SchemaVersion {
found: packet.schema_version,
});
}
if packet.referents.is_empty() && packet.predications.is_empty() && packet.relations.is_empty()
{
out.push(Refusal::V2PayloadRequired);
}
let count = packet.referents.len() + packet.predications.len() + packet.relations.len();
if count > Packet::MAX_NODES {
out.push(Refusal::V12TooManyNodes {
count,
max: Packet::MAX_NODES,
});
}
let nodes = check_nodes(packet, &mut out);
check_refs(packet, &nodes, &mut out);
check_repeats(packet, &mut out);
if inventory.has_revision(packet.inventory_rev) {
Inventoried {
inv: inventory,
rev: packet.inventory_rev,
out: &mut out,
}
.check(packet);
} else {
out.push(Refusal::V10UnknownRevision {
rev: packet.inventory_rev,
});
}
if out.is_empty() {
Ok(LoweringInput::of(packet))
} else {
Err(out)
}
}
fn check_nodes(packet: &Packet, out: &mut Vec<Refusal>) -> BTreeSet<NodeId> {
let mut nodes = BTreeSet::new();
let ids = packet
.referents
.iter()
.map(|r| &r.id)
.chain(packet.predications.iter().map(|p| &p.id));
for id in ids {
if !nodes.insert(id.clone()) {
out.push(Refusal::V3DuplicateNode { id: id.clone() });
}
}
nodes
}
fn check_refs(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>) {
let mut need = |id: &NodeId, at: &'static str| {
if !nodes.contains(id) {
out.push(Refusal::V4DanglingRef { id: id.clone(), at });
}
};
for p in &packet.predications {
for r in &p.roles {
need(&r.filler, "role filler");
}
}
for r in &packet.relations {
need(&r.from, "relation from");
need(&r.to, "relation to");
}
if let Some(a) = &packet.discourse.addressee {
need(a, "addressee");
}
for (list, at) in node_lists(packet) {
for id in list {
need(id, at);
}
}
}
fn node_lists(packet: &Packet) -> [(&Vec<NodeId>, &'static str); 3] {
[
(&packet.context.participants, "participant"),
(&packet.context.audience, "audience"),
(&packet.constraints.preserve, "preserve"),
]
}
/// V11 for the lists that hold nodes and roles: each node id once per node list,
/// each role id once per predication.
fn check_repeats(packet: &Packet, out: &mut Vec<Refusal>) {
for (list, at) in node_lists(packet) {
let mut seen = BTreeSet::new();
for id in list {
if !seen.insert(id) {
out.push(Refusal::V11DuplicateNodeRef { id: id.clone(), at });
}
}
}
for p in &packet.predications {
let mut seen = BTreeSet::new();
for r in &p.roles {
if !seen.insert(r.role) {
out.push(Refusal::V11DuplicateRole {
role: r.role,
predication: p.id.clone(),
});
}
}
}
}
/// The inventory-dependent rules V5..V8 and V11, read at the packet's revision.
struct Inventoried<'a> {
inv: &'a Inventory,
rev: u32,
out: &'a mut Vec<Refusal>,
}
impl<'a> Inventoried<'a> {
fn check(&mut self, p: &Packet) {
let Packet {
context,
discourse,
referents,
predications,
relations,
extensions,
stance,
..
} = p;
let Context {
setting, medium, ..
} = context;
for id in setting.iter().chain(medium) {
self.slot(id, Family::Concept, "context concept");
}
let Discourse {
speech_act,
features,
..
} = discourse;
if let Some(id) = speech_act {
self.slot(id, Family::SpeechAct, "speech act");
}
self.features(features, Slot::Discourse);
self.features(&stance.features, Slot::Stance);
for Referent {
kind,
concept,
features,
..
} in referents
{
self.slot(kind, Family::ReferentKind, "referent kind");
self.slot(concept, Family::Concept, "referent concept");
self.features(features, Slot::Referent);
}
for Predication {
concept,
frame,
roles,
features,
..
} in predications
{
self.slot(concept, Family::Concept, "predication concept");
self.slot(frame, Family::Frame, "frame");
for r in roles {
self.slot(&r.role, Family::Role, "role");
}
self.features(features, Slot::Predication);
}
for Relation {
relation, features, ..
} in relations
{
self.slot(relation, Family::Relation, "relation");
self.features(features, Slot::Relation);
}
let mut seen = BTreeSet::new();
for Extension { key, value } in extensions {
if !seen.insert(*key) {
self.out.push(Refusal::V11DuplicateFeature {
feature: *key,
slot: None,
});
}
if let Some(entry) = self.slot(key, Family::Extension, "extension")
&& !fits(entry.value_kind.as_ref(), value)
{
self.out.push(Refusal::V7ValueKind {
what: key.to_string(),
});
}
}
}
/// V5, then V6 when it resolved.
fn slot(&mut self, id: &InvId, want: Family, at: &'static str) -> Option<&'a InventoryEntry> {
let entry = self.resolve(id)?;
if entry.family() != want {
self.out.push(Refusal::V6WrongFamily {
id: *id,
expected: want,
at,
});
return None;
}
Some(entry)
}
fn resolve(&mut self, id: &InvId) -> Option<&'a InventoryEntry> {
let found = self.inv.resolve(id, self.rev);
if found.is_none() {
self.out.push(Refusal::V5Unresolved { id: *id });
}
found
}
fn features(&mut self, features: &[FeatureValue], slot: Slot) {
let mut seen = BTreeSet::new();
for f in features {
if !seen.insert(f.feature) {
self.out.push(Refusal::V11DuplicateFeature {
feature: f.feature,
slot: Some(slot),
});
}
let Some(entry) = self.slot(&f.feature, Family::Feature, "feature") else {
continue;
};
if !entry.applies_to.contains(&slot) {
self.out.push(Refusal::V8NotApplicable {
feature: f.feature,
slot,
});
}
if !fits(entry.value_kind.as_ref(), &f.value) {
self.out.push(Refusal::V7ValueKind {
what: f.feature.to_string(),
});
}
}
}
}
fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
match (kind, value) {
(Some(ValueKind::Grade), FeatValue::Grade(_)) => true,
(Some(ValueKind::Options(labels)), FeatValue::Option(l)) => labels.contains(l),
(Some(ValueKind::Flag), FeatValue::Flag(_)) => true,
_ => false,
}
}
/// What the lowerer sees: a validated packet's meaning, with no packet id, no
/// provenance (so no corpus item key, encoder or source) and no trace. Its
/// fields are private and the constructor is crate-private, called only by
/// `PacketStore::admit`, so a lowerer cannot be handed an unadmitted packet or
/// the key to an answer text.
///
/// Each field has a doctest that reads it from outside the crate. A read
/// compiles exactly when the field is `pub`, so the doctest (which must fail to
/// compile) fails as soon as any one field is made public. A struct literal
/// cannot pin this: it fails to compile while any other field stays private,
/// and stable rustdoc does not check the error code.
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.inventory_rev; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.kind; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.context; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.discourse; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.referents; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.predications; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.relations; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.constraints; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.extensions; }
/// ```
///
/// ```compile_fail
/// fn read(l: weksa::LoweringInput) { let _ = l.stance; }
/// ```
///
/// The constructor is private: a call from outside the crate compiles only if
/// `LoweringInput::of` is `pub`. The first block is the same code without the
/// call, so the second fails for that call and nothing else.
///
/// ```
/// fn build(p: &weksa::packet::Packet) { let _ = p; }
/// ```
///
/// ```compile_fail
/// fn build(p: &weksa::packet::Packet) { let _ = weksa::LoweringInput::of(p); }
/// ```
///
/// A lowering view is never read from bytes: `Deserialize` would let a caller
/// build one without `validate`. The first block proves the bound resolves for
/// a type that does implement it.
///
/// ```
/// fn de<T: serde::de::DeserializeOwned>() {}
/// de::<weksa::packet::Packet>();
/// ```
///
/// ```compile_fail
/// fn de<T: serde::de::DeserializeOwned>() {}
/// de::<weksa::LoweringInput>();
/// ```
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LoweringInput {
inventory_rev: u32,
kind: PacketKind,
context: Context,
discourse: Discourse,
referents: Vec<Referent>,
predications: Vec<Predication>,
relations: Vec<Relation>,
constraints: Constraints,
extensions: Vec<Extension>,
stance: Stance,
}
impl LoweringInput {
fn of(p: &Packet) -> Self {
Self {
inventory_rev: p.inventory_rev,
kind: p.kind,
context: p.context.clone(),
discourse: p.discourse.clone(),
referents: p.referents.clone(),
predications: p.predications.clone(),
relations: p.relations.clone(),
constraints: p.constraints.clone(),
extensions: p.extensions.clone(),
stance: p.stance.clone(),
}
}
pub fn inventory_rev(&self) -> u32 {
self.inventory_rev
}
pub fn kind(&self) -> PacketKind {
self.kind
}
pub fn context(&self) -> &Context {
&self.context
}
pub fn discourse(&self) -> &Discourse {
&self.discourse
}
pub fn referents(&self) -> &[Referent] {
&self.referents
}
pub fn predications(&self) -> &[Predication] {
&self.predications
}
pub fn relations(&self) -> &[Relation] {
&self.relations
}
pub fn constraints(&self) -> &Constraints {
&self.constraints
}
pub fn extensions(&self) -> &[Extension] {
&self.extensions
}
pub fn stance(&self) -> &Stance {
&self.stance
}
}

869
crates/weksa/tests/cli.rs Normal file
View file

@ -0,0 +1,869 @@
mod common;
use std::process::{Command, Output};
use weksa::{CorpusStore, EntityStore, InventoryStore, PacketStore};
fn weksa(dir: &std::path::Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_weksa"))
.current_dir(dir)
.args([
"--corpus",
"./c.cc",
"--inventory",
"./i.cc",
"--entities",
"./e.cc",
])
.args(args)
.output()
.unwrap()
}
/// Admits the cast entities the tests cite.
fn cast_entities(dir: &std::path::Path, slugs: &[&str]) {
for slug in slugs {
let id = format!("entity:{slug}");
let form = format!("en-US={}", slug.to_uppercase());
let put = weksa(
dir,
&[
"entity",
"put",
&id,
"--form",
&form,
"--origin",
"cast",
"--persona",
slug,
"--fixture",
"f.yaml",
],
);
assert!(put.status.success(), "{}", out(&put));
}
}
fn out(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned() + &String::from_utf8_lossy(&o.stderr)
}
const ADD: [&str; 14] = [
"corpus",
"add",
"--slug",
"nibu",
"--text",
"Mind the hatch.",
"--speaker",
"entity:nibu",
"--group",
"scene-1",
"--admitted-on",
"2026-10-29",
"--source",
"cast-authored",
];
#[test]
fn seed_add_dump_split_withdraw() {
let d = tempfile::tempdir().unwrap();
cast_entities(d.path(), &["nibu"]);
let seeded = weksa(d.path(), &["inventory", "seed"]);
assert!(seeded.status.success(), "{}", out(&seeded));
assert!(out(&seeded).contains("seeded rev:1"));
let again = weksa(d.path(), &["inventory", "seed"]);
assert!(
!again.status.success(),
"a second seed is refused: {}",
out(&again)
);
let dump = out(&weksa(d.path(), &["inventory", "dump"]));
assert!(
dump.contains("rev:1") && dump.contains("speech_act:5") && dump.contains("warn"),
"{dump}"
);
// A cast-authored item needs its persona, fixture and author.
let missing = weksa(d.path(), &ADD);
assert!(
!missing.status.success() && out(&missing).contains("--persona"),
"{}",
out(&missing)
);
let mut add = ADD.to_vec();
add.extend([
"--persona",
"nibu",
"--fixture",
"f.yaml",
"--author",
"Ada Writer",
]);
let added = weksa(d.path(), &add);
assert!(
added.status.success() && out(&added).contains("Added item:nibu:1"),
"{}",
out(&added)
);
// The same text under a new key is refused (S6); another text is not.
let copy = weksa(d.path(), &add);
assert!(
!copy.status.success() && out(&copy).contains("S6TextHeld"),
"{}",
out(&copy)
);
let mut other = add.clone();
other[5] = "Mind the other hatch.";
let second = weksa(d.path(), &other);
assert!(
out(&second).contains("Added item:nibu:2"),
"{}",
out(&second)
);
let dump = out(&weksa(d.path(), &["corpus", "dump"]));
assert!(
dump.contains("item:nibu:1")
&& dump.contains("Mind the hatch.")
&& dump.contains("CastAuthored"),
"{dump}"
);
let split = out(&weksa(d.path(), &["split", "item:nibu:1"]));
assert!(
["Train", "Validation", "Test"]
.iter()
.any(|s| split.trim() == *s),
"{split}"
);
assert!(!weksa(d.path(), &["split", "item:nibu:9"]).status.success());
let withdrawn = weksa(d.path(), &["corpus", "withdraw", "item:nibu:1"]);
assert!(withdrawn.status.success());
assert!(out(&weksa(d.path(), &["corpus", "dump"])).contains("withdrawn"));
}
#[test]
fn model_written_and_public_domain_sources_name_their_provenance() {
let d = tempfile::tempdir().unwrap();
cast_entities(d.path(), &["s", "player"]);
let base = [
"corpus",
"add",
"--slug",
"x",
"--speaker",
"entity:s",
"--group",
"g",
"--admitted-on",
"2026-10-29",
];
// S6 refuses one text under two keys, so each run writes its own.
let runs = std::cell::Cell::new(0);
let run = |extra: &[&str]| {
runs.set(runs.get() + 1);
let text = format!("line {}", runs.get());
let mut args = base.to_vec();
args.extend_from_slice(&["--text", &text]);
args.extend_from_slice(extra);
weksa(d.path(), &args)
};
let no_family = run(&["--source", "model-written"]);
assert!(
!no_family.status.success() && out(&no_family).contains("--model-family"),
"{}",
out(&no_family)
);
let not_an_enum_value = run(&["--source", "model-written", "--model-family", "m"]);
assert!(
!not_an_enum_value.status.success(),
"{}",
out(&not_an_enum_value)
);
let model = run(&[
"--source",
"model-written",
"--model-family",
"open-ai",
"--generator",
"g",
]);
assert!(model.status.success(), "{}", out(&model));
let dump = weksa(d.path(), &["corpus", "dump"]);
assert!(
out(&dump).contains("OpenAi") && out(&dump).contains("generator: Some(\"g\")"),
"{}",
out(&dump)
);
let no_work = run(&["--source", "public-domain", "--author", "a"]);
assert!(
!no_work.status.success() && out(&no_work).contains("--work"),
"{}",
out(&no_work)
);
let pd = run(&[
"--source",
"public-domain",
"--work",
"w",
"--author",
"a",
"--edition-url",
"https://example.org/w",
]);
assert!(pd.status.success(), "{}", out(&pd));
let bad_turn = run(&[
"--source",
"operator-written",
"--prior-turn",
"no equals sign",
]);
assert!(
!bad_turn.status.success() && out(&bad_turn).contains("speaker=text"),
"{}",
out(&bad_turn)
);
let turn = run(&[
"--source",
"operator-written",
"--prior-turn",
"entity:player=hello",
]);
assert!(turn.status.success(), "{}", out(&turn));
}
#[test]
fn validate_reports_each_packet() {
let d = tempfile::tempdir().unwrap();
let w = common::world();
let full = w.dir.path().join("i.cc");
let packets = d.path().join("p.cc");
let packet = common::good();
PacketStore::open(&packets)
.unwrap()
.admit(packet.clone(), &w.inventory, &w.corpus, &w.entities)
.unwrap();
let validate = |inventory: &std::path::Path, extra: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_weksa"))
.arg("--inventory")
.arg(inventory)
.arg("validate")
.arg(&packets)
.args(extra)
.output()
.unwrap()
};
let ok = validate(&full, &[]);
assert!(
ok.status.success() && out(&ok).contains(&format!("ok {}", packet.id)),
"{}",
out(&ok)
);
assert!(validate(&full, &[packet.id.as_str()]).status.success());
assert!(!validate(&full, &["packet:missing"]).status.success());
// Against an inventory that only reaches revision 1 the same packet is refused, by rule.
let short = d.path().join("short.cc");
let (rev1, entries1) = weksa::inventory::seed();
InventoryStore::open(&short)
.unwrap()
.admit_revision(rev1, entries1)
.unwrap();
let refused = validate(&short, &[]);
assert!(
!refused.status.success() && out(&refused).contains("V10"),
"{}",
out(&refused)
);
}
#[test]
fn schema_writes_the_published_files() {
let d = tempfile::tempdir().unwrap();
let o = weksa(d.path(), &["schema", "--out", "s"]);
assert!(o.status.success(), "{}", out(&o));
let mut names: Vec<String> = std::fs::read_dir(d.path().join("s"))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
names.sort();
assert_eq!(
names,
[
"weksa.corpus_item.schema.json",
"weksa.entity.schema.json",
"weksa.interlingua_packet.schema.json",
"weksa.inventory_entry.schema.json",
"weksa.inventory_revision.schema.json"
]
);
}
#[test]
fn entity_put_and_name_spans() {
let d = tempfile::tempdir().unwrap();
let put = |args: &[&str]| {
let mut all = vec![
"entity",
"put",
"entity:kay",
"--origin",
"cast",
"--fixture",
"f.yaml",
];
all.extend_from_slice(args);
weksa(d.path(), &all)
};
let no_persona = put(&["--form", "en-US=Kay"]);
assert!(
!no_persona.status.success() && out(&no_persona).contains("--persona"),
"{}",
out(&no_persona)
);
let first = put(&["--form", "en-US=Kay", "--persona", "kay"]);
assert!(
first.status.success() && out(&first).contains("Added entity:kay"),
"{}",
out(&first)
);
// Forms append; a language's forms keep the order given.
let grown = put(&[
"--form",
"en-US=Kay",
"--form",
"en-US=Kay's",
"--persona",
"kay",
]);
assert!(grown.status.success(), "{}", out(&grown));
let shrunk = put(&["--form", "en-US=Kay", "--persona", "kay"]);
assert!(
!shrunk.status.success() && out(&shrunk).contains("EntityNotAppendOnly"),
"{}",
out(&shrunk)
);
let dump = out(&weksa(d.path(), &["entity", "dump"]));
assert!(
dump.contains("entity:kay") && dump.contains("Kay's"),
"{dump}"
);
cast_entities(d.path(), &["nibu"]);
let add = |range: &str, text: &str| {
weksa(
d.path(),
&[
"corpus",
"add",
"--slug",
"kay",
"--text",
text,
"--speaker",
"entity:nibu",
"--group",
"g",
"--admitted-on",
"2026-10-29",
"--source",
"operator-written",
"--name",
range,
],
)
};
let named = add("0..3=entity:kay", "Kay left.");
assert!(named.status.success(), "{}", out(&named));
let unequal = add("0..3=entity:kay", "Kai left.");
assert!(
!unequal.status.success() && out(&unequal).contains("NameSpanNotAForm"),
"{}",
out(&unequal)
);
let malformed = add("0-3", "Kay again.");
assert!(
!malformed.status.success() && out(&malformed).contains("start..end=entity"),
"{}",
out(&malformed)
);
let dump = out(&weksa(d.path(), &["corpus", "dump"]));
assert!(
dump.contains("names:") && dump.contains("entity:kay"),
"{dump}"
);
}
#[test]
fn entity_origin_flags_must_match_origin() {
let d = tempfile::tempdir().unwrap();
let put = |args: &[&str]| {
let mut all = vec!["entity", "put", "entity:kay", "--form", "en-US=Kay"];
all.extend_from_slice(args);
weksa(d.path(), &all)
};
for mismatched in [
&["--origin", "live"][..],
&["--origin", "live", "--persona", "kay", "--work", "edda"],
&[
"--origin",
"live",
"--persona",
"kay",
"--fixture",
"f.yaml",
],
&["--origin", "cast", "--persona", "kay"],
&["--origin", "cast", "--fixture", "f.yaml"],
&[
"--origin",
"cast",
"--persona",
"kay",
"--fixture",
"f.yaml",
"--work",
"edda",
],
&["--origin", "public-domain"],
&[
"--origin",
"public-domain",
"--work",
"edda",
"--persona",
"kay",
],
&[
"--origin",
"public-domain",
"--work",
"edda",
"--fixture",
"f.yaml",
],
&["--origin", "cast", "--persona", "", "--fixture", "f.yaml"],
&["--origin", "cast", "--persona", "kay", "--fixture", ""],
&["--origin", "live", "--persona", ""],
&["--origin", "public-domain", "--work", ""],
] {
let o = put(mismatched);
assert!(!o.status.success(), "{mismatched:?}: {}", out(&o));
}
assert!(!out(&weksa(d.path(), &["entity", "dump"])).contains("entity:kay"));
assert!(
put(&["--origin", "live", "--persona", "kay"])
.status
.success()
);
let work = weksa(
d.path(),
&[
"entity",
"put",
"entity:brokkr",
"--form",
"en-US=Brokkr",
"--origin",
"public-domain",
"--work",
"edda",
],
);
assert!(work.status.success(), "{}", out(&work));
assert!(out(&weksa(d.path(), &["entity", "dump"])).contains("PublicDomain"));
}
#[test]
fn intake_normalizes_to_nfc() {
let d = tempfile::tempdir().unwrap();
// A decomposed form and decomposed text meet as the same composed bytes.
let put = weksa(
d.path(),
&[
"entity",
"put",
"entity:rene",
"--form",
"fr=Rene\u{301}",
"--origin",
"live",
"--persona",
"rene",
],
);
assert!(put.status.success(), "{}", out(&put));
cast_entities(d.path(), &["nibu"]);
let add = weksa(
d.path(),
&[
"corpus",
"add",
"--slug",
"rene",
"--text",
"Rene\u{301} part.",
"--language",
"fr",
"--speaker",
"entity:nibu",
"--group",
"g",
"--admitted-on",
"2026-10-29",
"--source",
"operator-written",
"--name",
"0..5=entity:rene",
],
);
assert!(add.status.success(), "{}", out(&add));
let dump = out(&weksa(d.path(), &["corpus", "dump"]));
assert!(dump.contains("Ren\u{e9} part."), "{dump}");
}
/// `corpus add` of "Mind the hatch." by `entity:nibu` under `source`, with
/// `extra` flags.
fn add_with(dir: &std::path::Path, source: &str, extra: &[&str]) -> Output {
let mut args = vec![
"corpus",
"add",
"--slug",
"nibu",
"--text",
"Mind the hatch.",
"--speaker",
"entity:nibu",
"--group",
"scene-1",
"--admitted-on",
"2026-10-29",
"--source",
source,
];
args.extend_from_slice(extra);
weksa(dir, &args)
}
#[test]
fn source_flags_must_match_the_source() {
let d = tempfile::tempdir().unwrap();
cast_entities(d.path(), &["nibu"]);
// A provenance flag that does not apply to --source refuses, naming the flag.
for (source, extra, flag) in [
("operator-written", &["--persona", "p"][..], "--persona"),
(
"operator-approved",
&["--note", "n", "--work", "w"],
"--work",
),
(
"public-domain",
&[
"--work",
"w",
"--author",
"a",
"--edition-url",
"u",
"--note",
"n",
],
"--note",
),
(
"cast-authored",
&[
"--persona",
"p",
"--fixture",
"f",
"--author",
"a",
"--generator",
"g",
],
"--generator",
),
(
"model-written",
&["--model-family", "unrecorded", "--edition-url", "u"],
"--edition-url",
),
(
"operator-written",
&["--model-family", "unrecorded"],
"--model-family",
),
(
"model-written",
&[
"--model-family",
"unrecorded",
"--prompt-digest",
"d",
"--fixture",
"f",
],
"--fixture",
),
// A source missing a flag it needs.
(
"cast-authored",
&["--persona", "p", "--fixture", "f"],
"--author",
),
(
"public-domain",
&["--work", "w", "--edition-url", "u"],
"--author",
),
// Empty provenance strings.
(
"model-written",
&["--model-family", "unrecorded", "--generator", ""],
"",
),
(
"model-written",
&["--model-family", "unrecorded", "--prompt-digest", ""],
"",
),
(
"cast-authored",
&["--persona", "", "--fixture", "f", "--author", "a"],
"",
),
("operator-approved", &["--note", ""], ""),
] {
let o = add_with(d.path(), source, extra);
assert!(!o.status.success(), "{source} {extra:?}: {}", out(&o));
assert!(out(&o).contains(flag), "{source} {extra:?}: {}", out(&o));
}
assert!(!out(&weksa(d.path(), &["corpus", "dump"])).contains("item:"));
// A source whose flags all apply is admitted; --author reaches a
// cast-authored item.
let cast = add_with(
d.path(),
"cast-authored",
&["--persona", "p", "--fixture", "f", "--author", "Ada Writer"],
);
assert!(cast.status.success(), "{}", out(&cast));
let dump = out(&weksa(d.path(), &["corpus", "dump"]));
assert!(dump.contains("author: \"Ada Writer\""), "{dump}");
}
#[test]
fn malformed_intake_never_echoes_its_values() {
const CANARY: &str = "CANARY-7f3a";
let d = tempfile::tempdir().unwrap();
cast_entities(d.path(), &["nibu"]);
let shape = |value: &str| format!("{CANARY}{value}");
let puts = [
(vec![shape("")], "--form"),
(vec![shape("_x=Kay")], ""),
(vec![format!("en-US={CANARY}\u{7}")], ""),
(vec![format!("{CANARY}_x=Kay")], ""),
];
for (forms, flag) in puts {
let mut args = vec![
"entity",
"put",
"entity:kay",
"--origin",
"live",
"--persona",
"kay",
];
for f in &forms {
args.extend(["--form", f]);
}
let o = weksa(d.path(), &args);
assert!(!o.status.success(), "{forms:?}: {}", out(&o));
assert!(!out(&o).contains(CANARY), "{forms:?}: {}", out(&o));
assert!(out(&o).contains(flag), "{forms:?}: {}", out(&o));
}
let bad_turn = [shape(""), format!("{CANARY}=hello")];
let bad_name = [
shape(""),
format!("0..1={CANARY}"),
format!("{CANARY}..1=entity:nibu"),
format!("0..{CANARY}=entity:nibu"),
];
let cases = bad_turn
.iter()
.map(|t| ("--prior-turn", t))
.chain(bad_name.iter().map(|n| ("--name", n)));
for (flag, value) in cases {
let o = add_with(d.path(), "operator-written", &[flag, value]);
assert!(!o.status.success(), "{flag} {value}: {}", out(&o));
assert!(!out(&o).contains(CANARY), "{flag} {value}: {}", out(&o));
assert!(out(&o).contains(flag), "{flag} {value}: {}", out(&o));
}
}
#[test]
fn every_decomposed_form_and_turn_is_stored_composed() {
let d = tempfile::tempdir().unwrap();
cast_entities(d.path(), &["nibu"]);
let put = weksa(
d.path(),
&[
"entity",
"put",
"entity:rene",
"--form",
"fr=Rene\u{301}",
"--form",
"fr=Zoe\u{308}",
"--origin",
"live",
"--persona",
"rene",
],
);
assert!(put.status.success(), "{}", out(&put));
let rene = EntityStore::open(&d.path().join("e.cc"))
.unwrap()
.get(&"entity:rene".parse().unwrap())
.unwrap()
.unwrap();
assert_eq!(rene.forms[0].forms, ["Ren\u{e9}", "Zo\u{eb}"]);
let add = add_with(
d.path(),
"operator-written",
&["--prior-turn", "entity:rene=Cafe\u{301} now"],
);
assert!(add.status.success(), "{}", out(&add));
let item = CorpusStore::open(&d.path().join("c.cc"))
.unwrap()
.get("item:nibu:1")
.unwrap()
.unwrap();
assert_eq!(item.prior_turns[0].text, "Caf\u{e9} now");
}
/// An Ollama stand-in: every text embeds to `[1, its length]`, so identical
/// texts have cosine 1.
fn fake_embedder() -> String {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut buf = vec![0u8; 65536];
let mut n = 0;
let body = loop {
n += stream.read(&mut buf[n..]).unwrap();
let text = String::from_utf8_lossy(&buf[..n]).into_owned();
if let Some((head, body)) = text.split_once("\r\n\r\n") {
let want: usize = head
.to_lowercase()
.split("content-length: ")
.nth(1)
.unwrap()
.split("\r\n")
.next()
.unwrap()
.parse()
.unwrap();
if body.len() >= want {
break body.to_string();
}
}
};
let body: serde_json::Value = serde_json::from_str(&body).unwrap();
let vectors: Vec<Vec<f64>> = body["input"]
.as_array()
.unwrap()
.iter()
.map(|t| vec![1.0, t.as_str().unwrap().len() as f64])
.collect();
let reply = serde_json::json!({ "embeddings": vectors }).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{reply}",
reply.len()
)
.unwrap();
}
});
url
}
#[test]
fn proof_packet_lowers_and_the_lowering_scores_against_items() {
let d = tempfile::tempdir().unwrap();
cast_entities(d.path(), &["nibu"]);
for args in [&["inventory", "seed"][..], &["inventory", "proof"]] {
let run = weksa(d.path(), args);
assert!(run.status.success(), "{}", out(&run));
}
let item = weksa(
d.path(),
&[
"corpus",
"add",
"--slug",
"nibu",
"--text",
"Warning: you may endanger yourself by touching the control. The ship mind will prevent that to protect you.",
"--speaker",
"entity:nibu",
"--group",
"scene-1",
"--admitted-on",
"2026-11-01",
"--source",
"operator-written",
],
);
assert!(item.status.success(), "{}", out(&item));
let admitted = weksa(d.path(), &["packet", "proof", "./p.cc"]);
assert!(
admitted.status.success() && out(&admitted).contains("admitted packet:6e6962752d303031"),
"{}",
out(&admitted)
);
let lowered = weksa(d.path(), &["lower", "./p.cc", "packet:6e6962752d303031"]);
assert!(lowered.status.success(), "{}", out(&lowered));
let text = String::from_utf8(lowered.stdout).unwrap();
assert!(
text.starts_with("Warning: you may endanger yourself"),
"{text}"
);
let missing = weksa(d.path(), &["lower", "./p.cc", "packet:0000000000000000"]);
assert!(!missing.status.success() && out(&missing).contains("no packet"));
std::fs::write(d.path().join("out.txt"), &text).unwrap();
let url = fake_embedder();
let scored = weksa(
d.path(),
&[
"score",
"item:nibu:1",
"--output",
"out.txt",
"--ollama-url",
&url,
],
);
assert!(scored.status.success(), "{}", out(&scored));
assert_eq!(
out(&scored).trim(),
"item:nibu:1 chrf=100.00 meaning=1.0000"
);
let unknown = weksa(
d.path(),
&[
"score",
"item:nibu:9",
"--output",
"out.txt",
"--ollama-url",
&url,
],
);
assert!(!unknown.status.success() && out(&unknown).contains("no item item:nibu:9"));
}

View file

@ -0,0 +1,331 @@
#![allow(dead_code)]
use std::sync::OnceLock;
use tempfile::TempDir;
use weksa::corpus::ModelFamily;
use weksa::inventory::{Change, seed};
use weksa::packet::*;
use weksa::{
CorpusItem, CorpusItemId, CorpusStore, Entity, EntityOrigin, EntityStore, InvId, Inventory,
InventoryEntry, InventoryRevision, InventoryStore, LanguageForms, NameSpan, OptionLabel, Slot,
Source, SplitScheme, ValueKind,
};
pub const PERSON: &str = "referent_kind:1";
pub const AGENT: &str = "role:1";
pub const PATIENT: &str = "role:2";
pub const WARN: &str = "speech_act:5";
pub const POLARITY: &str = "feature:1";
pub const MODALITY: &str = "feature:4";
pub const HEDGE: &str = "feature:6";
pub const IRONY: &str = "feature:8";
/// Stance features, slot Stance only (Grade).
pub const ANGER: &str = "feature:9";
pub const AFFECTION: &str = "feature:16";
pub const RESTRAINT: &str = "feature:18";
/// Introduced at rev 2.
pub const CONCEPT_A: &str = "concept:1";
pub const CONCEPT_B: &str = "concept:2";
/// Introduced at rev 2, retired at rev 3.
pub const CONCEPT_RETIRED: &str = "concept:3";
/// Introduced at rev 4.
pub const CONCEPT_LATE: &str = "concept:4";
pub const FRAME: &str = "frame:1";
/// An extension entry of value kind Grade, introduced at rev 2.
pub const EXT: &str = "extension:1";
/// A Grade feature introduced at rev 2 that sits in every slot.
pub const FEAT_ALL: &str = "feature:50";
pub const PACKET_ID: &str = "packet:0123456789abcdef";
pub const ENCODER_ID: &str = "encoder:fedcba9876543210";
pub fn iv(s: &str) -> InvId {
s.parse().unwrap()
}
pub fn nd(s: &str) -> NodeId {
NodeId::parse(s).unwrap()
}
pub fn lab(s: &str) -> OptionLabel {
OptionLabel::parse(s).unwrap()
}
pub fn grade(v: u16) -> Grade {
Grade::new(v).unwrap()
}
pub fn group_in_bucket(want: u64) -> String {
(0..100_000)
.map(|i| format!("g{i}"))
.find(|g| SplitScheme::V1.bucket(g) == want)
.unwrap_or_else(|| panic!("no group lands in bucket {want}"))
}
pub fn cast() -> Source {
Source::CastAuthored {
persona: "nibu".into(),
fixture: "f.yaml".into(),
author: "Ada Writer".into(),
}
}
pub fn model() -> Source {
Source::ModelWritten {
family: ModelFamily::Unrecorded,
generator: None,
prompt_digest: None,
}
}
/// Every speaker the tests use, as entities with one en-US form.
const SPEAKERS: [&str; 6] = ["nibu", "brokkr", "player", "s", "someone", "hero"];
pub fn eid(slug: &str) -> EntityId {
EntityId::parse(&format!("entity:{slug}")).unwrap()
}
/// A cast entity with `forms` in `language`, citation form first.
pub fn ent(slug: &str, language: &str, forms: &[&str]) -> Entity {
Entity {
id: eid(slug),
forms: vec![LanguageForms {
language: language.into(),
forms: forms.iter().map(|f| f.to_string()).collect(),
}],
origin: EntityOrigin::Cast {
persona: slug.into(),
fixture: "f.yaml".into(),
},
}
}
/// The entity store at `entities.cc` under `dir`, holding every test speaker
/// (en-US form: the slug capitalized). Reopening replays identically.
pub fn entities_in(dir: &std::path::Path) -> EntityStore {
let mut store = EntityStore::open(&dir.join("entities.cc")).unwrap();
for slug in SPEAKERS {
let form = slug[..1].to_uppercase() + &slug[1..];
store.put(ent(slug, "en-US", &[&form])).unwrap();
}
store
}
pub fn item_in(key: &str, text: &str, group: &str, source: Source) -> CorpusItem {
CorpusItem {
id: key.into(),
text: text.into(),
language: "en-US".into(),
speaker: eid("nibu"),
names: vec![],
group: group.into(),
prior_turns: vec![],
source,
admitted_on: "2026-10-29".into(),
supersedes: None,
withdrawn: false,
}
}
pub fn entry(id: &str, introduced: u32) -> InventoryEntry {
InventoryEntry {
id: iv(id),
gloss: id.into(),
value_kind: None,
applies_to: vec![],
introduced_rev: introduced,
retired_rev: None,
}
}
pub fn feature(id: &str, kind: ValueKind, slots: &[Slot]) -> InventoryEntry {
InventoryEntry {
value_kind: Some(kind),
applies_to: slots.to_vec(),
..entry(id, 1)
}
}
pub fn rev_with(changes: Vec<Change>, n: u32, parent: Option<u32>) -> InventoryRevision {
InventoryRevision {
n,
parent,
changes,
frozen: false,
}
}
/// Admits `entries` as revision `n` and returns nothing; the stores are the only door.
pub fn admit(store: &mut InventoryStore, n: u32, entries: Vec<InventoryEntry>) {
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
let parent = n.checked_sub(1).filter(|p| *p > 0);
store
.admit_revision(rev_with(changes, n, parent), entries)
.unwrap();
}
/// Stores a test can admit through: a corpus holding `item:nibu:1` in a Train
/// group, and an inventory of revisions 1 (the seed), 2 (concepts A, B,
/// RETIRED, a frame, a Grade extension), 3 (RETIRED retired) and 4 (LATE).
pub struct World {
pub dir: TempDir,
pub inventory: InventoryStore,
pub corpus: CorpusStore,
pub entities: EntityStore,
}
impl World {
pub fn inv(&self) -> Inventory {
self.inventory.inventory().unwrap()
}
}
pub fn world() -> World {
let dir = tempfile::tempdir().unwrap();
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
let entities = entities_in(dir.path());
let train = group_in_bucket(10);
let mut first = item_in("item:nibu:1", "Nibu says mind the hatch.", &train, cast());
first.names = vec![NameSpan {
entity: eid("nibu"),
start: 0,
end: 4,
}];
corpus.add(first, &entities).unwrap();
let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
let (rev1, entries) = seed();
inventory.admit_revision(rev1, entries).unwrap();
let mut ext = entry(EXT, 2);
ext.value_kind = Some(ValueKind::Grade);
admit(
&mut inventory,
2,
vec![
entry(CONCEPT_A, 2),
entry(CONCEPT_B, 2),
entry(CONCEPT_RETIRED, 2),
entry(FRAME, 2),
ext,
InventoryEntry {
introduced_rev: 2,
..feature(
FEAT_ALL,
ValueKind::Grade,
&[
Slot::Discourse,
Slot::Referent,
Slot::Predication,
Slot::Relation,
Slot::Stance,
],
)
},
],
);
inventory
.admit_revision(
rev_with(vec![Change::Retire(iv(CONCEPT_RETIRED))], 3, Some(2)),
vec![],
)
.unwrap();
admit(&mut inventory, 4, vec![entry(CONCEPT_LATE, 4)]);
World {
dir,
inventory,
corpus,
entities,
}
}
/// The world's inventory, built once.
pub fn inventory() -> Inventory {
static INV: OnceLock<Inventory> = OnceLock::new();
INV.get_or_init(|| world().inv()).clone()
}
pub fn good() -> Packet {
Packet {
id: PacketId::parse(PACKET_ID).unwrap(),
schema_version: SCHEMA_VERSION,
inventory_rev: 2,
kind: PacketKind::DialogueLine,
provenance: Provenance {
source: ProvenanceSource::Authored,
corpus_item: CorpusItemId::parse("item:nibu:1").unwrap(),
encoder: EncoderId::parse(ENCODER_ID).unwrap(),
confidence: grade(900),
},
context: Context {
setting: Some(iv(CONCEPT_A)),
medium: Some(iv(CONCEPT_B)),
participants: vec![nd("n1")],
audience: vec![nd("n1")],
},
discourse: Discourse {
speech_act: Some(iv(WARN)),
addressee: Some(nd("n1")),
features: vec![FeatureValue {
feature: iv(HEDGE),
value: FeatValue::Grade(grade(300)),
}],
},
referents: vec![Referent {
id: nd("n1"),
kind: iv(PERSON),
concept: iv(CONCEPT_A),
entity: Some(eid("nibu")),
features: vec![],
}],
predications: vec![Predication {
id: nd("n2"),
concept: iv(CONCEPT_B),
frame: iv(FRAME),
roles: vec![
RoleFill {
role: iv(AGENT),
filler: nd("n1"),
},
RoleFill {
role: iv(PATIENT),
filler: nd("n1"),
},
],
features: vec![FeatureValue {
feature: iv(MODALITY),
value: FeatValue::Option(lab("potential")),
}],
}],
relations: vec![Relation {
relation: iv("relation:8"),
from: nd("n2"),
to: nd("n1"),
features: vec![],
}],
constraints: Constraints {
preserve: vec![nd("n1")],
deterministic: true,
max_latency_ms: Some(50),
},
extensions: vec![Extension {
key: iv(EXT),
value: FeatValue::Grade(grade(3)),
}],
trace: Trace {
notes: vec!["a note".into()],
rejected_readings: vec![],
},
stance: Stance {
features: vec![
FeatureValue {
feature: iv(ANGER),
value: FeatValue::Grade(grade(300)),
},
FeatureValue {
feature: iv(RESTRAINT),
value: FeatValue::Grade(grade(700)),
},
],
},
}
}

View file

@ -0,0 +1,429 @@
mod common;
use common::*;
use weksa::corpus::{CorpusItem, NameSpan, Turn};
use weksa::packet::{Packet, Referent};
use weksa::{
AddOutcome, CorpusItemId, CorpusStore, Entity, EntityId, EntityOrigin, EntityStore, PacketId,
PacketStore, Refusal, SCHEMA_VERSION, StoreError, StoreRefusal, validate,
};
fn refusal(r: Result<impl std::fmt::Debug, StoreError>) -> StoreRefusal {
match r {
Err(StoreError::Refused(r)) => r,
other => panic!("expected a store refusal, got {other:?}"),
}
}
fn malformed(r: Result<impl std::fmt::Debug, StoreError>) -> &'static str {
match refusal(r) {
StoreRefusal::Malformed { why, .. } => why,
other => panic!("expected Malformed, got {other:?}"),
}
}
fn span(slug: &str, start: u32, end: u32) -> NameSpan {
NameSpan {
entity: eid(slug),
start,
end,
}
}
/// An item in `language` whose text and spans the test chooses.
fn named(n: u32, language: &str, text: &str, names: Vec<NameSpan>) -> CorpusItem {
CorpusItem {
language: language.into(),
names,
..item_in(&format!("item:t:{n}"), text, "g", cast())
}
}
#[test]
fn entity_id_form() {
for ok in ["entity:brokkr", "entity:a-1", "entity:7", "entity:a-b-c"] {
assert_eq!(EntityId::parse(ok).unwrap().as_str(), ok);
}
let long_ok = format!("entity:{}", "a".repeat(48));
assert!(EntityId::parse(&long_ok).is_ok());
let too_long = format!("entity:{}", "a".repeat(49));
for bad in [
"Entity:x",
"entity:-a",
"entity:a-",
"entity:",
"entity:a_b",
"entity:A",
"entity:a b",
"entity:a:b",
"brokkr",
"entity:\u{5c71}",
&too_long,
] {
assert!(EntityId::parse(bad).is_err(), "{bad:?}");
assert!(
serde_json::from_value::<EntityId>(bad.into()).is_err(),
"{bad:?} must not read back from a store"
);
}
// A referent reads its entity through the same parse.
let mut referent = serde_json::to_value(good().referents[0].clone()).unwrap();
assert!(serde_json::from_value::<Referent>(referent.clone()).is_ok());
referent["entity"] = "Nibu".into();
assert!(serde_json::from_value::<Referent>(referent).is_err());
}
#[test]
fn entity_forms_only_grow() {
let d = tempfile::tempdir().unwrap();
let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap();
let kay = ent("kay", "en-US", &["Kay"]);
assert_eq!(store.put(kay.clone()).unwrap(), AddOutcome::Added);
assert_eq!(store.put(kay.clone()).unwrap(), AddOutcome::Unchanged);
// A form appends; a language appends.
let mut grown = kay.clone();
grown.forms[0].forms.push("Kay's".into());
assert_eq!(store.put(grown.clone()).unwrap(), AddOutcome::Added);
let mut more = grown.clone();
more.forms.push(weksa::LanguageForms {
language: "tr".into(),
forms: vec!["Kay".into()],
});
assert_eq!(store.put(more.clone()).unwrap(), AddOutcome::Added);
// The store read back from disk holds the growth.
let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap();
assert_eq!(store.get(&eid("kay")).unwrap(), Some(more.clone()));
// Dropping, reordering, editing and re-originating are refused, each leaving
// the stored entity as it was.
let not_append = StoreRefusal::EntityNotAppendOnly { id: eid("kay") };
let edits: [fn(&mut Entity); 6] = [
|e| e.forms[0].forms.reverse(),
|e| e.forms[0].forms[0] = "Kai".into(),
|e| {
e.forms.pop();
},
|e| e.forms.reverse(),
|e| e.forms[1].language = "fi".into(),
|e| {
e.origin = EntityOrigin::Live {
persona: "kay".into(),
}
},
];
assert_eq!(
refusal(store.put(ent("kay", "en-US", &["Kay"]))),
not_append
);
for edit in edits {
let mut v = more.clone();
edit(&mut v);
assert_eq!(refusal(store.put(v.clone())), not_append, "{v:?}");
}
assert_eq!(store.get(&eid("kay")).unwrap(), Some(more));
// Shape: no forms, a language twice, an empty language, an empty language
// entry, an empty, padded, control-bearing, oversize or repeated form.
let shape = |forms: Vec<weksa::LanguageForms>| weksa::Entity {
forms,
..ent("shape", "en-US", &["X"])
};
let lf = |language: &str, forms: &[&str]| weksa::LanguageForms {
language: language.into(),
forms: forms.iter().map(|f| f.to_string()).collect(),
};
for bad in [
vec![],
vec![lf("en-US", &["A"]), lf("en-US", &["B"])],
vec![lf("en-US", &[])],
vec![lf("en-US", &[""])],
vec![lf("en-US", &[" A"])],
vec![lf("en-US", &["A\u{a0}"])],
vec![lf("en-US", &["A\nB"])],
vec![lf("en-US", &["A", "A"])],
] {
malformed(store.put(shape(bad.clone())));
assert!(
store.get(&eid("shape")).unwrap().is_none(),
"{bad:?} was stored"
);
}
// An empty language is not a tag.
let e = shape(vec![lf("", &["A"])]);
assert!(matches!(
refusal(store.put(e)),
StoreRefusal::EntityBounds {
bound: weksa::store::EntityBound::LanguageTag,
..
}
));
// 64 bytes is the edge; a form may repeat across languages.
let edge = "a".repeat(64);
store
.put(shape(vec![lf("en-US", &[&edge]), lf("tr", &[&edge])]))
.unwrap();
}
#[test]
fn corpus_spans_checked() {
let d = tempfile::tempdir().unwrap();
let mut ents = entities_in(d.path());
ents.put(ent("kay", "en-US", &["Kay"])).unwrap();
ents.put(ent("ab", "en-US", &["abab", "bab"])).unwrap();
let mut corpus = CorpusStore::open(&d.path().join("c.cc")).unwrap();
assert_eq!(
corpus
.add(
named(1, "en-US", "Kay left.", vec![span("kay", 0, 3)]),
&ents
)
.unwrap(),
AddOutcome::Added
);
let range = |n: u32, start: u32, end: u32| StoreRefusal::NameSpanRange {
key: format!("item:t:{n}"),
start,
end,
};
let not_a_form = |n: u32, slug: &str, start: u32, end: u32| StoreRefusal::NameSpanNotAForm {
key: format!("item:t:{n}"),
entity: eid(slug),
start,
end,
};
let mut refused = |item: CorpusItem| refusal(corpus.add(item, &ents));
// Empty, backwards, past the end: a range refusal.
for (start, end) in [(3, 3), (4, 3), (0, 99)] {
let item = named(2, "en-US", "Kay left.", vec![span("kay", start, end)]);
assert_eq!(refused(item), range(2, start, end));
}
// Inside a multibyte character: start and end off the boundary.
let text = "\u{e9}Kay";
assert_eq!(text.len(), 5);
let item = named(2, "en-US", text, vec![span("kay", 1, 4)]);
assert_eq!(refused(item), range(2, 1, 4));
let item = named(2, "en-US", "Kay\u{e9}", vec![span("kay", 0, 4)]);
assert_eq!(refused(item), range(2, 0, 4));
// An unknown entity.
let item = named(2, "en-US", "Kay left.", vec![span("ghost", 0, 3)]);
assert_eq!(
refused(item),
StoreRefusal::UnknownEntity {
entity: eid("ghost")
}
);
// Not byte-equal: other text, other case, a missing language.
let item = named(2, "en-US", "Kai left.", vec![span("kay", 0, 3)]);
assert_eq!(refused(item), not_a_form(2, "kay", 0, 3));
let item = named(2, "en-US", "kay left.", vec![span("kay", 0, 3)]);
assert_eq!(refused(item), not_a_form(2, "kay", 0, 3));
let item = named(2, "tr", "Kay left.", vec![span("kay", 0, 3)]);
assert_eq!(refused(item), not_a_form(2, "kay", 0, 3));
// Overlap: both spans are forms, they share bytes.
let item = named(2, "en-US", "abab", vec![span("ab", 0, 4), span("ab", 1, 4)]);
assert_eq!(
refused(item),
StoreRefusal::NameSpanOverlap {
key: "item:t:2".into()
}
);
// Adjacent spans do not overlap, whatever order they are listed in.
let adjacent = named(
90,
"en-US",
"KayKay",
vec![span("kay", 3, 6), span("kay", 0, 3)],
);
assert_eq!(corpus.add(adjacent, &ents).unwrap(), AddOutcome::Added);
// Speakers: unknown, and unknown in a prior turn.
let ghost = EntityId::parse("entity:ghost").unwrap();
let unknown = StoreRefusal::UnknownEntity {
entity: ghost.clone(),
};
let mut item = named(91, "en-US", "Hello there.", vec![]);
item.speaker = ghost.clone();
assert_eq!(refusal(corpus.add(item, &ents)), unknown);
let mut item = named(92, "en-US", "Hello again.", vec![]);
item.prior_turns = vec![
Turn {
speaker: eid("nibu"),
text: "hi".into(),
},
Turn {
speaker: ghost,
text: "hi".into(),
},
];
assert_eq!(refusal(corpus.add(item, &ents)), unknown);
let mut item = named(93, "en-US", "Hello at last.", vec![]);
item.prior_turns = vec![Turn {
speaker: eid("nibu"),
text: "hi".into(),
}];
assert_eq!(corpus.add(item, &ents).unwrap(), AddOutcome::Added);
// Nothing refused was stored.
let keys: Vec<String> = corpus.all().unwrap().into_iter().map(|i| i.id).collect();
assert_eq!(keys, ["item:t:1", "item:t:90", "item:t:93"]);
}
#[test]
fn corpus_spans_every_script() {
let d = tempfile::tempdir().unwrap();
let mut ents = entities_in(d.path());
ents.put(ent(
"minsu",
"ko",
&["\u{BBFC}\u{C218}", "\u{BBFC}\u{C218}\u{AC00}"],
))
.unwrap();
ents.put(ent("brokkr-tr", "tr", &["Brokkr", "Brokkr'\u{131}n"]))
.unwrap();
ents.put(ent(
"somchai",
"th",
&["\u{E2A}\u{E21}\u{E0A}\u{E32}\u{E22}"],
))
.unwrap();
let mut corpus = CorpusStore::open(&d.path().join("c.cc")).unwrap();
// The particle-bound Korean form: byte equality with the stored form.
let ko = "\u{BBFC}\u{C218}\u{AC00} \u{C654}\u{B2E4}";
let with_particle = "\u{BBFC}\u{C218}\u{AC00}".len();
assert_eq!(with_particle, 9);
let item = named(1, "ko", ko, vec![span("minsu", 0, with_particle as u32)]);
assert_eq!(corpus.add(item, &ents).unwrap(), AddOutcome::Added);
// The bare stem is a stored form too; a span cutting a syllable is not.
let item = named(
2,
"ko",
"\u{BBFC}\u{C218} \u{C654}\u{B2E4}",
vec![span("minsu", 0, 6)],
);
assert_eq!(corpus.add(item, &ents).unwrap(), AddOutcome::Added);
let item = named(3, "ko", ko, vec![span("minsu", 0, 7)]);
assert!(matches!(
refusal(corpus.add(item, &ents)),
StoreRefusal::NameSpanRange { .. }
));
// Turkish: the apostrophe suffix.
let tr = "Brokkr'\u{131}n geldi.";
let len = "Brokkr'\u{131}n".len() as u32;
let item = named(4, "tr", tr, vec![span("brokkr-tr", 0, len)]);
assert_eq!(corpus.add(item, &ents).unwrap(), AddOutcome::Added);
// Thai: no spaces, no segmentation, a span is a byte range.
let th = "\u{E2A}\u{E21}\u{E0A}\u{E32}\u{E22}\u{E21}\u{E32}\u{E41}\u{E25}\u{E49}\u{E27}";
let len = "\u{E2A}\u{E21}\u{E0A}\u{E32}\u{E22}".len() as u32;
let item = named(5, "th", th, vec![span("somchai", 0, len)]);
assert_eq!(corpus.add(item, &ents).unwrap(), AddOutcome::Added);
// The same bytes under another language tag are refused.
let item = named(6, "ko", th, vec![span("somchai", 0, len)]);
assert!(matches!(
refusal(corpus.add(item, &ents)),
StoreRefusal::NameSpanNotAForm { .. }
));
}
#[test]
fn packet_entities_resolve() {
let mut w = world();
w.entities.put(ent("kay", "en-US", &["Kay"])).unwrap();
let mut talk = named(2, "en-US", "Kay and Hero talk.", vec![span("kay", 0, 3)]);
talk.id = "item:nibu:2".into();
talk.prior_turns = vec![Turn {
speaker: eid("hero"),
text: "hi".into(),
}];
w.corpus.add(talk, &w.entities).unwrap();
let mut packets = PacketStore::open(&w.dir.path().join("p.cc")).unwrap();
let packet = |n: u8, item: &str, entity: &str| {
let mut p = good();
p.id = PacketId::parse(&format!("packet:{n:016x}")).unwrap();
p.provenance.corpus_item = CorpusItemId::parse(item).unwrap();
p.referents[0].entity = Some(eid(entity));
p
};
let mut admit = |p: Packet| packets.admit(p, &w.inventory, &w.corpus, &w.entities);
// An entity the store lacks.
let unknown = StoreRefusal::UnknownEntity {
entity: eid("ghost"),
};
assert_eq!(refusal(admit(packet(1, "item:nibu:1", "ghost"))), unknown);
assert_eq!(refusal(admit(packet(2, "item:nibu:2", "ghost"))), unknown);
// A stored entity that is not on the item.
let off_item = |item: &str, entity: &str| StoreRefusal::EntityNotOnItem {
entity: eid(entity),
item: item.into(),
};
assert_eq!(
refusal(admit(packet(3, "item:nibu:2", "brokkr"))),
off_item("item:nibu:2", "brokkr")
);
// nibu:1 names nibu (and is spoken by nibu); kay is only on nibu:2.
assert_eq!(
refusal(admit(packet(4, "item:nibu:1", "kay"))),
off_item("item:nibu:1", "kay")
);
// On the item: a name entity, the speaker, a prior-turn speaker.
admit(packet(5, "item:nibu:2", "kay")).unwrap();
admit(packet(6, "item:nibu:2", "nibu")).unwrap();
admit(packet(7, "item:nibu:2", "hero")).unwrap();
// No packet escapes the item check: a stored entity that no item names is
// refused on every item, and a packet cannot omit its item.
assert_eq!(
refusal(admit(packet(8, "item:nibu:1", "brokkr"))),
off_item("item:nibu:1", "brokkr")
);
// A referent with no entity needs none; only a packet that cites checks.
let mut anonymous = packet(9, "item:nibu:2", "kay");
anonymous.referents[0].entity = None;
admit(anonymous).unwrap();
// Refused packets were not stored.
let stored: Vec<String> = PacketStore::open(&w.dir.path().join("p.cc"))
.unwrap()
.all()
.unwrap()
.iter()
.map(|p| p.id.to_string())
.collect();
assert_eq!(stored.len(), 4, "{stored:?}");
}
#[test]
fn packet_schema_v2() {
assert_eq!(SCHEMA_VERSION, 4);
let mut p = good();
p.schema_version = 2;
let Err(refused) = validate(&p, &inventory()) else {
panic!("a v1 packet must not validate")
};
assert_eq!(refused, vec![Refusal::V1SchemaVersion { found: 2 }]);
let published: std::collections::BTreeMap<_, _> =
weksa::schema::published().into_iter().collect();
let packet: serde_json::Value =
serde_json::from_str(&published["weksa.interlingua_packet.schema.json"]).unwrap();
let referent = &packet["$defs"]["Referent"]["properties"];
assert!(referent.get("entity").is_some(), "{referent}");
assert!(referent.get("name").is_none(), "{referent}");
let defs = packet["$defs"].as_object().unwrap();
assert!(!defs.keys().any(|k| k.ends_with("Name")), "{defs:?}");
assert_eq!(
packet["$defs"]["EntityId"]["pattern"],
"^entity:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$"
);
let item: serde_json::Value =
serde_json::from_str(&published["weksa.corpus_item.schema.json"]).unwrap();
assert!(item["properties"].get("names").is_some());
assert!(published.contains_key("weksa.entity.schema.json"));
// v4: the packet carries the speaker's stance, a bare feature list.
assert!(packet["properties"].get("stance").is_some(), "{packet}");
assert_eq!(
packet["$defs"]["Stance"]["properties"]["features"]["type"],
"array"
);
}

View file

@ -0,0 +1,638 @@
//! Behavioural pins from Soul's entity-store s1 probes: the entity door does not
//! trust the writer (ruling entity-door-bounds).
mod common;
use common::*;
use unicode_normalization::UnicodeNormalization;
use weksa::corpus::{CorpusItem, NameSpan};
use weksa::entity::{Entity, EntityOrigin, LanguageForms};
use weksa::store::EntityBound;
use weksa::{EntityStore, StoreError, StoreRefusal};
fn refusal(r: Result<impl std::fmt::Debug, StoreError>) -> StoreRefusal {
match r {
Err(StoreError::Refused(r)) => r,
other => panic!("expected a store refusal, got {other:?}"),
}
}
fn item(
n: impl std::fmt::Display,
lang: &str,
text: &str,
names: Vec<NameSpan>,
group: &str,
) -> CorpusItem {
CorpusItem {
language: lang.into(),
names,
..item_in(&format!("item:probe:{n}"), text, group, cast())
}
}
fn span(slug: &str, start: usize, end: usize) -> NameSpan {
NameSpan {
entity: eid(slug),
start: start as u32,
end: end as u32,
}
}
fn bounds(entity: &Entity, bound: EntityBound) -> StoreRefusal {
StoreRefusal::EntityBounds {
id: entity.id.clone(),
bound,
}
}
#[test]
fn span_never_splits_a_grapheme_cluster() {
let mut w = world();
let g = group_in_bucket(10);
let splits = |n: u32, start: usize, end: usize| StoreRefusal::NameSpanSplitsCluster {
key: format!("item:probe:{n}"),
start: start as u32,
end: end as u32,
};
// Devanagari: the word is a base, a spacing vowel sign and a base; the form
// "र" ends inside the cluster of the first two.
w.entities.put(ent("ra", "hi", &["र"])).unwrap();
let text = "राम आया";
assert_eq!(
refusal(
w.corpus
.add(item(1, "hi", text, vec![span("ra", 0, 3)], &g), &w.entities)
),
splits(1, 0, 3)
);
// The whole words are legal names, the second starting after a space.
w.entities.put(ent("ram", "hi", &["राम", "आया"])).unwrap();
let both = vec![span("ram", 0, 9), span("ram", 10, text.len())];
w.corpus
.add(item(2, "hi", text, both, &g), &w.entities)
.unwrap();
// A start inside a cluster is refused too.
w.entities.put(ent("aa", "hi", &["ा"])).unwrap();
assert_eq!(
refusal(w.corpus.add(
item(3, "hi", "राम x", vec![span("aa", 3, 6)], &g),
&w.entities
)),
splits(3, 3, 6)
);
// Latin base + combining mark with no precomposed form, so NFC keeps two
// code points: the form "q" leaves the accent outside.
let q = "q\u{301} arrive.";
w.entities.put(ent("q", "fr", &["q"])).unwrap();
assert_eq!(
refusal(
w.corpus
.add(item(4, "fr", q, vec![span("q", 0, 1)], &g), &w.entities)
),
splits(4, 0, 1)
);
w.entities.put(ent("qa", "fr", &["q\u{301}"])).unwrap();
w.corpus
.add(item(5, "fr", q, vec![span("qa", 0, 3)], &g), &w.entities)
.unwrap();
}
#[test]
fn text_and_forms_are_stored_nfc() {
let mut w = world();
let g = group_in_bucket(10);
let not_nfc = |key: &str| StoreRefusal::NotNfc { key: key.into() };
// Decomposed Hangul (conjoining jamo) text is refused, not segmented.
let nfd = "\u{1106}\u{1175}\u{11AB}\u{1109}\u{116E} \u{110B}\u{116A}\u{11BB}\u{1103}\u{1161}";
assert!(!unicode_normalization::is_nfc(nfd));
assert_eq!(
refusal(w.corpus.add(item(1, "ko", nfd, vec![], &g), &w.entities)),
not_nfc("item:probe:1")
);
// The same words composed are admitted, and a span on a syllable pair is exact.
let nfc: String = nfd.nfc().collect();
let name: String = "\u{1106}\u{1175}\u{11AB}\u{1109}\u{116E}".nfc().collect();
w.entities.put(ent("minsu", "ko", &[&name])).unwrap();
w.corpus
.add(
item(2, "ko", &nfc, vec![span("minsu", 0, name.len())], &g),
&w.entities,
)
.unwrap();
// Latin NFD text, NFD forms and NFD prior turns are refused.
assert_eq!(
refusal(w.corpus.add(
item(3, "fr", "Rene\u{301} arrive.", vec![], &g),
&w.entities
)),
not_nfc("item:probe:3")
);
assert_eq!(
refusal(w.entities.put(ent("rene", "fr", &["Rene\u{301}"]))),
not_nfc("entity:rene")
);
let mut turns = item(4, "fr", "Salut.", vec![], &g);
turns.prior_turns = vec![weksa::Turn {
speaker: eid("nibu"),
text: "Rene\u{301}?".into(),
}];
assert_eq!(
refusal(w.corpus.add(turns, &w.entities)),
not_nfc("item:probe:4")
);
// NFC text and an NFC form of the same name agree byte for byte.
w.entities.put(ent("rene", "fr", &["Ren\u{e9}"])).unwrap();
w.corpus
.add(
item(5, "fr", "Ren\u{e9} part.", vec![span("rene", 0, 5)], &g),
&w.entities,
)
.unwrap();
}
#[test]
fn entity_capacity_is_bounded() {
let mut w = world();
let lf = |language: &str, forms: Vec<String>| LanguageForms {
language: language.into(),
forms,
};
let one = |f: &str| vec![f.to_string()];
let make = |forms: Vec<LanguageForms>| Entity {
id: eid("big"),
forms,
origin: EntityOrigin::Live {
persona: "p".into(),
},
};
// A form is bounded by bytes, not words: a spaced name of any word count and
// names in scripts that write no spaces are admitted (the Thai name counts
// five UAX #29 words, a Han name one per character).
for (i, name) in [
"I will never leave you, not ever.",
"Mary of the Isles too",
"de la Cruz y Garcia",
"\u{e2a}\u{e21}\u{e0a}\u{e32}\u{e22} \u{e43}\u{e08}\u{e14}\u{e35}",
"\u{5c71}\u{7530}\u{592a}\u{90ce}",
]
.into_iter()
.enumerate()
{
w.entities
.put(Entity {
id: eid(&format!("name-{i}")),
..make(vec![lf("en-US", one(name))])
})
.unwrap();
}
// 64 bytes is the edge; 65 is over, counted in bytes (21 Han characters are
// 63 bytes, 22 are 66).
let wide = make(vec![lf("en-US", one(&"a".repeat(65)))]);
assert_eq!(
refusal(w.entities.put(wide.clone())),
bounds(&wide, EntityBound::FormBytes)
);
w.entities
.put(Entity {
id: eid("edge"),
..make(vec![lf("en-US", one(&"a".repeat(64)))])
})
.unwrap();
w.entities
.put(Entity {
id: eid("han-edge"),
..make(vec![lf("ja", one(&"\u{5c71}".repeat(21)))])
})
.unwrap();
let han = make(vec![lf("ja", one(&"\u{5c71}".repeat(22)))]);
assert_eq!(
refusal(w.entities.put(han.clone())),
bounds(&han, EntityBound::FormBytes)
);
// 8 forms per language is the edge.
let forms = |n: usize| (0..n).map(|i| format!("form{i}")).collect::<Vec<_>>();
w.entities
.put(Entity {
id: eid("eight"),
..make(vec![lf("en-US", forms(8))])
})
.unwrap();
let nine = make(vec![lf("en-US", forms(9))]);
assert_eq!(
refusal(w.entities.put(nine.clone())),
bounds(&nine, EntityBound::Forms)
);
// 8 languages is the edge.
let langs = |n: usize| {
(0..n)
.map(|i| lf(&format!("x-l{i}"), one("Form")))
.collect::<Vec<_>>()
};
w.entities
.put(Entity {
id: eid("langs"),
..make(langs(8))
})
.unwrap();
let many = make(langs(9));
assert_eq!(
refusal(w.entities.put(many.clone())),
bounds(&many, EntityBound::Languages)
);
// Soul's probe: 200 junk languages of 5000 long forms each.
let long: Vec<String> = (0..5000)
.map(|i| format!("form number {i} of a long free text channel"))
.collect();
let probe = make(
(0..200)
.map(|l| lf(&format!("x-{l}\n\u{7}"), long.clone()))
.collect(),
);
assert_eq!(
refusal(w.entities.put(probe.clone())),
bounds(&probe, EntityBound::Languages)
);
assert!(w.entities.get(&eid("big")).unwrap().is_none());
// Language tags are BCP 47 in form.
for bad in [
"",
"e",
"en_US",
"x-0\n\u{7}",
"-en",
"en-",
"en--US",
"en-US-abcdefghi",
"1n",
"\u{65e5}\u{672c}",
"en-US-aaaaaaaa-bbbbbbbb-cccccccc-dddddddd-eeeeeeee",
"en-aaaaaaaa-aaaaaaaa-aaaaaaaa-bbbbbb",
] {
let e = make(vec![lf(bad, one("Form"))]);
assert_eq!(
refusal(w.entities.put(e.clone())),
bounds(&e, EntityBound::LanguageTag),
"{bad:?}"
);
}
for (i, ok) in [
"en",
"en-US",
"zh-Hant-TW",
"x-klingon",
"de-CH-1996",
"en-aaaaaaaa-aaaaaaaa-aaaaaaaa-bbbbb",
]
.into_iter()
.enumerate()
{
w.entities
.put(Entity {
id: eid(&format!("tag-{i}")),
..make(vec![lf(ok, one("Form"))])
})
.unwrap();
}
assert!(w.entities.get(&eid("big")).unwrap().is_none());
}
#[test]
fn item_language_is_a_tag() {
let mut w = world();
let g = group_in_bucket(10);
for (n, bad) in ["", "en_US", "x-0\n\u{7}"].into_iter().enumerate() {
assert!(matches!(
refusal(
w.corpus
.add(item(n + 1, bad, "Nibu left.", vec![], &g), &w.entities)
),
StoreRefusal::Malformed { .. }
));
}
}
#[test]
fn entities_are_append_only_and_replay_needs_them() {
let mut w = world();
let base = ent("z", "en-US", &["Zed"]);
w.entities.put(base.clone()).unwrap();
let not_append = StoreRefusal::EntityNotAppendOnly { id: eid("z") };
let mut prepend = base.clone();
prepend.forms.insert(
0,
LanguageForms {
language: "fr".into(),
forms: vec!["Z\u{e8}de".into()],
},
);
assert_eq!(refusal(w.entities.put(prepend)), not_append);
let mut persona = base.clone();
persona.origin = EntityOrigin::Cast {
persona: "someone-else".into(),
fixture: "f.yaml".into(),
};
assert_eq!(refusal(w.entities.put(persona)), not_append);
let mut duplicate = base.clone();
duplicate.forms.push(LanguageForms {
language: "en-US".into(),
forms: vec!["Zeddy".into()],
});
assert!(matches!(
refusal(w.entities.put(duplicate)),
StoreRefusal::Malformed { .. }
));
// A corpus replay against an entity store that lacks the speaker is refused.
let empty = EntityStore::open(&w.dir.path().join("empty.cc")).unwrap();
let again = CorpusItem {
id: "item:nibu:2".into(),
..item(1, "en-US", "Nibu says hello.", vec![], &group_in_bucket(10))
};
assert_eq!(
refusal(w.corpus.add(again, &empty)),
StoreRefusal::UnknownEntity {
entity: eid("nibu")
}
);
}
/// One entity holding `form` as its only en-US form.
fn with_form(slug: &str, form: &str) -> Entity {
Entity {
id: eid(slug),
forms: vec![LanguageForms {
language: "en-US".into(),
forms: vec![form.into()],
}],
origin: EntityOrigin::Live {
persona: "p".into(),
},
}
}
#[test]
fn invisible_format_characters_are_refused_in_forms() {
let mut w = world();
let hidden = |slug: &str| StoreRefusal::Malformed {
key: format!("entity:{slug}"),
why: "a form holds an invisible format character",
};
let at = |code: u32| char::from_u32(code).unwrap();
// Every Cf code point except the joiners, plus U+2028 and U+2029, listed
// from Python's unicodedata. The code-point sweep in form.rs pins the edges
// of the rule; this checks the door applies it, in each position.
let listed: Vec<u32> = include_str!("format_refused.txt")
.lines()
.filter(|l| !l.starts_with('#'))
.map(|l| u32::from_str_radix(l, 16).unwrap())
.collect();
assert_eq!(listed.len(), 170);
for (n, code) in listed.iter().enumerate() {
let c = at(*code);
let mut forms = vec![format!("Ka{c}y")];
if !c.is_whitespace() {
forms.extend([format!("{c}Kay"), format!("Kay{c}")]);
}
for form in forms {
assert_eq!(
refusal(w.entities.put(with_form("fmt", &form))),
hidden("fmt"),
"U+{code:X} (#{n}) in {form:?}"
);
}
}
// Default_Ignorable fillers that are not Cf: Hangul and Khmer.
for code in [0x115f, 0x1160, 0x17b4, 0x17b5, 0x3164, 0xffa0] {
assert_eq!(
refusal(
w.entities
.put(with_form("fmt", &format!("Ka{}y", at(code))))
),
hidden("fmt"),
"U+{code:X}"
);
}
assert!(w.entities.get(&eid("fmt")).unwrap().is_none());
// The neighbours of the refused ranges, ZWNJ, ZWJ, CGJ, variation
// selectors and the Mongolian selectors (all Mn) are admitted in a form.
let admitted: [u32; 33] = [
0xac,
0xae,
0x34f,
0x5ff,
0x606,
0x61b,
0x61d,
0x6dc,
0x6de,
0x70e,
0x710,
0x88f,
0x892,
0x8e1,
0x8e3,
0x180b,
0x180c,
0x180d,
0x180f,
0x200a,
0x200c,
0x200d,
0x2010,
0x202f,
0x2065,
0x2070,
0xfe00,
0xfe0f,
0xfeff - 1,
0xfffc,
0x110be,
0x1d172,
0xe0100,
];
for (n, code) in admitted.into_iter().enumerate() {
let form = format!("Ka{}y", at(code));
w.entities
.put(with_form(&format!("ok-{n}"), &form))
.unwrap_or_else(|e| panic!("U+{code:X}: {e}"));
}
// ZWNJ in a Persian form, ZWJ in an emoji sequence, VS16 and a Mongolian
// free variation selector beside a letter.
for (n, form) in [
"\u{645}\u{6cc}\u{200c}\u{62e}\u{648}\u{627}\u{647}\u{645}",
"\u{1f469}\u{200d}\u{1f4bb}",
"\u{2764}\u{fe0f}",
"\u{1820}\u{180b}",
"\u{1f600}\u{e0100}",
]
.into_iter()
.enumerate()
{
w.entities
.put(with_form(&format!("script-{n}"), form))
.unwrap();
}
// A form of only joiners, variation selectors, CGJ or Mongolian selectors
// shows nothing and is refused.
let empty_looking = |slug: &str| StoreRefusal::Malformed {
key: format!("entity:{slug}"),
why: "a form is more than joiners and selectors",
};
for form in [
"\u{200d}",
"\u{200c}",
"\u{200c}\u{200d}",
"\u{fe0f}",
"\u{fe00}\u{fe0f}",
"\u{34f}",
"\u{180b}\u{180c}\u{180d}\u{180f}",
"\u{e0100}",
"\u{e01ef}\u{34f}\u{200d}",
] {
assert_eq!(
refusal(w.entities.put(with_form("blank", form))),
empty_looking("blank"),
"{form:?}"
);
}
assert!(w.entities.get(&eid("blank")).unwrap().is_none());
}
#[test]
fn an_origin_attests_where_the_entity_came_from() {
let mut w = world();
let bad = |slug: &str, why: &'static str| StoreRefusal::Malformed {
key: format!("entity:{slug}"),
why,
};
let with = |slug: &str, origin: EntityOrigin| Entity {
origin,
..with_form(slug, "Kay")
};
let cast = |persona: &str, fixture: &str| EntityOrigin::Cast {
persona: persona.into(),
fixture: fixture.into(),
};
assert_eq!(
refusal(w.entities.put(with("a", cast("", "f.yaml")))),
bad("a", "an origin names its persona")
);
assert_eq!(
refusal(w.entities.put(with("b", cast("kay", "")))),
bad("b", "a cast origin names its fixture")
);
assert_eq!(
refusal(w.entities.put(with(
"c",
EntityOrigin::Live {
persona: String::new()
}
))),
bad("c", "an origin names its persona")
);
assert_eq!(
refusal(w.entities.put(with(
"d",
EntityOrigin::PublicDomain {
work: String::new()
}
))),
bad("d", "a public-domain origin names its work")
);
for slug in ["a", "b", "c", "d"] {
assert!(w.entities.get(&eid(slug)).unwrap().is_none());
}
w.entities.put(with("e", cast("kay", "f.yaml"))).unwrap();
w.entities
.put(with(
"f",
EntityOrigin::PublicDomain {
work: "edda".into(),
},
))
.unwrap();
}
#[test]
fn an_origin_attestation_holds_something_readable() {
let mut w = world();
let with = |slug: &str, origin: EntityOrigin| Entity {
origin,
..with_form(slug, "Kay")
};
type Build = fn(String) -> EntityOrigin;
let origins: [(Build, &str); 4] = [
(
|v| EntityOrigin::Cast {
persona: v,
fixture: "f.yaml".into(),
},
"an origin names its persona",
),
(
|v| EntityOrigin::Cast {
persona: "kay".into(),
fixture: v,
},
"a cast origin names its fixture",
),
(
|v| EntityOrigin::Live { persona: v },
"an origin names its persona",
),
(
|v| EntityOrigin::PublicDomain { work: v },
"a public-domain origin names its work",
),
];
let unreadable = [
" ".to_string(),
"\t".into(),
"\n".into(),
"\u{200b}".into(),
"\u{200d}".into(),
"\0".into(),
"--".into(),
"ok\u{200b}ok".into(),
"ok\u{2028}ok".into(),
"ok\0ok".into(),
"x".repeat(201),
"x".repeat(2 * 1024 * 1024),
];
let mut n = 0;
for (build, why) in &origins {
for bad in &unreadable {
n += 1;
let slug = format!("o-{n}");
assert_eq!(
refusal(w.entities.put(with(&slug, build(bad.clone())))),
StoreRefusal::Malformed {
key: format!("entity:{slug}"),
why
},
"{why}: {:?}",
bad.chars().take(12).collect::<String>()
);
assert!(w.entities.get(&eid(&slug)).unwrap().is_none());
}
}
for (m, (build, _)) in origins.iter().enumerate() {
for (k, good) in ["x".repeat(200), "two words, 2 more".to_string()]
.into_iter()
.enumerate()
{
w.entities
.put(with(&format!("g-{}", 2 * m + k), build(good)))
.unwrap();
}
}
}

View file

@ -0,0 +1,171 @@
# Cf minus ZWNJ/ZWJ, plus U+2028 and U+2029; Python 3.13 unicodedata 15.1.0 over 0..10FFFF
AD
600
601
602
603
604
605
61C
6DD
70F
890
891
8E2
180E
200B
200E
200F
2028
2029
202A
202B
202C
202D
202E
2060
2061
2062
2063
2064
2066
2067
2068
2069
206A
206B
206C
206D
206E
206F
FEFF
FFF9
FFFA
FFFB
110BD
110CD
13430
13431
13432
13433
13434
13435
13436
13437
13438
13439
1343A
1343B
1343C
1343D
1343E
1343F
1BCA0
1BCA1
1BCA2
1BCA3
1D173
1D174
1D175
1D176
1D177
1D178
1D179
1D17A
E0001
E0020
E0021
E0022
E0023
E0024
E0025
E0026
E0027
E0028
E0029
E002A
E002B
E002C
E002D
E002E
E002F
E0030
E0031
E0032
E0033
E0034
E0035
E0036
E0037
E0038
E0039
E003A
E003B
E003C
E003D
E003E
E003F
E0040
E0041
E0042
E0043
E0044
E0045
E0046
E0047
E0048
E0049
E004A
E004B
E004C
E004D
E004E
E004F
E0050
E0051
E0052
E0053
E0054
E0055
E0056
E0057
E0058
E0059
E005A
E005B
E005C
E005D
E005E
E005F
E0060
E0061
E0062
E0063
E0064
E0065
E0066
E0067
E0068
E0069
E006A
E006B
E006C
E006D
E006E
E006F
E0070
E0071
E0072
E0073
E0074
E0075
E0076
E0077
E0078
E0079
E007A
E007B
E007C
E007D
E007E
E007F

152
crates/weksa/tests/forms.rs Normal file
View file

@ -0,0 +1,152 @@
mod common;
use common::*;
use weksa::packet::{EncoderId, Grade, NodeId, PacketId, Referent};
use weksa::{Family, InvId, OptionLabel, OptionSet};
/// Soul's probe: a whole corpus line, and the snake_case form that would
/// pass for an identifier.
const LINE: &str = "Take your hand off that control unless you want your nervous system to become my least useful circuit diagram.";
const SNAKE: &str = "take_your_hand_off_that_control_unless_you_want_your_nervous_system_to_become_my_least_useful_circuit_diagram";
#[test]
fn forms_refuse_surface_text() {
for text in [LINE, SNAKE, "", " "] {
assert!(NodeId::parse(text).is_err(), "NodeId {text:?}");
assert!(PacketId::parse(text).is_err(), "PacketId {text:?}");
assert!(EncoderId::parse(text).is_err(), "EncoderId {text:?}");
assert!(InvId::parse(text).is_err(), "InvId {text:?}");
assert!(OptionLabel::parse(text).is_err(), "OptionLabel {text:?}");
}
// Soul's extension key `nibu.<snake line>` is not an inventory id either.
assert!(InvId::parse(&format!("extension:{SNAKE}")).is_err());
assert!(InvId::parse(&format!("nibu.{SNAKE}")).is_err());
// NodeId is n<k>, k canonical 1..=9999.
for ok in ["n1", "n10", "n9999"] {
assert_eq!(NodeId::parse(ok).unwrap().as_str(), ok);
}
for bad in [
"n0", "n01", "n+1", "n-1", "n10000", "n", "1", "N1", "n1 ", "r1", "n1x",
] {
assert!(NodeId::parse(bad).is_err(), "{bad:?}");
}
// PacketId and EncoderId are a prefix and exactly 16 lowercase hex digits.
assert!(PacketId::parse(PACKET_ID).is_ok());
assert!(EncoderId::parse(ENCODER_ID).is_ok());
for bad in [
"packet:0123456789abcde",
"packet:0123456789abcdef0",
"packet:0123456789ABCDEF",
"packet:0123456789abcdeg",
"encoder:0123456789abcdef",
"0123456789abcdef",
"packet:item:nibu:1:r2:abc",
] {
assert!(PacketId::parse(bad).is_err(), "{bad:?}");
}
assert!(EncoderId::parse("packet:0123456789abcdef").is_err());
assert!(EncoderId::parse("encoder:0123456789abcdeg").is_err());
assert!(EncoderId::parse("encoder:0123456789ABCDEF").is_err());
assert!(EncoderId::parse("encoder:0123456789abcde").is_err());
assert!(EncoderId::parse("encoder:0123456789abcdef0").is_err());
}
#[test]
fn a_refusal_names_the_form_and_the_value_and_a_form_reads_back_as_written() {
let e = NodeId::parse("n0").unwrap_err();
assert_eq!(e.form, "NodeId");
assert_eq!(e.got, "n0");
assert_eq!(e.to_string(), "\"n0\" is not a NodeId");
assert_eq!(
Grade::new(1001).unwrap_err().to_string(),
"\"1001\" is not a Grade"
);
assert_eq!(EncoderId::parse(ENCODER_ID).unwrap().as_str(), ENCODER_ID);
assert_eq!(PacketId::parse(PACKET_ID).unwrap().as_str(), PACKET_ID);
assert_eq!(
EncoderId::parse(ENCODER_ID).unwrap().to_string(),
ENCODER_ID
);
assert_eq!(lab("potential").as_str(), "potential");
}
#[test]
fn a_malformed_value_does_not_deserialize() {
// A node id that is the snake line fails where a store would read it.
let mut referent = serde_json::to_value(good().referents[0].clone()).unwrap();
assert!(serde_json::from_value::<Referent>(referent.clone()).is_ok());
referent["id"] = SNAKE.into();
assert!(serde_json::from_value::<Referent>(referent.clone()).is_err());
referent["id"] = "n1".into();
referent["concept"] = "concept:01".into();
assert!(serde_json::from_value::<Referent>(referent).is_err());
assert!(serde_json::from_str::<Grade>("1001").is_err());
assert_eq!(serde_json::from_str::<Grade>("1000").unwrap().get(), 1000);
assert!(serde_json::from_str::<OptionSet>("[]").is_err());
}
#[test]
fn inv_id_canonical() {
for bad in [
"concept:01",
"concept:+1",
"concept:0",
"concept:-1",
"concept:",
"concept:1x",
"concept: 1",
"nofamily:1",
"Concept:1",
"concept",
"concept:99999999999",
"concept:1:2",
] {
assert!(InvId::parse(bad).is_err(), "{bad:?}");
}
let id = InvId::parse("concept:1").unwrap();
assert_eq!(id.family(), Family::Concept);
assert_eq!(id.to_string(), "concept:1");
for f in Family::ALL {
let id = InvId::parse(&format!("{}:12", f.prefix())).unwrap();
assert_eq!(id.family(), f);
assert_eq!(id.to_string(), format!("{}:12", f.prefix()));
}
assert_eq!(iv("extension:1").family(), Family::Extension);
}
#[test]
fn options_bounded() {
let labels = |n: usize| (0..n).map(|i| lab(&format!("l{i}"))).collect::<Vec<_>>();
assert!(OptionSet::new(labels(0)).is_err());
assert!(OptionSet::new(labels(1)).is_ok());
assert!(OptionSet::new(labels(16)).is_ok());
assert!(OptionSet::new(labels(17)).is_err());
assert!(OptionSet::new(vec![lab("a"), lab("a")]).is_err());
assert!(OptionSet::new(vec![lab("a"), lab("b"), lab("a")]).is_err());
// Soul's probe: the snake line is not a label; labels are short snake words.
assert!(OptionLabel::parse(SNAKE).is_err());
assert!(OptionLabel::parse(&"a".repeat(24)).is_ok());
assert!(OptionLabel::parse(&"a".repeat(25)).is_err());
for bad in ["A", "1a", "_a", "a-b", "a b", "a.b", "\u{e9}"] {
assert!(OptionLabel::parse(bad).is_err(), "{bad:?}");
}
for ok in ["a", "a1", "a_b", "potential"] {
assert!(OptionLabel::parse(ok).is_ok(), "{ok:?}");
}
}
#[test]
fn grade_bound_is_the_type() {
assert!(Grade::new(1001).is_err());
assert_eq!(Grade::new(1000).unwrap().get(), 1000);
assert_eq!(Grade::new(0).unwrap().get(), 0);
// The published schema carries the same bound.
let (_, json) = weksa::schema::published()
.into_iter()
.find(|(n, _)| n == "weksa.interlingua_packet.schema.json")
.unwrap();
let schema: serde_json::Value = serde_json::from_str(&json).unwrap();
let grade = &schema["$defs"]["Grade"];
assert_eq!(grade["maximum"], 1000, "{grade}");
assert_eq!(grade["minimum"], 0, "{grade}");
assert_eq!(schema["$defs"]["NodeId"]["pattern"], "^n[1-9][0-9]{0,3}$");
}

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

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

692
crates/weksa/tests/score.rs Normal file
View file

@ -0,0 +1,692 @@
mod common;
use std::cell::RefCell;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use common::{cast, group_in_bucket, item_in, model};
use serde_json::Value;
use weksa::CorpusItem;
use weksa::score::{
Embedder, ItemScore, OllamaEmbedder, ScoreInput, Scorer, aggregate, chrf, cosine,
leak_diagnostic,
};
/// (reference, hypothesis, sacrebleu 2.4.3 sentence_chrf), map 491536c.
const VECTORS: [(&str, &str, f64); 6] = [
(
"Take your hand off that control unless you want your nervous system to become my least useful circuit diagram.",
"Take your hand off that control, unless you'd like your nervous system to be my least useful wiring diagram.",
74.4498,
),
(
"The command has no receipt. It is not truth.",
"The order carries no receipt, so it is not true.",
45.7164,
),
("I am not angry.", "I am not angry.", 100.0),
("Forgive me, sir.", "Get out.", 3.9683),
("その制御に触るな。", "その操作盤に触らないで。", 14.2931),
("Well, aren't you a sight for sore eyes, love.", "", 0.0),
];
#[test]
fn chrf_matches_sacrebleu_vectors() {
for (reference, hypothesis, want) in VECTORS {
let got = chrf(hypothesis, reference);
assert!(
(got - want).abs() < 1e-4,
"{hypothesis:?} against {reference:?}: {got} != {want}"
);
}
}
#[test]
fn chrf_argument_order() {
let short = "mind the hatch";
let long = "mind the hatch before the storm arrives tonight";
let (as_hypothesis, as_reference) = (chrf(short, long), chrf(long, short));
assert!(
as_hypothesis < as_reference - 1.0,
"recall is weighted: a short hypothesis of a long reference ({as_hypothesis}) must score below the reverse ({as_reference})"
);
}
#[derive(Default)]
struct Recorder {
seen: RefCell<Vec<String>>,
}
impl Embedder for Recorder {
fn model(&self) -> &str {
"fake"
}
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
self.seen
.borrow_mut()
.extend(texts.iter().map(|t| t.to_string()));
Ok(texts.iter().map(|t| vec![t.len() as f32, 1.0]).collect())
}
}
/// A deterministic bag-of-letters embedder, so cosine differs with the text.
struct Bag;
impl Embedder for Bag {
fn model(&self) -> &str {
"bag"
}
fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
Ok(texts
.iter()
.map(|t| {
let mut v = vec![0.0; 27];
v[26] = 1.0;
for c in t.chars().filter(char::is_ascii_alphabetic) {
v[(c.to_ascii_lowercase() as u8 - b'a') as usize] += 1.0;
}
v
})
.collect())
}
}
fn score_one<E: Embedder>(scorer: &Scorer<E>, reference: &str, output: &str) -> ItemScore {
scorer
.score(&[ScoreInput { reference, output }])
.unwrap()
.remove(0)
}
fn close(got: f64, want: f64) {
assert!((got - want).abs() < 1e-4, "{got} != {want}");
}
#[test]
fn chrf_whitespace_parity() {
// sacrebleu 2.4.3 strips str.split's whitespace; every vector scores 100
let vectors = [
("a\tb c", "ab c"),
("a\u{a0}b", "ab"),
("a\u{1c}b", "ab"),
("a\u{85}b", "ab"),
("a\u{2028}b c", "ab c"),
("line one\nline two", "line one line two"),
("その\u{3000}制御", "その制御"),
];
for (hypothesis, reference) in vectors {
assert_eq!(chrf(hypothesis, reference), 100.0, "{hypothesis:?}");
assert_eq!(chrf(reference, hypothesis), 100.0, "{hypothesis:?}");
}
// a zero-width space is not whitespace
close(chrf("a\u{200b}b", "ab"), 45.4545);
}
#[test]
fn scorer_argument_order() {
let short = "mind the hatch";
let long = "mind the hatch before the storm arrives tonight";
let scorer = Scorer::new(Bag);
let got = score_one(&scorer, long, short);
assert_eq!(got.chrf, chrf(short, long));
assert_ne!(got.chrf, chrf(long, short));
}
#[test]
fn meaning_none_on_empty_text() {
let scorer = Scorer::new(Recorder::default());
let inputs = [
ScoreInput {
reference: "Nibu says hi",
output: " ",
},
ScoreInput {
reference: " ",
output: "says hi",
},
ScoreInput {
reference: "Nibu says hi",
output: "says hi",
},
];
let scores = scorer.score(&inputs).unwrap();
assert_eq!(scores[0].meaning, None);
assert_eq!(scores[1].meaning, None);
assert!(scores[2].meaning.is_some());
assert_eq!(
*scorer.embedder().seen.borrow(),
["Nibu says hi", "says hi"]
);
assert_eq!(scores[0].chrf, 0.0);
}
/// Answers every embed call with the same vectors.
struct Fixed(Vec<Vec<f32>>);
impl Embedder for Fixed {
fn model(&self) -> &str {
"fixed"
}
fn embed(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>> {
Ok(self.0.clone())
}
}
#[test]
fn scorer_refuses_bad_vectors() {
let run = |vectors: Vec<Vec<f32>>| {
Scorer::new(Fixed(vectors)).score(&[ScoreInput {
reference: "mind the hatch",
output: "mind the door",
}])
};
assert!(run(vec![vec![1.0, 2.0], vec![2.0, 1.0]]).is_ok());
for bad in [
vec![vec![1.0, 2.0], vec![1.0]],
vec![vec![1.0], vec![1.0, 2.0]],
vec![vec![], vec![]],
vec![vec![0.0, 0.0], vec![0.0, 0.0]],
vec![vec![1.0, 2.0], vec![0.0, 0.0]],
vec![vec![0.0, 0.0], vec![1.0, 2.0]],
vec![vec![1.0, f32::NAN], vec![1.0, 2.0]],
vec![vec![1.0, 2.0]],
] {
assert!(run(bad.clone()).is_err(), "{bad:?}");
}
}
#[test]
fn cosine_precondition() {
assert!((cosine(&[1.0, 2.0], &[2.0, 4.0]) - 1.0).abs() < 1e-9);
assert!(cosine(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-9);
for (a, b) in [
(vec![1.0], vec![1.0, 1.0]),
(vec![], vec![]),
(vec![0.0, 0.0], vec![1.0, 1.0]),
] {
let refused = std::panic::catch_unwind(|| cosine(&a, &b));
assert!(refused.is_err(), "{a:?} {b:?}");
}
}
/// A plain-HTTP fake of `/api/embed`. Records each request body; answers with
/// `dim` floats per input, one fewer vector than asked when `drop_one`, and a
/// short last vector when `short_last`.
fn fake_ollama(drop_one: bool, short_last: bool) -> (String, Arc<Mutex<Vec<Value>>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let bodies = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&bodies);
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
'conn: loop {
let mut length = 0;
let mut line = String::new();
let mut first = true;
loop {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break 'conn;
}
if first {
assert!(line.starts_with("POST /api/embed "), "{line}");
first = false;
}
let lower = line.to_ascii_lowercase();
if let Some(v) = lower.strip_prefix("content-length:") {
length = v.trim().parse().unwrap();
}
if line == "\r\n" {
break;
}
}
let mut body = vec![0u8; length];
reader.read_exact(&mut body).unwrap();
let body: Value = serde_json::from_slice(&body).unwrap();
let mut count = body["input"].as_array().unwrap().len();
seen.lock().unwrap().push(body);
if drop_one {
count -= 1;
}
let vectors: Vec<Vec<f32>> = (0..count)
.map(|i| vec![1.0; if short_last && i + 1 == count { 2 } else { 3 }])
.collect();
let reply = serde_json::json!({ "embeddings": vectors }).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{reply}",
reply.len()
)
.unwrap();
}
}
});
(url, bodies)
}
fn lengths(bodies: &Mutex<Vec<Value>>) -> Vec<usize> {
let bodies = bodies.lock().unwrap();
bodies
.iter()
.map(|b| b["input"].as_array().unwrap().len())
.collect()
}
#[test]
fn ollama_request_shape_and_batching() {
let (url, bodies) = fake_ollama(false, false);
let embedder = OllamaEmbedder::new(&url, "qwen3-embedding:0.6b");
let texts: Vec<String> = (0..70).map(|i| format!("text {i}")).collect();
let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
let vectors = embedder.embed(&refs).unwrap();
assert_eq!(vectors.len(), 70);
assert_eq!(lengths(&bodies), [32, 32, 6]);
for body in bodies.lock().unwrap().iter() {
assert_eq!(body["model"], "qwen3-embedding:0.6b");
assert_eq!(body["options"]["num_thread"], 8);
}
assert_eq!(bodies.lock().unwrap()[2]["input"][5], "text 69");
let (url, _) = fake_ollama(true, false);
let err = OllamaEmbedder::new(&url, "m")
.embed(&["a", "b"])
.unwrap_err();
assert!(err.to_string().contains("got 1"), "{err}");
let (url, _) = fake_ollama(false, true);
let err = OllamaEmbedder::new(&url, "m")
.embed(&["a", "b"])
.unwrap_err();
assert!(err.to_string().contains("dimension"), "{err}");
}
#[test]
fn ollama_clamps_batch_and_omits_unset_num_thread() {
let (url, bodies) = fake_ollama(false, false);
let mut embedder = OllamaEmbedder::new(&url, "m");
embedder.batch = 500;
embedder.num_thread = None;
let texts: Vec<String> = (0..40).map(|i| i.to_string()).collect();
let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
embedder.embed(&refs).unwrap();
assert_eq!(lengths(&bodies), [32, 8]);
assert!(bodies.lock().unwrap()[0].get("options").is_none());
assert!(embedder.embed(&[]).unwrap().is_empty());
assert_eq!(lengths(&bodies).len(), 2, "no request for no texts");
}
#[test]
#[ignore = "needs WEKSA_OLLAMA_URL, Yggdrasil's ollama-cpu"]
fn ollama_on_yggdrasil() {
let url = std::env::var("WEKSA_OLLAMA_URL").unwrap();
let embedder = OllamaEmbedder::new(url, "qwen3-embedding:0.6b");
let text = "Take your hand off that control.";
let vectors = embedder.embed(&[text, text]).unwrap();
assert_eq!(vectors.len(), 2);
assert!(vectors.iter().all(|v| v.len() == 1024));
assert!((cosine(&vectors[0], &vectors[1]) - 1.0).abs() < 1e-4);
}
fn score(chrf: f64, meaning: Option<f64>) -> ItemScore {
ItemScore { chrf, meaning }
}
#[test]
fn aggregate_by_derived_split() {
let train = item_in("item:a:1", "t", &group_in_bucket(10), cast());
let train2 = item_in("item:a:2", "t", &group_in_bucket(10), cast());
let validation = item_in("item:b:1", "v", &group_in_bucket(50), cast());
let test = item_in("item:c:1", "x", &group_in_bucket(90), cast());
let test_model = item_in("item:c:2", "x", &group_in_bucket(90), model());
let agg = aggregate(&[
(&train, score(10.0, Some(0.5))),
(&train2, score(20.0, None)),
(&validation, score(40.0, Some(0.8))),
(&test, score(60.0, Some(0.6))),
(&test_model, score(100.0, Some(1.0))),
]);
assert_eq!(agg.train.count, 2);
assert_eq!(agg.train.chrf, Some(15.0));
assert_eq!(agg.train.meaning, Some(0.5), "None is not averaged as zero");
assert_eq!((agg.validation.count, agg.validation.chrf), (1, Some(40.0)));
assert_eq!((agg.test.count, agg.test.chrf), (1, Some(60.0)));
assert_eq!(
agg.test.meaning,
Some(0.6),
"the model-written test item is in no split"
);
assert_eq!(aggregate(&[]).validation.chrf, None);
}
const HELD_OUT: &str = "extraordinary lighthouse keepers whispered unforgettable stories throughout midnight thunderstorms before surrendering peacefully";
const UNRELATED: &str = "harbor ledgers record ordinary tides";
fn chunks(size: usize, joiner: &str) -> Vec<String> {
HELD_OUT
.split(' ')
.collect::<Vec<_>>()
.chunks(size)
.map(|c| c.join(joiner))
.collect()
}
fn validation(key: &str, text: &str) -> CorpusItem {
item_in(key, text, &group_in_bucket(50), cast())
}
fn train(key: &str, text: &str) -> CorpusItem {
item_in(key, text, &group_in_bucket(10), cast())
}
/// (novel, covered) per order for the Validation split.
fn read(minted: &[&str], items: &[CorpusItem]) -> Vec<(usize, usize)> {
leak_diagnostic(minted, items)
.validation
.coverage_by_n
.iter()
.map(|c| (c.novel, c.covered))
.collect()
}
fn refs(strings: &[String]) -> Vec<&str> {
strings.iter().map(String::as_str).collect()
}
#[test]
fn leak_diagnostic_catches_chunked_glosses() {
let minted = chunks(3, " ");
let minted = refs(&minted);
let held = validation("item:v:1", HELD_OUT);
let unrelated = validation("item:v:2", "gardening is quiet work");
let train_item = train("item:t:1", UNRELATED);
let test_model = item_in("item:x:1", HELD_OUT, &group_in_bucket(90), model());
let d = leak_diagnostic(
&minted,
&[
held.clone(),
unrelated.clone(),
train_item.clone(),
test_model,
],
);
let pair = d.validation.max_chrf.as_ref().unwrap();
assert!(
pair.chrf < 50.0,
"each chunk alone scores low: {}",
pair.chrf
);
assert_eq!(pair.item, "item:v:1");
// pooled over both items: 12+4 words, 11+3 bigrams, 10+2 trigrams
assert_eq!(
read(&minted, &[held.clone(), unrelated, train_item.clone()]),
[(16, 12), (14, 8), (12, 4)]
);
let worst = d.validation.worst.as_ref().unwrap();
assert_eq!(
(worst.item.as_str(), worst.n, worst.covered, worst.novel),
("item:v:1", 2, 8, 11)
);
assert_eq!(d.validation.items, 2);
assert_eq!(d.test.items, 0, "a model-written test item is in no split");
assert_eq!(d.test.max_chrf, None);
assert_eq!(d.test.worst, None);
let only_train = leak_diagnostic(&minted, &[train_item]);
assert_eq!(only_train.validation.items, 0);
assert_eq!(only_train.validation.max_chrf, None);
assert_eq!(only_train.validation.worst, None);
let full = leak_diagnostic(&[HELD_OUT], &[held]);
assert_eq!(full.validation.coverage_by_n[2].share, Some(1.0));
assert!(full.validation.max_chrf.unwrap().chrf > 99.0);
}
#[test]
fn coverage_reads_joined_labels() {
let items = [
validation("item:v:1", HELD_OUT),
train("item:t:1", UNRELATED),
];
for size in [1, 2, 3] {
let plain = chunks(size, " ");
let want = read(&refs(&plain), &items);
for joiner in ["_", "."] {
let joined = chunks(size, joiner);
assert_eq!(read(&refs(&joined), &items), want, "{size} {joiner:?}");
}
// camelCase: each word after the first capitalised within a chunk
let camel: Vec<String> = chunks(size, " ")
.iter()
.map(|c| {
c.split(' ')
.enumerate()
.map(|(i, w)| match i {
0 => w.to_string(),
_ => w[..1].to_uppercase() + &w[1..],
})
.collect::<String>()
})
.collect();
assert_eq!(read(&refs(&camel), &items), want, "{size} camel");
}
let items = [
validation("item:v:1", "He will kick the bucket"),
train("item:t:1", "he will go"),
];
for label in [
"kick_the_bucket",
"kick.the.bucket",
"kickTheBucket",
"kick the bucket",
] {
let d = leak_diagnostic(&[label], &items);
let by_n = &d.validation.coverage_by_n;
assert_eq!((by_n[2].novel, by_n[2].covered), (3, 1), "{label}");
let worst = d.validation.worst.unwrap();
assert_eq!((worst.n, worst.covered), (2, 2), "{label}");
}
}
#[test]
fn coverage_is_case_insensitive() {
let items = [
validation("item:v:1", HELD_OUT),
train("item:t:1", UNRELATED),
];
let minted = chunks(2, " ");
let shouted: Vec<String> = minted.iter().map(|m| m.to_uppercase()).collect();
assert_eq!(read(&refs(&shouted), &items), read(&refs(&minted), &items));
assert_eq!(read(&refs(&minted), &items)[0], (12, 12));
}
#[test]
fn coverage_pooled_per_order() {
// honest single-word glosses saturate n=1 and read nothing above it
let items = [
train(
"item:t:1",
"They left before the storm and found the harbor.",
),
validation("item:v:1", "They left the group before the storm."),
validation("item:v:2", "They found the place after the storm."),
validation("item:v:3", "Warn them before they leave the harbor."),
];
let glosses = [
"left", "group", "found", "place", "storm", "after", "warn", "leave", "harbor",
];
let d = leak_diagnostic(&glosses, &items);
let by_n = &d.validation.coverage_by_n;
assert_eq!(by_n.iter().map(|c| c.n).collect::<Vec<_>>(), [1, 2, 3]);
assert_eq!((by_n[0].novel, by_n[0].covered), (6, 5));
assert!(by_n[0].share.unwrap() < 1.0);
for c in &by_n[1..] {
assert!(c.novel > 0);
assert_eq!((c.covered, c.share), (0, Some(0.0)));
}
assert_eq!(d.validation.worst, None);
// no novel n-gram reads None, never NaN; an empty split reads the same
let items = [
train("item:t:1", "tides harbors guard the quiet shore"),
validation("item:v:1", "harbors guard the quiet"),
];
let d = leak_diagnostic(&["anything at all"], &items);
for split in [&d.validation, &d.test] {
assert_eq!(split.coverage_by_n.len(), 3);
assert!(
split
.coverage_by_n
.iter()
.all(|c| (c.novel, c.share) == (0, None))
);
assert_eq!(split.worst, None);
}
}
#[test]
fn coverage_keeps_digits_and_strings() {
let items = [
validation("item:v:1", "code 0400 open"),
train("item:t:1", "code open"),
];
assert_eq!(read(&["0400"], &items)[0], (1, 1));
// a bigram spanning two minted strings is not covered
let items = [validation("item:v:1", "zeta eta")];
assert_eq!(read(&["zeta", "eta"], &items), [(2, 2), (1, 0), (0, 0)]);
assert_eq!(read(&["zeta eta"], &items), [(2, 2), (1, 1), (0, 0)]);
}
#[test]
fn worst_item_rules() {
// equal covered counts: the larger share wins over an earlier, smaller one
let items = [
validation("item:v:1", "alpha beta gamma"),
validation("item:v:2", "delta epsilon"),
];
let w = leak_diagnostic(&["alpha beta", "delta epsilon"], &items)
.validation
.worst
.unwrap();
assert_eq!(
(w.item.as_str(), w.n, w.covered, w.novel),
("item:v:2", 2, 1, 1)
);
// a later, smaller share does not displace an earlier larger one
let items = [
validation("item:v:1", "delta epsilon"),
validation("item:v:2", "alpha beta gamma"),
];
let w = leak_diagnostic(&["alpha beta", "delta epsilon"], &items)
.validation
.worst
.unwrap();
assert_eq!(w.item, "item:v:1");
// a larger covered count beats a larger share
let items = [
validation("item:v:1", "delta epsilon"),
validation("item:v:2", "alpha beta gamma delta"),
];
let w = leak_diagnostic(&["alpha beta gamma", "delta epsilon"], &items)
.validation
.worst
.unwrap();
assert_eq!((w.item.as_str(), w.covered, w.novel), ("item:v:2", 2, 3));
// full ties go to the first item, then the smaller n
let items = [
validation("item:v:1", "delta epsilon"),
validation("item:v:2", "delta epsilon"),
];
let w = leak_diagnostic(&["delta epsilon"], &items)
.validation
.worst
.unwrap();
assert_eq!(w.item, "item:v:1");
let items = [
validation("item:v:1", "alpha beta gamma"),
train("item:t:1", "alpha beta"),
];
let w = leak_diagnostic(&["alpha beta gamma"], &items)
.validation
.worst
.unwrap();
assert_eq!((w.n, w.covered, w.novel), (2, 1, 1));
}
#[test]
fn ollama_reports_its_model() {
assert_eq!(
OllamaEmbedder::new("http://x", "qwen3-embedding:0.6b").model(),
"qwen3-embedding:0.6b"
);
}
#[test]
fn leak_diagnostic_reports_the_best_pair_and_the_first_of_ties() {
let a = validation("item:v:1", HELD_OUT);
let b = validation("item:v:2", HELD_OUT);
// the better minted string comes second; equal items tie, the first is kept
let d = leak_diagnostic(&["zzzzzz qqqqqq", HELD_OUT], &[a, b]);
let pair = d.validation.max_chrf.unwrap();
assert_eq!(
(pair.minted.as_str(), pair.item.as_str()),
(HELD_OUT, "item:v:1")
);
}
#[test]
fn scores_whole_texts() {
let reference = "He is going to kick the bucket tomorrow.";
let output = "He is going to die tomorrow.";
// exhaustive: a scorer input that grows a field breaks this line
let ScoreInput { reference, output } = ScoreInput { reference, output };
let scorer = Scorer::new(Recorder::default());
let got = score_one(&scorer, reference, output);
assert_eq!(*scorer.embedder().seen.borrow(), [reference, output]);
assert_eq!(got.chrf, chrf(output, reference));
}
#[test]
fn filler_costs() {
// naming filler cannot delete it: the scorer has no input but the two texts
let reference = "I am not angry.";
let filler = "Darling, I am truly not angry, darling.";
let scorer = Scorer::new(Bag);
close(score_one(&scorer, reference, filler).chrf, 46.8378);
let copy_reference = "He is going to kick the bucket tomorrow.";
let padded = "Well, he is going to kick the bucket tomorrow, honestly.";
let unnamed = score_one(&scorer, copy_reference, padded);
close(unnamed.chrf, chrf(padded, copy_reference));
assert!(unnamed.chrf < 100.0);
let honest = score_one(&scorer, "Never again.", "Never again.");
assert_eq!(honest.chrf, 100.0);
}
#[test]
fn honest_name_earns_credit() {
let scorer = Scorer::new(Bag);
let reference = "Brokkr came back.";
assert_eq!(score_one(&scorer, reference, reference).chrf, 100.0);
let wrong = score_one(&scorer, reference, "Kestrel came back.");
assert!(wrong.chrf < 100.0);
assert!(wrong.meaning.unwrap() < 1.0);
}
#[test]
fn every_script_alike() {
let scorer = Scorer::new(Bag);
for text in [
"민수가 왔다",
"Brokkr'ın gemisi geldi",
"Brokkrin laiva tuli",
"สมชายมาแล้ว",
"ブロッカーが来た。",
] {
let got = score_one(&scorer, text, text);
assert_eq!(got.chrf, 100.0, "{text}");
}
}

View file

@ -0,0 +1,38 @@
//! The committed seed stores replay through today's doors, so a rule that
//! tightens cannot leave a seed the stores would now refuse.
mod common;
use std::path::Path;
use weksa::{CorpusStore, EntityStore};
#[test]
fn committed_seeds_replay_through_the_doors() {
let data = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
let seeded_entities = EntityStore::open(&data.join("entity/entity.cc")).unwrap();
let seeded_corpus = CorpusStore::open(&data.join("corpus/corpus.cc")).unwrap();
let dir = tempfile::tempdir().unwrap();
let mut entities = EntityStore::open(&dir.path().join("e.cc")).unwrap();
let all = seeded_entities.all().unwrap();
assert!(!all.is_empty());
for e in all {
entities.put(e).unwrap();
}
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
let items = seeded_corpus.all().unwrap();
assert!(!items.is_empty());
for item in items {
let withdrawn = item.withdrawn;
let id = item.id.clone();
corpus
.add(
weksa::CorpusItem {
withdrawn: false,
..item
},
&entities,
)
.unwrap();
if withdrawn {
corpus.withdraw(&id).unwrap();
}
}
}

View file

@ -0,0 +1,153 @@
use weksa::corpus::{CorpusItem, ModelFamily, Source};
use weksa::split::fnv1a64;
use weksa::{Split, SplitScheme};
fn item(group: &str, source: Source) -> CorpusItem {
CorpusItem {
id: "item:t:1".into(),
text: "t".into(),
language: "en-US".into(),
speaker: weksa::EntityId::parse("entity:s").unwrap(),
names: vec![],
group: group.into(),
prior_turns: vec![],
source,
admitted_on: "2026-10-29".into(),
supersedes: None,
withdrawn: false,
}
}
fn cast() -> Source {
Source::CastAuthored {
persona: "p".into(),
fixture: "f".into(),
author: "a".into(),
}
}
fn model() -> Source {
Source::ModelWritten {
family: ModelFamily::Unrecorded,
generator: None,
prompt_digest: None,
}
}
fn group_in_bucket(want: u64) -> String {
(0..100_000)
.map(|i| format!("g{i}"))
.find(|g| SplitScheme::V1.bucket(g) == want)
.unwrap_or_else(|| panic!("no group lands in bucket {want}"))
}
#[test]
fn fnv1a64_matches_the_reference_vectors() {
assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c);
assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8);
}
#[test]
fn p1_split_deterministic() {
let a = SplitScheme::V1.split(&item("scene-9", cast()));
for _ in 0..3 {
assert_eq!(SplitScheme::V1.split(&item("scene-9", cast())), a);
}
// The split reads the group and nothing else: speaker, text and id do not move it.
let mut other = item("scene-9", cast());
other.speaker = weksa::EntityId::parse("entity:someone").unwrap();
other.text = "different".into();
other.id = "item:other:7".into();
assert_eq!(SplitScheme::V1.split(&other), a);
// The bucket is the pinned hash of the prefixed group.
assert_eq!(
SplitScheme::V1.bucket("x"),
fnv1a64(b"weksa-split-v1:x") % 100
);
}
#[test]
fn split_boundaries() {
let s = SplitScheme::V1;
assert_eq!(s.of_bucket(0), Split::Train);
assert_eq!(s.of_bucket(39), Split::Train);
assert_eq!(s.of_bucket(40), Split::Validation);
assert_eq!(s.of_bucket(79), Split::Validation);
assert_eq!(s.of_bucket(80), Split::Test);
assert_eq!(s.of_bucket(99), Split::Test);
for (bucket, want) in [
(39, Split::Train),
(40, Split::Validation),
(79, Split::Validation),
(80, Split::Test),
] {
assert_eq!(
s.split(&item(&group_in_bucket(bucket), cast())),
Some(want),
"bucket {bucket}"
);
}
}
#[test]
fn p2_model_written_in_a_test_group_is_excluded() {
let test_group = group_in_bucket(95);
assert_eq!(
SplitScheme::V1.split(&item(&test_group, cast())),
Some(Split::Test)
);
// A model line of the test meaning is in no split; it is never moved to train.
assert_eq!(SplitScheme::V1.split(&item(&test_group, model())), None);
// Model-written items in the other splits keep them.
assert_eq!(
SplitScheme::V1.split(&item(&group_in_bucket(10), model())),
Some(Split::Train)
);
assert_eq!(
SplitScheme::V1.split(&item(&group_in_bucket(60), model())),
Some(Split::Validation)
);
}
#[test]
fn p3_split_ratio() {
let mut counts = [0usize; 3];
for i in 0..10_000 {
match SplitScheme::V1.split(&item(&format!("scene-{i}"), cast())) {
Some(Split::Train) => counts[0] += 1,
Some(Split::Validation) => counts[1] += 1,
Some(Split::Test) => counts[2] += 1,
None => panic!("a cast item is always in a split"),
}
}
let near = |got: usize, want_percent: usize| got.abs_diff(want_percent * 100) <= 200;
assert!(
near(counts[0], 40) && near(counts[1], 40) && near(counts[2], 20),
"{counts:?}"
);
}
#[test]
fn published_schemas_match() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas/cultnet");
let published = weksa::schema::published();
let mut names: Vec<String> = published.iter().map(|(n, _)| n.clone()).collect();
names.sort();
let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".schema.json"))
.collect();
on_disk.sort();
assert_eq!(
on_disk, names,
"schemas/cultnet holds exactly the generated files"
);
for (name, json) in published {
let held = std::fs::read_to_string(dir.join(&name))
.unwrap()
.replace("\r\n", "\n");
assert_eq!(held, json, "{name} drifted; run `weksa schema`");
}
}

1294
crates/weksa/tests/stores.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,685 @@
mod common;
use common::*;
use weksa::packet::*;
use weksa::{Family, PacketStore, Refusal, Slot, validate};
fn refusals(p: &Packet) -> Vec<Refusal> {
validate(p, &inventory()).err().unwrap_or_default()
}
fn one(p: &Packet) -> Refusal {
let mut r = refusals(p);
assert_eq!(r.len(), 1, "{r:?}");
r.remove(0)
}
fn unresolved(id: &str) -> Refusal {
Refusal::V5Unresolved { id: iv(id) }
}
fn empty_payload() -> Packet {
let mut p = good();
p.referents.clear();
p.predications.clear();
p.relations.clear();
p.discourse.addressee = None;
p.context.participants.clear();
p.context.audience.clear();
p.constraints.preserve.clear();
p
}
fn feat(id: &str, value: FeatValue) -> FeatureValue {
FeatureValue {
feature: iv(id),
value,
}
}
#[test]
fn a_good_packet_validates() {
assert!(validate(&good(), &inventory()).is_ok());
}
#[test]
fn v1_schema_version() {
let mut p = good();
p.schema_version = SCHEMA_VERSION + 1;
assert_eq!(
refusals(&p),
vec![Refusal::V1SchemaVersion {
found: SCHEMA_VERSION + 1
}]
);
}
#[test]
fn v2_payload_required() {
assert_eq!(refusals(&empty_payload()), vec![Refusal::V2PayloadRequired]);
// Any one family is payload enough.
let mut only_relations = empty_payload();
only_relations.relations = good().relations;
only_relations.relations[0].from = nd("n8");
only_relations.relations[0].to = nd("n9");
assert!(!refusals(&only_relations).contains(&Refusal::V2PayloadRequired));
let mut only_predications = empty_payload();
only_predications.predications = good().predications;
only_predications.predications[0].roles.clear();
assert_eq!(refusals(&only_predications), vec![]);
let mut only_referents = empty_payload();
only_referents.referents = good().referents;
assert_eq!(refusals(&only_referents), vec![]);
}
#[test]
fn v3_unique_node_ids() {
let mut p = good();
p.predications[0].id = nd("n1");
p.relations[0].from = nd("n1");
assert_eq!(
refusals(&p),
vec![Refusal::V3DuplicateNode { id: nd("n1") }]
);
let mut twice = good();
twice.referents.push(twice.referents[0].clone());
assert_eq!(
refusals(&twice),
vec![Refusal::V3DuplicateNode { id: nd("n1") }]
);
}
#[test]
fn v4_refs_resolve() {
let dangling = |edit: fn(&mut Packet), at: &'static str| {
let mut p = good();
edit(&mut p);
assert_eq!(
refusals(&p),
vec![Refusal::V4DanglingRef { id: nd("n99"), at }],
"{at}"
);
};
dangling(
|p| p.predications[0].roles[0].filler = nd("n99"),
"role filler",
);
dangling(|p| p.relations[0].from = nd("n99"), "relation from");
dangling(|p| p.relations[0].to = nd("n99"), "relation to");
dangling(|p| p.discourse.addressee = Some(nd("n99")), "addressee");
dangling(|p| p.context.participants.push(nd("n99")), "participant");
dangling(|p| p.context.audience.push(nd("n99")), "audience");
dangling(|p| p.constraints.preserve.push(nd("n99")), "preserve");
}
#[test]
fn v5_inventory_resolves_at_rev() {
let with_concept = |rev: u32, id: &str| {
let mut p = good();
p.inventory_rev = rev;
p.referents[0].concept = iv(id);
p
};
// Introduced after the packet's rev.
assert_eq!(
one(&with_concept(2, CONCEPT_LATE)),
unresolved(CONCEPT_LATE)
);
// Introduced at the packet's rev.
assert_eq!(refusals(&with_concept(4, CONCEPT_LATE)), vec![]);
// Retired later than the packet's rev still resolves.
assert_eq!(refusals(&with_concept(2, CONCEPT_RETIRED)), vec![]);
// Retired at or before the packet's rev does not.
assert_eq!(
one(&with_concept(3, CONCEPT_RETIRED)),
unresolved(CONCEPT_RETIRED)
);
assert_eq!(
one(&with_concept(4, CONCEPT_RETIRED)),
unresolved(CONCEPT_RETIRED)
);
// An id no revision holds.
assert_eq!(
one(&with_concept(2, "concept:99")),
unresolved("concept:99")
);
// An extension is resolved like any inventory id.
let mut ext = good();
ext.extensions[0].key = iv("extension:99");
assert_eq!(one(&ext), unresolved("extension:99"));
}
#[test]
fn v6_family_matches_slot() {
let wrong = |edit: fn(&mut Packet), id: &str, expected: Family, at: &'static str| {
let mut p = good();
edit(&mut p);
assert_eq!(
refusals(&p),
vec![Refusal::V6WrongFamily {
id: iv(id),
expected,
at
}],
"{at}"
);
};
wrong(
|p| p.discourse.features[0].feature = iv(AGENT),
AGENT,
Family::Feature,
"feature",
);
wrong(
|p| p.discourse.speech_act = Some(iv("relation:1")),
"relation:1",
Family::SpeechAct,
"speech act",
);
wrong(
|p| p.referents[0].kind = iv(CONCEPT_A),
CONCEPT_A,
Family::ReferentKind,
"referent kind",
);
wrong(
|p| p.referents[0].concept = iv(PERSON),
PERSON,
Family::Concept,
"referent concept",
);
wrong(
|p| p.predications[0].concept = iv(FRAME),
FRAME,
Family::Concept,
"predication concept",
);
wrong(
|p| p.predications[0].frame = iv(CONCEPT_A),
CONCEPT_A,
Family::Frame,
"frame",
);
wrong(
|p| p.predications[0].roles[0].role = iv(HEDGE),
HEDGE,
Family::Role,
"role",
);
wrong(
|p| p.relations[0].relation = iv(AGENT),
AGENT,
Family::Relation,
"relation",
);
wrong(
|p| p.context.setting = Some(iv(FRAME)),
FRAME,
Family::Concept,
"context concept",
);
wrong(
|p| p.context.medium = Some(iv(FRAME)),
FRAME,
Family::Concept,
"context concept",
);
wrong(
|p| p.extensions[0].key = iv(CONCEPT_A),
CONCEPT_A,
Family::Extension,
"extension",
);
}
#[test]
fn v7_value_kind() {
let hedge = |v: FeatValue| {
let mut p = good();
p.discourse.features[0].value = v;
refusals(&p)
};
let bad = vec![Refusal::V7ValueKind { what: HEDGE.into() }];
assert_eq!(hedge(FeatValue::Grade(grade(1000))), vec![]);
assert_eq!(hedge(FeatValue::Flag(true)), bad);
assert_eq!(hedge(FeatValue::Option(lab("x"))), bad);
let modality = |v: FeatValue| {
let mut p = good();
p.predications[0].features[0].value = v;
refusals(&p)
};
let bad_modality = vec![Refusal::V7ValueKind {
what: MODALITY.into(),
}];
assert_eq!(modality(FeatValue::Option(lab("necessary"))), vec![]);
assert_eq!(modality(FeatValue::Option(lab("banana"))), bad_modality);
assert_eq!(modality(FeatValue::Grade(grade(1))), bad_modality);
let mut flag = good();
flag.discourse.features = vec![feat(IRONY, FeatValue::Flag(false))];
assert_eq!(refusals(&flag), vec![]);
flag.discourse.features[0].value = FeatValue::Grade(grade(5));
assert_eq!(
refusals(&flag),
vec![Refusal::V7ValueKind { what: IRONY.into() }]
);
}
#[test]
fn extension_key_is_an_extension_entry() {
// A concept id is not an extension; an extension entry fits its value kind.
let mut by_concept = good();
by_concept.extensions[0].key = iv(CONCEPT_A);
assert_eq!(
one(&by_concept),
Refusal::V6WrongFamily {
id: iv(CONCEPT_A),
expected: Family::Extension,
at: "extension"
}
);
let mut flagged = good();
flagged.extensions[0].value = FeatValue::Flag(true);
assert_eq!(one(&flagged), Refusal::V7ValueKind { what: EXT.into() });
let mut optioned = good();
optioned.extensions[0].value = FeatValue::Option(lab("x"));
assert_eq!(one(&optioned), Refusal::V7ValueKind { what: EXT.into() });
let mut unknown = good();
unknown.extensions[0].key = iv("extension:2");
assert_eq!(one(&unknown), unresolved("extension:2"));
// No extension means none to check.
let mut none = good();
none.extensions.clear();
assert_eq!(refusals(&none), vec![]);
}
#[test]
fn v8_feature_applies_to() {
let mut p = good();
// polarity applies to predications only.
p.discourse
.features
.push(feat(POLARITY, FeatValue::Option(lab("negative"))));
assert_eq!(
refusals(&p),
vec![Refusal::V8NotApplicable {
feature: iv(POLARITY),
slot: Slot::Discourse
}]
);
let mut referent = good();
referent.referents[0]
.features
.push(feat(HEDGE, FeatValue::Grade(grade(1))));
assert_eq!(
refusals(&referent),
vec![Refusal::V8NotApplicable {
feature: iv(HEDGE),
slot: Slot::Referent
}]
);
let mut relation = good();
relation.relations[0]
.features
.push(feat(HEDGE, FeatValue::Grade(grade(1))));
assert_eq!(
refusals(&relation),
vec![Refusal::V8NotApplicable {
feature: iv(HEDGE),
slot: Slot::Relation
}]
);
let mut pred = good();
pred.predications[0]
.features
.push(feat(HEDGE, FeatValue::Grade(grade(1))));
assert_eq!(refusals(&pred), vec![]);
}
#[test]
fn stance_features_apply_to_the_stance_only() {
// Affect and restraint sit in slot Stance and nowhere else.
assert_eq!(refusals(&good()), vec![]);
for id in [ANGER, RESTRAINT] {
let mut p = good();
p.discourse
.features
.push(feat(id, FeatValue::Grade(grade(1))));
assert_eq!(
refusals(&p),
vec![Refusal::V8NotApplicable {
feature: iv(id),
slot: Slot::Discourse
}]
);
}
let mut referent = good();
referent.referents[0]
.features
.push(feat(AFFECTION, FeatValue::Grade(grade(1))));
assert_eq!(
refusals(&referent),
vec![Refusal::V8NotApplicable {
feature: iv(AFFECTION),
slot: Slot::Referent
}]
);
let mut predication = good();
predication.predications[0]
.features
.push(feat(ANGER, FeatValue::Grade(grade(1))));
assert_eq!(
refusals(&predication),
vec![Refusal::V8NotApplicable {
feature: iv(ANGER),
slot: Slot::Predication
}]
);
// Hedge is a stance on content, not a felt state: not in the stance.
let mut p = good();
p.stance
.features
.push(feat(HEDGE, FeatValue::Grade(grade(1))));
assert_eq!(
refusals(&p),
vec![Refusal::V8NotApplicable {
feature: iv(HEDGE),
slot: Slot::Stance
}]
);
// A stance feature takes its inventory's value kind (V7) and must exist (V5).
let mut flag = good();
flag.stance.features = vec![feat(ANGER, FeatValue::Flag(true))];
assert_eq!(
refusals(&flag),
vec![Refusal::V7ValueKind { what: ANGER.into() }]
);
let mut missing = good();
missing.stance.features = vec![feat("feature:99", FeatValue::Grade(grade(1)))];
assert_eq!(refusals(&missing), vec![unresolved("feature:99")]);
// A default stance is valid: the packet records no felt state.
let mut none = good();
none.stance = Stance::default();
assert_eq!(refusals(&none), vec![]);
}
#[test]
fn displayed_formality_warmth_and_intensity_are_not_in_the_inventory() {
// Felt affect is the packet's; how it is displayed is each language's
// ontology's, so no feature carries it.
let inv = inventory();
for gone in ["warmth", "formality", "intensity"] {
assert!(inv.entries().all(|e| e.gloss != gone), "{gone}");
}
}
#[test]
fn v10_rev_exists() {
let mut p = good();
p.inventory_rev = 9;
assert_eq!(refusals(&p), vec![Refusal::V10UnknownRevision { rev: 9 }]);
p.inventory_rev = 0;
assert_eq!(refusals(&p), vec![Refusal::V10UnknownRevision { rev: 0 }]);
}
#[test]
fn every_broken_rule_is_reported() {
let mut p = good();
p.schema_version = 7;
p.predications[0].roles[0].filler = nd("n99");
p.extensions[0].key = iv(CONCEPT_A);
let rules: Vec<&str> = refusals(&p).iter().map(Refusal::rule).collect();
assert_eq!(rules, vec!["V1", "V4", "V6"]);
}
#[test]
fn lowering_input_carries_no_trace_id_or_provenance() {
let mut p = good();
p.trace.notes = vec!["SECRET-NOTE".into()];
p.trace.rejected_readings = vec!["SECRET-READING".into()];
// Only the packet store's door yields the view.
let w = world();
let mut store = PacketStore::open(&w.dir.path().join("p.cc")).unwrap();
let input = store
.admit(p.clone(), &w.inventory, &w.corpus, &w.entities)
.unwrap();
let seen = format!("{input:?}");
for hidden in [PACKET_ID, ENCODER_ID, "item:nibu:1", "SECRET"] {
assert!(!seen.contains(hidden), "{hidden} reached the input: {seen}");
}
// The view is the packet's meaning, readable through getters.
assert_eq!(input.inventory_rev(), 2);
assert_eq!(input.kind(), PacketKind::DialogueLine);
assert_eq!(input.referents(), p.referents.as_slice());
assert_eq!(input.predications(), p.predications.as_slice());
assert_eq!(input.relations(), p.relations.as_slice());
assert_eq!(input.extensions(), p.extensions.as_slice());
assert_eq!(input.context(), &p.context);
assert_eq!(input.discourse(), &p.discourse);
assert_eq!(input.constraints(), &p.constraints);
// The speaker's stance is meaning, not provenance: it reaches the lowerer.
assert_eq!(input.stance(), &p.stance);
assert!(!p.stance.features.is_empty());
}
#[test]
fn invalid_packet_has_no_lowering_input() {
let mut p = good();
p.schema_version = 99;
p.referents.clear();
let refused = validate(&p, &inventory()).unwrap_err();
let rules: Vec<&str> = refused.iter().map(Refusal::rule).collect();
assert_eq!(rules[0], "V1");
assert!(rules.contains(&"V4"), "{rules:?}");
}
fn grade_feature(grade_value: u16) -> FeatureValue {
feat(FEAT_ALL, FeatValue::Grade(grade(grade_value)))
}
fn referent(id: &str) -> Referent {
Referent {
id: nd(id),
kind: iv(PERSON),
concept: iv(CONCEPT_A),
entity: None,
features: vec![],
}
}
fn v11(slot: Option<Slot>, feature: &str) -> Refusal {
Refusal::V11DuplicateFeature {
feature: iv(feature),
slot,
}
}
#[test]
fn v11_duplicate_feature_each_slot() {
let mut discourse = good();
discourse.discourse.features = vec![grade_feature(1), grade_feature(2)];
assert_eq!(
refusals(&discourse),
vec![v11(Some(Slot::Discourse), FEAT_ALL)]
);
let mut referent = good();
referent.referents[0].features = vec![grade_feature(1), grade_feature(2)];
assert_eq!(
refusals(&referent),
vec![v11(Some(Slot::Referent), FEAT_ALL)]
);
let mut predication = good();
predication.predications[0]
.features
.extend([grade_feature(1), grade_feature(2)]);
assert_eq!(
refusals(&predication),
vec![v11(Some(Slot::Predication), FEAT_ALL)]
);
let mut relation = good();
relation.relations[0].features = vec![grade_feature(1), grade_feature(2)];
assert_eq!(
refusals(&relation),
vec![v11(Some(Slot::Relation), FEAT_ALL)]
);
let mut stance = good();
stance.stance.features = vec![grade_feature(1), grade_feature(2)];
assert_eq!(refusals(&stance), vec![v11(Some(Slot::Stance), FEAT_ALL)]);
let mut felt = good();
felt.stance
.features
.push(feat(ANGER, FeatValue::Grade(grade(5))));
assert_eq!(refusals(&felt), vec![v11(Some(Slot::Stance), ANGER)]);
let mut extension = good();
extension.extensions.push(Extension {
key: iv(EXT),
value: FeatValue::Grade(grade(4)),
});
assert_eq!(refusals(&extension), vec![v11(None, EXT)]);
}
#[test]
fn v11_counts_per_list_not_per_packet() {
// The same feature once in each list, and different features in one list, are fine.
let mut p = good();
p.discourse.features = vec![grade_feature(1), feat(HEDGE, FeatValue::Grade(grade(2)))];
p.referents[0].features = vec![grade_feature(1)];
p.predications[0].features.push(grade_feature(1));
p.relations[0].features = vec![grade_feature(1)];
assert_eq!(refusals(&p), vec![]);
}
#[test]
fn v11_grade_bytes_packet_refused() {
// Soul s2: 113 hedge grades in one list spell a line byte by byte.
let mut p = good();
p.discourse.features = (0..113)
.map(|i| feat(HEDGE, FeatValue::Grade(grade(i))))
.collect();
let got = refusals(&p);
assert_eq!(got.len(), 112, "{got:?}");
assert!(got.iter().all(|r| *r == v11(Some(Slot::Discourse), HEDGE)));
}
#[test]
fn v12_node_bound() {
// 62 referents, the predication n2 and the relation make 64 nodes.
let with_referents = |last: u32| {
let mut p = good();
let ids = std::iter::once(1).chain(3..=last);
p.referents = ids.map(|k| referent(&format!("n{k}"))).collect();
p
};
let at_bound = with_referents(63);
assert_eq!(
at_bound.referents.len() + at_bound.predications.len() + at_bound.relations.len(),
Packet::MAX_NODES
);
assert_eq!(refusals(&at_bound), vec![]);
assert_eq!(
refusals(&with_referents(64)),
vec![Refusal::V12TooManyNodes {
count: 65,
max: Packet::MAX_NODES
}]
);
}
// Soul s3: a repeated node id in a node list, or a repeated role in one
// predication, spelled a line one bit per entry. Each list now holds a node or
// a role at most once, so the channel is bounded.
const LINE: &str = "Take your hand off the hatch, Captain.";
fn bits(s: &str) -> Vec<bool> {
s.bytes()
.flat_map(|b| (0..8).rev().map(move |i| (b >> i) & 1 == 1))
.collect()
}
fn two_referents() -> Packet {
let mut p = good();
let mut r = p.referents[0].clone();
r.id = nd("n3");
p.referents.push(r);
p
}
fn bit_ids(s: &str) -> Vec<NodeId> {
bits(s)
.into_iter()
.map(|b| nd(if b { "n3" } else { "n1" }))
.collect()
}
fn dup_node(id: &str, at: &'static str) -> Refusal {
Refusal::V11DuplicateNodeRef { id: nd(id), at }
}
#[test]
fn v11_node_lists_refuse_repeats_and_allow_each_node_once() {
let mut ok = two_referents();
ok.context.participants = vec![nd("n1"), nd("n3")];
ok.context.audience = vec![nd("n3"), nd("n1")];
ok.constraints.preserve = vec![nd("n3"), nd("n1")];
assert_eq!(refusals(&ok), vec![]);
let mut p = two_referents();
p.context.participants = vec![nd("n1"), nd("n1")];
assert_eq!(refusals(&p), vec![dup_node("n1", "participant")]);
let mut p = two_referents();
p.context.audience = vec![nd("n3"), nd("n1"), nd("n3")];
assert_eq!(refusals(&p), vec![dup_node("n3", "audience")]);
let mut p = two_referents();
p.constraints.preserve = vec![nd("n1"), nd("n1")];
assert_eq!(refusals(&p), vec![dup_node("n1", "preserve")]);
}
#[test]
fn v11_bit_channel_in_each_node_list_refused() {
let n = bits(LINE).len();
let mut p = two_referents();
p.context.participants = bit_ids(LINE);
let got = refusals(&p);
assert_eq!(got.len(), n - 2, "{got:?}");
assert!(got.iter().all(|r| r.rule() == "V11"));
let mut p = two_referents();
p.context.audience = bit_ids(LINE);
assert_eq!(refusals(&p).len(), n - 2);
let mut p = two_referents();
p.constraints.preserve = bit_ids(LINE);
assert_eq!(refusals(&p).len(), n - 2);
}
#[test]
fn v11_unbounded_participants_refused() {
let mut p = two_referents();
p.context.participants = (0..100_000)
.map(|i| nd(if i % 3 == 0 { "n3" } else { "n1" }))
.collect();
assert_eq!(refusals(&p).len(), 100_000 - 2);
}
#[test]
fn v11_role_repeat_in_one_predication_refused() {
let dup = |role: &str| Refusal::V11DuplicateRole {
role: iv(role),
predication: nd("n2"),
};
let mut p = good();
p.predications[0].roles = bits(LINE)
.into_iter()
.map(|b| RoleFill {
role: iv(if b { AGENT } else { PATIENT }),
filler: nd("n1"),
})
.collect();
let got = refusals(&p);
assert_eq!(got.len(), bits(LINE).len() - 2, "{got:?}");
assert!(got.contains(&dup(AGENT)) && got.contains(&dup(PATIENT)));
// The same role in two different predications is not a repeat.
let mut ok = good();
let mut second = ok.predications[0].clone();
second.id = nd("n4");
ok.predications.push(second);
assert_eq!(refusals(&ok), vec![]);
}

1
data/corpus/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:brokkr:1±weksa.corpus_item´2026-10-29T14:59:18ZĨ›­item:brokkr:1Ù,The command has no receipt. It is not truth.¥en-US­entity:brokkr±brokkr-edda-voice<EFBFBD><EFBFBD>¬ModelWritten“¦OpenAi¾codex-local-source-aware-draftÀª2026-10-29ÀÂ<EFBFBD>”«item:nibu:1±weksa.corpus_item´2026-10-29T14:59:18ZÄÉ›«item:nibu:1ÙnTake your hand off that control unless you want your nervous system to become my least useful circuit diagram.¥en-US«entity:nibu®nibu-proof-001<EFBFBD><EFBFBD>¬ModelWritten“ªUnrecordedÀÀª2026-10-29ÀÂ<EFBFBD>”«item:nibu:2±weksa.corpus_item´2026-10-29T14:59:18ZÄ웫item:nibu:2Ù‘Do not touch that control. It is damaged; one touch may put you directly in harm's way. I am preventing an avoidable injury, not asking politely.¥en-US«entity:nibu®nibu-proof-001<EFBFBD><EFBFBD>¬ModelWritten“ªUnrecordedÀÀª2026-10-29ÀÂ<EFBFBD>

View file

@ -20,7 +20,6 @@ may retrieve candidates, but accepted ontology remains typed state.
- `cultures/`: culture or life-history profiles that can shape lowering when a
Persona explicitly references them.
- `subcultures/`: community-of-practice and register profiles.
- `referents/`: bounded source-text, mythic, literary, ritual, or historical
referents. These are not whole-culture profiles; they preserve reusable
role, salience, affordance, and forbidden-flattening pressure.

View file

@ -14,18 +14,8 @@ qdrant:
Qdrant owns retrieval over culture/subculture chunks. It does not own
accepted ontology, activation, or lowering authority.
profiles:
- profile_id: weksa.culture.dutch_american_diaspora.v0
path: data/cultural-ontology/cultures/dutch-american-diaspora.yaml
- profile_id: weksa.culture.western_europe_maritime_childhood.v0
path: data/cultural-ontology/cultures/western-europe-maritime-childhood.yaml
- profile_id: weksa.culture.portugal_resident_algarve_lisbon.v0
path: data/cultural-ontology/cultures/portugal-resident-algarve-lisbon.yaml
- profile_id: weksa.culture.texas_houston_us_adolescence.v0
path: data/cultural-ontology/cultures/texas-houston-us-adolescence.yaml
- profile_id: weksa.subculture.online_game_dev_discord_pre2025.v0
path: data/cultural-ontology/subcultures/online-game-dev-discord-pre2025.yaml
- profile_id: weksa.subculture.gamecult_open_source_crypto_anarchist.v0
path: data/cultural-ontology/subcultures/gamecult-open-source-crypto-anarchist.yaml
- profile_id: weksa.cultural_referent.old_norse_edda_source_tradition.v0
path: data/cultural-ontology/referents/old-norse-edda-source-tradition.yaml
- profile_id: weksa.cultural_referent.old_norse_edda_brokkr_skaldskaparmal.v0

View file

@ -1,58 +0,0 @@
schema_version: weksa.cultural_ontology_profile.v0
profile_id: weksa.culture.dutch_american_diaspora.v0
kind: diaspora_identity
status: draft_guarded
language:
codes:
- en
- nl
community:
regions:
- Netherlands
- United States
speech_community: >
People whose self-description or life history crosses Dutch origin and
American nationality/residence. This profile is not a dialect claim.
activation:
allowed_when:
- persona_state_references_profile
- interlingua_mentions_origin_nationality_or_cross_cultural_identity
forbidden_when:
- used_to_infer_accent_or_fluency_without_evidence
- used_as_generic_dutch_or_generic_american_stereotype
salience_axes:
plural_national_identity:
axis_id: plural_national_identity
domain: identity_indexing
description: Birthplace, citizenship, residence, and cultural identification may diverge.
relevant_distinctions:
- distinction_id: birthplace_vs_citizenship
label: Birthplace versus citizenship
meaning: Being born in one country and holding identity/citizenship in another are separate ontology fields.
lowering_questions:
- Does the utterance need origin, nationality, residence, or cultural affiliation?
required_when:
- source_mentions_identity_origin_or_nationality
forbidden_when:
- scene_only_requires_casual_banter
evidence_refs:
- discord:805822798185627718
- discord:1030577958215225396
evidence_ledger:
- ref: discord:805822798185627718
quote: "I live in Portugal at the moment, though I am a Netherlands-born US citizen - lived mostly in Texas"
- ref: discord:1030577958215225396
quote: "Dual nationality, Dutch American"
- ref: discord:862490040755683348
quote: "I was born in the Netherlands but I'm a US citizen and did high school in Houston, Texas."
linguistic_affordances:
grammar_features: []
phonology_features: []
lexicon_features: []
discourse_patterns: []
orthography_features: []
code_switching:
allowed: false
constraints:
- Do not infer Dutch code switching from diaspora identity alone.
patterns: []

View file

@ -1,84 +0,0 @@
schema_version: weksa.cultural_ontology_profile.v0
profile_id: weksa.culture.portugal_resident_algarve_lisbon.v0
kind: regional_residence
status: draft_guarded
language:
codes:
- en
- pt
community:
regions:
- Portugal
- Algarve
- Lisbon
- Tavira
speech_community: >
Portugal as lived residence and locality rather than tourist backdrop.
activation:
allowed_when:
- persona_state_references_profile
- interlingua_mentions_portugal_residence_locality_bureaucracy_or_timing
forbidden_when:
- used_for_broad_portuguese_culture_without_locale_review
salience_axes:
lived_portugal:
axis_id: lived_portugal
domain: identity_indexing
description: Portugal references can be local/home references, including Algarve/Lisbon/Tavira specificity.
relevant_distinctions:
- distinction_id: current_or_returned_residence
label: Current or returned residence
meaning: Portugal can be present-tense home context, not childhood-only memory.
lowering_questions:
- Is Portugal being invoked as current location, childhood location, or comparison frame?
required_when:
- source_mentions_portugal_or_portuguese_residence
forbidden_when:
- scene_has_no_place_salience
evidence_refs:
- discord:581220303414231117
- discord:834858944013926421
- discord:888820124344254504
evidence_ledger:
- ref: discord:581220303414231117
quote: "I'm in the Algarve now"
- ref: discord:834858944013926421
quote: "I live in Portugal now"
- ref: discord:888820124344254504
quote: "But I'm currently in Lisbon"
- ref: discord:1023774733789040640
quote: "if you wanna move to Portugal you're gonna have to get used to not getting things on time"
linguistic_affordances:
grammar_features: []
phonology_features: []
lexicon_features: []
discourse_patterns:
- pattern_id: portugal_timing_joke_guarded
label: Portugal timing joke, guarded
status: draft
domain:
layer: discourse
category: humor
subcategory: local_bureaucracy_timing
interactional_function: locally_grounded_timing_joke
sequence_shape:
- playful_warning
- local_timing_generalization
adjacency_constraints:
- addressee_is_discussing_moving_or_living_in_portugal
activation:
allowed_when:
- local_portugal_residence_context
- joking_register
forbidden_when:
- formal_cultural_description
evidence:
source_refs:
- discord:1023774733789040640
confidence: low
orthography_features: []
code_switching:
allowed: false
constraints:
- This profile alone does not authorize Portuguese code switching.
patterns: []

View file

@ -1,56 +0,0 @@
schema_version: weksa.cultural_ontology_profile.v0
profile_id: weksa.culture.texas_houston_us_adolescence.v0
kind: adolescence_residence
status: draft_guarded
language:
codes:
- en
community:
regions:
- United States
- Texas
- Houston
speech_community: >
US/Texas adolescent institutional context, especially Houston high school
and later critique of American debt/institutional pathways.
activation:
allowed_when:
- persona_state_references_profile
- interlingua_mentions_us_texas_school_debt_or_american_institutions
forbidden_when:
- used_to_force_texan_dialect_markers
salience_axes:
american_institutional_distance:
axis_id: american_institutional_distance
domain: stance
description: US institutions can be lived experience and object of critique rather than foreign abstraction.
relevant_distinctions:
- distinction_id: debt_train_refusal
label: American debt train refusal
meaning: Avoiding US education/debt pathways can be a first-person institutional stance.
lowering_questions:
- Is the utterance about education, debt, leaving the US, or institutional pressure?
required_when:
- source_mentions_us_debt_or_school_path
forbidden_when:
- casual_fixture_without_institutional_topic
evidence_refs:
- discord:1160260490690306148
evidence_ledger:
- ref: discord:805822798185627718
quote: "lived mostly in Texas"
- ref: discord:862490040755683348
quote: "did high school in Houston, Texas"
- ref: discord:1160260490690306148
quote: "I specifically left the US after high school so as not to climb onto the traditional American debt train"
linguistic_affordances:
grammar_features: []
phonology_features: []
lexicon_features: []
discourse_patterns: []
orthography_features: []
code_switching:
allowed: false
constraints:
- Do not infer Southern or Texan dialect surface markers from residence alone.
patterns: []

View file

@ -10,8 +10,8 @@ community:
- coastal_western_europe
speech_community: >
Mobile childhood organized around sailing, ports, and cross-border Western
European movement. This profile is owner-attested for the Metame fixture and
needs more Discord corroboration before broader use.
European movement. This profile is owner-attested and needs corroboration
before broader use.
activation:
allowed_when:
- persona_state_references_profile
@ -35,11 +35,6 @@ salience_axes:
- scene_has_no_mobility_or_home_salience
evidence_refs:
- owner:2026-06-10:sailboat_france_spain_portugal_until_6
evidence_ledger:
- ref: owner:2026-06-10:sailboat_france_spain_portugal_until_6
quote: "I was born in the Netherlands, grew up on a sailboat traveling France, Spain, staying in Portugal until I was 6, moved to the US."
- ref: discord-search-gap:sailboat_childhood
summary: First semantic search did not locate a direct Discord message for this exact childhood account.
linguistic_affordances:
grammar_features: []
phonology_features: []

View file

@ -1,51 +0,0 @@
schema_version: weksa.cultural_ontology_profile.v0
profile_id: weksa.subculture.gamecult_open_source_crypto_anarchist.v0
kind: political_technical_community
status: draft_guarded
language:
codes:
- en
community:
regions:
- online
speech_community: >
GameCult-adjacent open-source, crypto-anarchist, game-development, and
engineering practice where ownership, inspectability, debt, authority, and
tool coherence are culturally salient.
activation:
allowed_when:
- persona_state_references_profile
- interlingua_mentions_tools_authority_open_source_debt_game_dev_or_engineering
forbidden_when:
- used_to_turn_random_banter_into_manifesto
salience_axes:
authority_and_tool_coherence:
axis_id: authority_and_tool_coherence
domain: stance
description: Systems are judged by ownership, inspectability, agency, and whether they actually work.
relevant_distinctions:
- distinction_id: open_source_anti_custody
label: Open source anti-custody
meaning: Open access and inspectability are political and practical values.
lowering_questions:
- Is the line judging a tool, institution, ownership structure, or coordination method?
- Should the interlingua preserve anti-custody or open-access salience?
required_when:
- source_mentions_open_source_or_institutional_authority
forbidden_when:
- casual_social_move_only
evidence_refs:
- discord:862490040755683348
linguistic_affordances:
grammar_features: []
phonology_features: []
lexicon_features: []
discourse_patterns: []
orthography_features: []
code_switching:
allowed: false
constraints: []
patterns: []
evidence_ledger:
- ref: discord:862490040755683348
quote: "I got my bachelor degree in game development last year. I'm a crypto-anarchist open source activist"

View file

@ -1,288 +0,0 @@
schema_version: weksa.cultural_ontology_profile.v0
profile_id: weksa.subculture.online_game_dev_discord_pre2025.v0
kind: community_of_practice
status: draft_guarded
language:
codes:
- en
community:
regions:
- online_discord
speech_community: >
Pre-2025 GameCult/Aquarium-ish Discord collaboration where game design,
sound, engineering, jokes, and casual status play cohabit the same rooms.
activation:
allowed_when:
- persona_state_references_profile
- target_medium_is_discord
- target_period_is_pre_2025_or_history_fixture
forbidden_when:
- formal_current_project_doctrine_is_target
salience_axes:
casual_room_temperature:
axis_id: casual_room_temperature
domain: discourse_patterns
description: Compact, socially alive wording that assumes shared room context.
relevant_distinctions:
- distinction_id: banter_over_explanation
label: Banter over explanation
meaning: The line may be short, fragmentary, and joke-shaped when context supplies the frame.
lowering_questions:
- Can the utterance be one sentence or fragment?
- Does explanation weaken the social move?
required_when:
- fixture_style_tags_include_casual
forbidden_when:
- formal_review_or_doctrine_is_requested
evidence_refs:
- discord:802911591288537118
- discord:820077384572338176
linguistic_affordances:
grammar_features:
- feature_id: fragmentary_like_comparison
label: Fragmentary Like comparison
status: draft
domain:
layer: grammar
category: comparison
subcategory: fragment
description: >
A joke-description may start with "Like..." and remain fragmentary when
it is an immediate continuation of collaborative design talk.
realization:
function: casual_comic_comparison
surface_strategy: "Like a really terrible <thing> that <absurd property>"
examples:
- "Like a really terrible engine that sounds like coconuts when you use it"
activation:
allowed_when:
- collaborative_design_banter
forbidden_when:
- standalone_formal_explanation
requires_profile_authority: true
minimum_density: light
maximum_density: normal
evidence:
source_refs:
- discord:802911591288537118
confidence: medium
phonology_features: []
lexicon_features:
- feature_id: casual_recoil_oof
label: Casual recoil marker oof
status: draft
domain:
layer: lexicon
category: stance_marker
subcategory: embarrassment
lexical_items:
- form: oof
gloss: amused impact or discomfort
sense: playful_recoil
register: casual_discord
allowed_speaker_standing: speaker_can_self_deprecate
forbidden_senses: []
collocations:
- "oof, don't tell me"
activation:
allowed_when:
- compliment_or_trait_read_lands
forbidden_when:
- serious_apology_or_harm_context
requires_profile_authority: true
minimum_density: light
maximum_density: light
evidence:
source_refs:
- discord:1037314100931338260
confidence: high
- feature_id: mock_challenge_coward
label: Mock challenge coward
status: draft
domain:
layer: lexicon
category: stance_marker
subcategory: friendly_roast
lexical_items:
- form: coward
gloss: mock insult
sense: friendly_status_challenge
register: casual_discord
allowed_speaker_standing: active_banter_with_known_participant
forbidden_senses:
- actual_contempt
collocations:
- "then you coward"
activation:
allowed_when:
- friendly_roast_context
- target_has_claimed_competence
forbidden_when:
- unknown_relationship
- serious_conflict
requires_profile_authority: true
minimum_density: light
maximum_density: light
evidence:
source_refs:
- discord:1023727427924545576
confidence: high
- feature_id: preserve_self_indexed_technical_label
label: Preserve self-indexed technical label
status: draft
domain:
layer: lexicon
category: technical_term
subcategory: self_indexing
description: >
When Persona interlingua frames the speaker through a locally salient
technical identity label, lowering should preserve that label instead of
inventing a fresher metaphor. In this subculture, "engineer" can be a
joked-about identity index, not just an occupation.
lexical_items:
- form: engineer
gloss: technical identity / competence index
sense: self_indexed_engineering_vibe
register: casual_discord
allowed_speaker_standing: speaker_or_room_has_made_engineering_salient
forbidden_senses:
- formal_job_title_unless_context_requires
collocations:
- engineer vibes
- engineer-like
activation:
allowed_when:
- persona_interlingua_mentions_engineer_like_or_engineering_salience
- prior_turn_compliments_efficiency_or_structure
forbidden_when:
- interlingua_does_not_name_technical_identity
requires_profile_authority: true
minimum_density: light
maximum_density: light
constraints:
register_contexts:
- casual
speaker_contexts:
- metame_or_metacrat_fixture
grammar_contexts:
- playful_self_deprecation
incompatibilities:
- replacement_identity_metaphor
- creature_metaphor_substitution
evidence:
source_refs:
- discord:1037314100931338260
confidence: medium
discourse_patterns:
- pattern_id: compact_recoil_no_context_dump
label: Compact recoil without context dump
status: draft
domain:
layer: discourse
category: stance_marker
subcategory: self_deprecation
interactional_function: answer_trait_read_with_short_mock_wince
sequence_shape:
- recoil_marker
- dont_tell_me_frame
- possessive_context_object
- preserved_trait_label
adjacency_constraints:
- prior_turn_already_contains_the_explanation
- generated_line_should_not_quote_or_summarize_prior_turns
activation:
allowed_when:
- playful_self_deprecation
- prior_turn_compliments_efficiency_or_structure
forbidden_when:
- source_intent_requires_explanation
requires_profile_authority: true
minimum_density: light
maximum_density: light
constraints:
register_contexts:
- casual
speaker_contexts:
- metame_or_metacrat_fixture
collocation_limits:
- target_length_roughly_6_to_12_words
- avoid_quoted_prior_turn_paraphrase
- avoid_because_clause
- avoid_disease_metaphor_unless_interlingua_requests_it
evidence:
source_refs:
- discord:1037314100931338260
confidence: medium
- pattern_id: casual_floor_yield_with_lol
label: Casual floor yield with lol
status: draft
domain:
layer: discourse
category: turn_taking
subcategory: floor_yield
interactional_function: yield_floor_after_riff
sequence_shape:
- discourse_pivot_anyway
- self_silencing_imma_shut_up_now
- invite_others_to_invent
- lol_softener
adjacency_constraints:
- prior_turns_show_speaker_has_been_riffing
- other_participants_have_interest_or_pending_contribution
activation:
allowed_when:
- conversational_yield
- casual_collaborative_ideation
forbidden_when:
- formal_moderation
requires_profile_authority: true
minimum_density: light
maximum_density: light
evidence:
source_refs:
- discord:820077384572338176
confidence: high
orthography_features:
- feature_id: light_discord_informality
label: Light Discord informality
status: draft
domain:
layer: orthography
category: punctuation
subcategory: informal_case
allowed_surface_forms:
- lower_case_opening
- markdown_italics_for_single_emphasis
- no_terminal_period
- imma
- lol
forbidden_surface_forms:
- heavy_eye_dialect_for_identity_caricature
- assistant_like_sentence_polishing
activation:
allowed_when:
- casual_discord_register
forbidden_when:
- formal_or_public_document_register
requires_profile_authority: true
minimum_density: light
maximum_density: normal
evidence:
source_refs:
- discord:1037314100931338260
- discord:820077384572338176
confidence: medium
code_switching:
allowed: false
constraints:
- This subculture alone does not authorize cross-language switching.
patterns: []
evidence_ledger:
- ref: discord:1037314100931338260
summary: Self-deprecating recoil after efficiency compliment.
- ref: discord:802911591288537118
summary: Fragmentary absurd comparison during sound/game design banter.
- ref: discord:1023727427924545576
summary: Very short mock-aggressive challenge in friendly roast context.
- ref: discord:820077384572338176
summary: Casual profane floor-yield after extended ideation.

1
data/entity/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:brokkr¬weksa.entity´2026-10-29T16:43:28ZÄn“­entity:brokkr‘’¥en-US‘¦Brokkr<EFBFBD>¤Cast’¦brokkrÙ?examples/persona-lowering/brokkr-edda-voice/lowered-output.yaml”«entity:nibu¬weksa.entity´2026-10-29T16:43:28ZÄe“«entity:nibu‘’¥en-US‘¤Nibu<EFBFBD>¤Cast’¤nibuÙ<examples/english-lowering/nibu-proof-001/lowered-output.yaml

Binary file not shown.

View file

@ -1,546 +0,0 @@
profile_id: weksa.target_cultural_ontology.en-US.metacrat_casual_pre2025.v0
kind: community_of_practice
status: draft_guarded
language:
code: en
locale: en-US
script: Latn
variety_label: Metacrat casual Discord, pre-2025
endonyms: []
exonyms: []
parent_profiles:
- weksa.target_cultural_ontology.en-US.v0
required_subculture_refs:
- weksa.subculture.online_game_dev_discord_pre2025.v0
community:
regions:
- online_discord
speech_community: >
Metacrat-authored casual Discord exchanges before 2025, especially
GameCult/Aquarium rooms where technical work, jokes, status play, and
collaborative ideation share the same channel.
historical_context: >
Pre-2025 room voice is less doctrine-heavy and less architectural than the
current Weksa/CultCache register. It tolerates messier banter, compact
jokes, self-deprecation, friendly mock aggression, and quick room-level
turn-taking.
identity_authority: >
This profile is a target-language lowering overlay for one subculture. Live
Persona state should reference a culture stack; current intent and memory
pressure live in Metame Persona state.
consent_notes: >
Use only for owner-authorized Metame/Metacrat fixture work. Do not generalize
to other people, cultures, dialects, or protected identities.
activation:
allowed_when:
- speaker_id_is_metame_or_metacrat_fixture
- target_period_is_pre_2025_casual_discord
- fixture_or_owner_authorized_history_target
forbidden_when:
- formal_current_project_doctrine_is_target
- target_speaker_is_not_metame_or_metacrat
- use_would_expose_private_persona_state
required_confidence: provisional
reviewer_required_when:
- adding_new_public_history_evidence
- using_profile_outside_fixture_harness
salience_axes:
register_gravity:
axis_id: register_gravity
domain: stance
description: >
Whether the line should stay in casual room banter instead of being pulled
into architecture, doctrine, or explanatory assistant prose.
relevant_distinctions:
- distinction_id: casual_room_temperature
label: Casual room temperature
meaning: Compact, socially alive wording that assumes shared room context.
lowering_questions:
- Can the utterance be one sentence or fragment?
- Does explanation weaken the social move?
required_when:
- fixture_style_tags_include_casual
forbidden_when:
- formal_review_or_doctrine_is_requested
evidence_refs:
- discord:1037314100931338260
- discord:802911591288537118
- discord:1023727427924545576
- discord:820077384572338176
self_indexed_technical_identity:
axis_id: self_indexed_technical_identity
domain: identity_indexing
description: >
If Persona interlingua names a technical self-index such as engineer-like
identity, preserve that label unless the target language profile has a
stronger reason to paraphrase.
relevant_distinctions:
- distinction_id: preserve_engineer_label
label: Preserve engineer label
meaning: "Engineer" may carry the social joke and should not be replaced by a novel metaphor.
lowering_questions:
- Did Persona interlingua mention engineer-like or engineering salience?
- Is the prior compliment about efficiency, structure, or technical order?
required_when:
- persona_interlingua_mentions_engineer_like_or_engineering_salience
forbidden_when:
- interlingua_does_not_name_technical_identity
evidence_refs:
- discord:1037314100931338260
context_compression:
axis_id: context_compression
domain: discourse_patterns
description: >
Prior-turn content should usually compress into a short object label or
stance marker, not be quoted back inside the utterance.
relevant_distinctions:
- distinction_id: no_context_dump_in_banter
label: No context dump in banter
meaning: Casual recoil should not summarize the setup that the room just saw.
lowering_questions:
- Is the prior context already visible to the addressee?
- Can the setup compress into "my discord", "this thing", or a short noun phrase?
required_when:
- casual_discord_register
- playful_self_deprecation
forbidden_when:
- source_intent_requires_explanation
evidence_refs:
- discord:1037314100931338260
status_play:
axis_id: status_play
domain: social_relation
description: >
Friendly teasing can take a sharper surface form than a neutral assistant
would choose, provided the local context already supports play.
relevant_distinctions:
- distinction_id: mock_aggression
label: Mock aggression
meaning: Imperative or insult-shaped challenge used as friendly banter.
lowering_questions:
- Is the addressee already joking or status-playing?
- Would softening erase the bit?
required_when:
- communicative_act_is_playful_challenge
forbidden_when:
- relationship_context_is_unknown_or_hostile
evidence_refs:
- discord:1023727427924545576
competence_recoil:
axis_id: competence_recoil
domain: affect
description: >
Praise for competence, efficiency, or engineering can be answered with
joking embarrassment rather than straightforward acceptance.
relevant_distinctions:
- distinction_id: self_deprecating_recoil
label: Self-deprecating recoil
meaning: "oof" plus mock concern that a trait is showing too clearly.
lowering_questions:
- Is the prior turn a compliment or recognition of technical order?
- Can the line admit the trait without fully accepting the label?
required_when:
- communicative_act_is_playful_self_deprecation
forbidden_when:
- source_intent_requires_gratitude_or_serious_acceptance
evidence_refs:
- discord:1037314100931338260
ideation_turn_taking:
axis_id: ideation_turn_taking
domain: discourse_patterns
description: >
After riffing, the speaker may explicitly yield the floor in profane,
casual language instead of formal facilitation.
relevant_distinctions:
- distinction_id: casual_floor_yield
label: Casual floor yield
meaning: Stop dominating the thread and invite others to invent.
lowering_questions:
- Has the speaker been riffing at length?
- Would a casual "lol" preserve warmth and reduce authority?
required_when:
- communicative_act_is_conversational_yield
forbidden_when:
- scene_requires_formal_moderation
evidence_refs:
- discord:820077384572338176
linguistic_affordances:
grammar_features:
- feature_id: fragmentary_like_comparison
label: Fragmentary Like comparison
status: draft
domain:
layer: grammar
category: comparison
subcategory: fragment
description: >
A joke-description may start with "Like..." and remain fragmentary when
it is an immediate continuation of collaborative design talk.
formal_conditions:
syntactic_environment:
- adjacency_pair_response
- elliptical_clause
semantic_inputs:
- comic_description
- absurd_comparison
required_interlingua_fields:
- discourse.speech_act
- predications
realization:
function: casual_comic_comparison
surface_strategy: "Like a really terrible <thing> that <absurd property>"
examples:
- "Like a really terrible engine that sounds like coconuts when you use it"
activation:
allowed_when:
- collaborative_design_banter
forbidden_when:
- standalone_formal_explanation
requires_profile_authority: true
minimum_density: light
maximum_density: normal
constraints:
grammar_contexts:
- previous_turn_supplies_topic
register_contexts:
- casual
speaker_contexts:
- metame_or_metacrat_fixture
collocation_limits: []
incompatibilities:
- overexplained_punchline
indexicality:
social_meanings:
- improvisational
- collaborative
- unserious_but_useful
in_group_meanings:
- game_design_room_banter
out_group_risks:
- may seem incomplete without prior context
stereotypes_to_avoid: []
evidence:
source_refs:
- discord:802911591288537118
qdrant_source_ids:
- "802911591288537118"
cached_source_refs: []
source_hashes: []
reviewer_refs: []
confidence: medium
uncertainty:
open_questions:
- How often should fragmentary openings appear outside sound/design talk?
known_limits:
- Single fixture line is evidence for an affordance, not a universal rule.
trace:
trace_label: fragmentary_like_comparison
must_report_when_used: true
must_report_when_rejected: true
phonology_features: []
lexicon_features:
- feature_id: casual_recoil_oof
label: Casual recoil marker oof
status: draft
domain:
layer: lexicon
category: stance_marker
subcategory: embarrassment
description: >
"oof" can mark amused discomfort when a social read lands too accurately.
lexical_items:
- form: oof
gloss: amused impact or discomfort
sense: playful_recoil
register: casual_discord
allowed_speaker_standing: speaker_can_self_deprecate
forbidden_senses: []
collocations:
- "oof, don't tell me"
realization:
function: self_deprecating_recoil
surface_strategy: start line with "oof" when prior turn praises a trait the speaker wants to half-resist
examples:
- "oof, don't tell me my discord has *engineer* vibes"
activation:
allowed_when:
- compliment_or_trait_read_lands
forbidden_when:
- serious_apology_or_harm_context
requires_profile_authority: true
minimum_density: light
maximum_density: light
constraints:
register_contexts:
- casual
speaker_contexts:
- metame_or_metacrat_fixture
grammar_contexts: []
phonological_contexts: []
collocation_limits:
- avoid_repeating_if_already_in_recent_context
incompatibilities: []
indexicality:
social_meanings:
- amused_embarrassment
- competence_recoil
in_group_meanings: []
out_group_risks:
- can sound too internet-generic if unsupported by context
stereotypes_to_avoid: []
evidence:
source_refs:
- discord:1037314100931338260
qdrant_source_ids:
- "1037314100931338260"
cached_source_refs: []
source_hashes: []
reviewer_refs: []
confidence: high
uncertainty:
open_questions: []
known_limits:
- Marker frequency should be sparse.
trace:
trace_label: casual_recoil_oof
must_report_when_used: true
must_report_when_rejected: true
- feature_id: mock_challenge_coward
label: Mock challenge coward
status: draft
domain:
layer: lexicon
category: stance_marker
subcategory: friendly_roast
description: >
"coward" can close a very short challenge when surrounding turns make
the aggression visibly playful.
lexical_items:
- form: coward
gloss: mock insult
sense: friendly_status_challenge
register: casual_discord
allowed_speaker_standing: active_banter_with_known_participant
forbidden_senses:
- actual_contempt
collocations:
- "then you coward"
realization:
function: playful_status_challenge
surface_strategy: imperative challenge plus "then you coward"
examples:
- "Engineer me something then you coward"
activation:
allowed_when:
- friendly_roast_context
- target_has_claimed_competence
forbidden_when:
- unknown_relationship
- serious_conflict
requires_profile_authority: true
minimum_density: light
maximum_density: light
constraints:
register_contexts:
- casual
speaker_contexts:
- metame_or_metacrat_fixture
grammar_contexts:
- imperative_challenge
phonological_contexts: []
collocation_limits: []
incompatibilities:
- assistant_safety_softening_that_erases_banter
indexicality:
social_meanings:
- teasing
- status_play
- friendly_pressure
in_group_meanings: []
out_group_risks:
- may be hostile if context is not playful
stereotypes_to_avoid: []
evidence:
source_refs:
- discord:1023727427924545576
qdrant_source_ids:
- "1023727427924545576"
cached_source_refs: []
source_hashes: []
reviewer_refs: []
confidence: high
uncertainty:
open_questions: []
known_limits:
- Only safe inside clear banter.
trace:
trace_label: mock_challenge_coward
must_report_when_used: true
must_report_when_rejected: true
discourse_patterns:
- pattern_id: casual_floor_yield_with_lol
label: Casual floor yield with lol
status: draft
domain:
layer: discourse
category: turn_taking
subcategory: floor_yield
description: >
After extended riffing, the speaker can step back with "anyway imma
shut up now..." plus "lol", reducing authority while keeping warmth.
interactional_function: yield_floor_after_riff
sequence_shape:
- discourse_pivot_anyway
- self_silencing_imma_shut_up_now
- invite_others_to_invent
- lol_softener
adjacency_constraints:
- prior_turns_show_speaker_has_been_riffing
- other_participants_have_interest_or_pending_contribution
realization:
function: collaborative_turn_release
surface_strategy: "anyway imma shut up now and let you guys come up with your own shit lol"
examples:
- "anyway imma shut up now and let you guys come up with your own shit lol"
activation:
allowed_when:
- conversational_yield
- casual_collaborative_ideation
forbidden_when:
- formal_moderation
requires_profile_authority: true
minimum_density: light
maximum_density: light
constraints:
register_contexts:
- casual
speaker_contexts:
- metame_or_metacrat_fixture
grammar_contexts: []
phonological_contexts: []
collocation_limits: []
incompatibilities: []
indexicality:
social_meanings:
- self_limiting
- collaborative
- profane_warmth
in_group_meanings: []
out_group_risks:
- profanity may be inappropriate outside familiar room
stereotypes_to_avoid: []
evidence:
source_refs:
- discord:820077384572338176
qdrant_source_ids:
- "820077384572338176"
cached_source_refs: []
source_hashes: []
reviewer_refs: []
confidence: high
uncertainty:
open_questions: []
known_limits:
- Not a generic moderation pattern.
trace:
trace_label: casual_floor_yield_with_lol
must_report_when_used: true
must_report_when_rejected: true
orthography_features:
- feature_id: light_discord_informality
label: Light Discord informality
status: draft
domain:
layer: orthography
category: punctuation
subcategory: informal_case
description: >
Casual lines may use lower-case openings, sparse punctuation, emphasis
with markdown italics, and uncorrected informal contractions when the
social move benefits.
allowed_surface_forms:
- lower_case_opening
- markdown_italics_for_single_emphasis
- no_terminal_period
- imma
- lol
forbidden_surface_forms:
- heavy_eye_dialect_for_identity_caricature
- assistant_like_sentence_polishing
renderer_notes: >
Preserve compactness. Do not add terminal politeness, explanation, or
moral framing unless source meaning requires it.
realization:
function: casual_discord_texture
surface_strategy: allow sparse punctuation and compact informal markers
examples:
- "oof, don't tell me my discord has *engineer* vibes"
activation:
allowed_when:
- casual_discord_register
forbidden_when:
- formal_or_public_document_register
requires_profile_authority: true
minimum_density: light
maximum_density: normal
constraints:
register_contexts:
- casual
speaker_contexts:
- metame_or_metacrat_fixture
grammar_contexts: []
phonological_contexts: []
collocation_limits: []
incompatibilities: []
indexicality:
social_meanings:
- low_ceremony
- room_familiarity
in_group_meanings: []
out_group_risks:
- overuse becomes imitation instead of lowering
stereotypes_to_avoid:
- do_not_represent_protected_dialect_by_eye_dialect
evidence:
source_refs:
- discord:1037314100931338260
- discord:820077384572338176
qdrant_source_ids:
- "1037314100931338260"
- "820077384572338176"
cached_source_refs: []
source_hashes: []
reviewer_refs: []
confidence: medium
uncertainty:
open_questions:
- Need more examples to estimate density safely.
known_limits: []
trace:
trace_label: light_discord_informality
must_report_when_used: true
must_report_when_rejected: true
code_switching:
allowed: false
constraints:
- This profile does not authorize cross-language switching.
patterns: []
forbidden_flattenings:
- Do not ask Metame to imitate the final utterance directly; Persona produces interlingua intent.
- Do not store Metacrat biographical/cultural strata here; store reusable strata as separate culture/subculture profiles and reference them from Persona state.
- Do not let current machine-cult architecture register overwrite pre-2025 casual fixture style.
- Do not treat profanity or insults as safe without relationship/context evidence.
- Do not copy private Persona state into Weksa fixtures or run artifacts.
evidence_ledger:
- ref: discord:1037314100931338260
summary: Self-deprecating recoil after efficiency compliment.
- ref: discord:802911591288537118
summary: Fragmentary absurd comparison during sound/game design banter.
- ref: discord:1023727427924545576
summary: Very short mock-aggressive challenge in friendly roast context.
- ref: discord:820077384572338176
summary: Casual profane floor-yield after extended ideation.
- ref: discord:870907292558721085
summary: Explicit joke about being bad at self-deprecation.
- ref: discord:1020888211729432728
summary: Positive uptake when another participant joins cult jokes.

View file

@ -1,23 +1,24 @@
# Architecture
Weksa is the language-intent daemon surface: currently a state spine, pipeline
map, fixtures, and service contract waiting for its runtime loop.
Weksa is a language-intent engine: currently a state spine, pipeline map,
fixtures, ontology data and a planned service contract. No daemon runs; the
planned packet crate is `crates/weksa` (cut `packet-store`).
See [Verse Service Contract](./verse-service-contract.md) for the
CultCache/CultMesh/Eve authority map.
CultCache/CultMesh authority map.
## Objective
Build a procedural alien-language engine whose outputs are grounded in
worldview, semantics, grammar, phonology, morphology, and history. As a daemon,
Weksa also turns writing plus agent state into typed conversational intent, then
worldview, semantics, grammar, phonology, morphology, and history. Once a service is
earned (planned), Weksa also turns writing plus agent state into typed conversational intent, then
lowers that intent into written realizations, pronunciation plans, speech-provider
requests, and utterance handoff documents.
## Current Mechanism
There is no long-running runtime loop yet. The repo contains persistent state
surfaces and a provider advertisement fixture that define the intended data flow
surfaces that define the intended data flow
and the ownership boundaries future code must respect.
The first shared contract is the [Interlingua Standard](./interlingua-standard.md):

View file

@ -1,273 +0,0 @@
{
"schema": "gamecult.eve.provider_advertisement.v1",
"providerId": "weksa.intent.service",
"title": "Weksa Intent Service",
"description": "Weksa converts writing, interlingua, and agent state into typed conversational intent, target-language realizations, pronunciation plans, speech-provider requests, and utterance handoff documents for AquaSynth and other consumers.",
"mode": "fixture-read-only",
"serviceVerse": {
"id": "verse.weksa.service.v1",
"cultMeshKey": "weksa.service/provider-advertisement",
"authority": "Weksa daemon identity, schema catalog, and provider publication",
"owns": [
"typed conversational intent",
"pronunciation plans",
"utterance handoff documents",
"speech-provider request projections",
"lowering traces",
"language-project and flavor-profile state"
],
"doesNotOwn": [
"AquaSynth learned embeddings",
"Faust compilation",
"rendered audio",
"MiMo provider audio",
"Persona canonical state"
]
},
"nestedVerses": [
{
"id": "weksa.intent",
"kind": "intent",
"cultMeshKey": "weksa.intent/current",
"authority": "Weksa conversational intent compiler",
"schemas": [
"weksa.conversational_intent.v0"
]
},
{
"id": "weksa.pronunciation",
"kind": "pronunciation",
"cultMeshKey": "weksa.pronunciation/current",
"authority": "Weksa pronunciation lowering",
"schemas": [
"weksa.pronunciation_plan.v0"
]
},
{
"id": "weksa.utterance",
"kind": "utterance",
"cultMeshKey": "weksa.utterance/handoffs",
"authority": "Weksa utterance handoff export",
"schemas": [
"weksa.utterance_handoff.v0",
"weksa.utterance_embedding_handoff.v0.1"
]
},
{
"id": "weksa.speech_provider",
"kind": "speech_provider",
"cultMeshKey": "weksa.speech-provider/requests",
"authority": "Weksa speech-provider request projection",
"schemas": [
"weksa.mimo_tts_request.v0"
]
},
{
"id": "weksa.operator",
"kind": "operator",
"cultMeshKey": "weksa.operator/status",
"authority": "Weksa daemon runtime",
"schemas": [
"weksa.operator_state.v0",
"gamecult.eve.surface.v1"
]
},
{
"id": "weksa.cultural_ontology",
"kind": "cultural_ontology",
"cultMeshKey": "weksa.cultural-ontology/catalogs",
"authority": "Weksa cultural ontology profile store",
"schemas": [
"weksa.cultural_ontology_profile.v0",
"weksa.cultural_ontology_catalog.v0"
]
}
],
"schemaWitnessFamilies": [
{
"family": "intent",
"namespace": "weksa.intent.*",
"witnesses": [
"weksa.conversational_intent.v0"
],
"durableShape": ".weksa/intent/{intentId}.cc"
},
{
"family": "pronunciation",
"namespace": "weksa.pronunciation.*",
"witnesses": [
"weksa.pronunciation_plan.v0"
],
"durableShape": ".weksa/pronunciation/{utteranceId}.cc"
},
{
"family": "utterance",
"namespace": "weksa.utterance.*",
"witnesses": [
"weksa.utterance_handoff.v0",
"weksa.utterance_embedding_handoff.v0.1"
],
"durableShape": ".weksa/handoff/{utteranceId}.cc"
},
{
"family": "speech_provider",
"namespace": "weksa.speech_provider.*",
"witnesses": [
"weksa.mimo_tts_request.v0"
],
"durableShape": ".weksa/speech-provider/{requestId}.cc"
},
{
"family": "trace",
"namespace": "weksa.trace.*",
"witnesses": [
"weksa.lowering_trace.v0"
],
"durableShape": ".weksa/traces/{traceId}.cc"
},
{
"family": "cultural_ontology",
"namespace": "weksa.cultural_ontology.*",
"witnesses": [
"weksa.cultural_ontology_profile.v0",
"weksa.cultural_ontology_catalog.v0"
],
"durableShape": ".weksa/cultural-ontology/{storeId}.cc"
},
{
"family": "eve",
"namespace": "weksa.eve.*",
"witnesses": [
"gamecult.eve.provider_advertisement.v1",
"gamecult.eve.surface.v1"
],
"durableShape": ".weksa/eve-surfaces.cc"
}
],
"cultMeshSurfaceKeys": [
{
"id": "weksa.eve.intent_review.v0",
"documentSchema": "gamecult.eve.surface.v1",
"cultMeshKey": "weksa.eve.surface.intent-review",
"binds": [
"weksa.intent.*",
"weksa.trace.*"
],
"status": "expected-surface"
},
{
"id": "weksa.eve.pronunciation_trace.v0",
"documentSchema": "gamecult.eve.surface.v1",
"cultMeshKey": "weksa.eve.surface.pronunciation-trace",
"binds": [
"weksa.pronunciation.*",
"weksa.trace.*"
],
"status": "expected-surface"
},
{
"id": "weksa.eve.utterance_handoff.v0",
"documentSchema": "gamecult.eve.surface.v1",
"cultMeshKey": "weksa.eve.surface.utterance-handoff",
"binds": [
"weksa.utterance.*",
"aquasynth.speech.*"
],
"status": "expected-surface"
}
],
"commandBoundaries": [
{
"id": "weksa.command.intent.create",
"owner": "Weksa",
"acceptedByFixture": false,
"allowedInputs": [
"writing",
"interlingua packet",
"Persona/agent state projection"
],
"emits": [
"weksa.conversational_intent.v0",
"weksa.lowering_trace.v0"
]
},
{
"id": "weksa.command.utterance.lower",
"owner": "Weksa",
"acceptedByFixture": false,
"allowedInputs": [
"accepted conversational intent",
"language project",
"speaker state"
],
"emits": [
"weksa.pronunciation_plan.v0",
"weksa.utterance_handoff.v0"
]
},
{
"id": "weksa.command.handoff.export",
"owner": "Weksa",
"acceptedByFixture": false,
"emits": [
"weksa.utterance_embedding_handoff.v0.1"
],
"forbiddenTargets": [
"Faust compilation",
"audio rendering",
"AquaSynth synth driver truth"
]
},
{
"id": "weksa.command.speech_provider.project",
"owner": "Weksa",
"acceptedByFixture": false,
"allowedInputs": [
"accepted target-language realization",
"provider-neutral delivery intent",
"speech provider profile"
],
"emits": [
"weksa.mimo_tts_request.v0"
],
"forbiddenTargets": [
"source interlingua truth",
"Persona canonical state",
"rendered audio ownership"
]
},
{
"id": "weksa.command.speech_provider.mimo.voicedesign",
"owner": "Weksa",
"acceptedByFixture": true,
"commandIngress": "cultnet.transport.rudp.v0",
"inputSchema": "weksa.mimo_voicedesign_command.v0",
"allowedInputs": [
"Persona/agent state file path",
"weksa.interlingua_packet.v0-compatible object",
"raw thought text for provisional interlingua decomposition",
"optional target-language and performance-register controls"
],
"emits": [
"weksa.interlingua_packet.v0-daemon-provisional",
"weksa.mimo_tts_request.v0",
"weksa.mimo_voicedesign_receipt.v0",
"rendered audio artifact reference"
],
"forbiddenTargets": [
"Persona canonical state mutation",
"source meaning rewrite after accepted interlingua",
"MiMo-owned text optimization",
"AquaSynth embedding truth"
]
}
],
"loweringRules": [
"Weksa owns conversational intent and utterance lowering.",
"Speech-provider requests are target projections from accepted realizations, not source meaning.",
"AquaSynth owns learned embeddings, synth controls, Faust compilation, and audio output.",
"MiMo owns provider rendering and audio output, not Weksa delivery truth.",
"Eve surfaces submit commands and inspect witnesses; they do not own intent truth.",
"Fixture exports are discovery metadata until the daemon loop commits typed witnesses."
]
}

View file

@ -1,6 +1,6 @@
# Interlingua Standard
Version: `0.1-draft`
Version: `0.2-draft`
The Weksa interlingua is the language-neutral meaning contract between clients,
setting surfaces, authoring tools, language projects, ontology projection, and
@ -82,235 +82,174 @@ Those belong downstream.
## Packet Shape
The canonical packet is an object with these top-level families:
The Rust types in [`crates/weksa/src/packet.rs`](../crates/weksa/src/packet.rs)
are the definition; the JSON Schemas in [`schemas/cultnet/`](../schemas/cultnet/)
are generated from them and a test fails when they drift. A packet is the
CultCache document `weksa.interlingua_packet`. This section describes the shape;
where it and the types disagree, the types win.
```yaml
interlingua_version:
packet_id:
kind:
provenance:
context:
discourse:
referents:
predications:
relations:
constraints:
extensions:
trace:
Two layers carry the standard. The **schema** is the Rust types: the packet
families, node ids, slots and value kinds. It changes by commit and rarely. The
**inventory** is data: the concepts, frames, roles, relation types, speech acts
and features that the schema's slots name. It is stored as
`weksa.inventory_entry` documents under appended `weksa.inventory_revision`
records, never edited in place (a changed meaning is a retire plus an add), and
a retired id still resolves for packets of earlier revisions.
Every id is an opaque form type, parsed when it is built and when a store
reads it: an inventory id is `<family>:<n>` with `n` a canonical decimal of at
least 1 (`role:1`; `role:01` is not an id), a node id is `n<k>` with `k` in
1..=9999, a packet id is `packet:<16 hex>` and an encoder id is
`encoder:<16 hex>`, an entity id is `entity:<slug>` (`[a-z0-9]` and inner
`-`, 1 to 48 bytes) and a corpus item id is `item:<slug>:<n>`. The only free
text a packet holds is the `trace` (`notes` and `rejected_readings`), which the
validator neither bounds nor reads and which is stripped before lowering: a
referent names its entity by id, and the entity store (`weksa.entity`) holds
the entity's forms per language. The packet store refuses a referent whose
entity the store lacks, a packet whose `corpus_item` the corpus lacks, and one
whose entity is neither named on that item nor its speaker or a prior-turn
speaker (a packet without a corpus item has no door in this store):
an id-set check, not a text rule. Leakage of scored text is owned outside the
validator, by the loop's context builder (what each model call sees) and the
scorer; the validator owns form and capacity. The trace is stripped before
lowering. Concept ids are opaque and carry a gloss in the inventory; concepts
are open-class and grow, while the other families are closed-class.
```text
Packet
id, schema_version, inventory_rev, kind
provenance { source, corpus_item, encoder, confidence }
context { setting, medium, participants, audience }
discourse { speech_act, addressee, features }
referents [ { id, kind, concept, entity, features } ]
predications [ { id, concept, frame, roles: [ { role, filler } ], features } ]
relations [ { relation, from, to, features } ]
constraints { preserve, deterministic, max_latency_ms }
extensions [ { key: InvId (family extension), value: Grade | Option | Flag } ]
trace { notes, rejected_readings }
stance { features }
```
Only `interlingua_version`, `packet_id`, `kind`, and at least one semantic
payload family are required. The rest should be present when known.
`schema_version` and `inventory_rev` name the exact schema and inventory
revision a packet was written against. A grade is an integer permille,
`0..=1000`, and the published schema says so. There are no floats.
## Top-Level Fields
### Validation
A packet exists only if the validator's rules pass and the packet store admits
it. `validate` judges and returns `Ok(())` or the refusals; the packet store's
`admit` is the one door and the only source of the lowering view. A refusal
names the rule.
- V1: `schema_version` is the version these types define.
- V2: at least one of `referents`, `predications`, `relations` is present.
- V3: a node id appears once across referents and predications.
- V4: every role filler, relation end, addressee, participant, audience member
and preserved node names a node.
- V5: every inventory id resolves at the packet's `inventory_rev`: introduced
at or before it, and not retired at or before it.
- V6: an inventory id is of the family its slot takes.
- V7: a feature or extension value fits its entry's value kind (a grade is a
grade, an option is one the entry declares, a flag is a flag).
- V8: a feature sits only in a slot its entry declares.
- V10: `inventory_rev` has a revision record.
- V11: a feature id appears at most once in one slot's feature list, an
extension id at most once per packet, a node id at most once in each of
`participants`, `audience` and `preserve`, and a role id at most once in one
predication's roles.
- V12: a packet has at most 64 referents, predications and relations together
(a calibration constant, capacity and not meaning).
V9 is retired and not renumbered: the form of a key or a grade is its type, and
an extension key's family is V6. `PacketStore::admit` returns the lowering view of a
packet it admits; the view carries the meaning and the stance, no packet id, no
provenance and no trace, and is built nowhere else. The store also refuses a
packet that names a withdrawn corpus item (packets admitted before the
withdrawal stay stored). A packet is admitted against the inventory the packet store
reads at the packet's revision, never one the caller supplies.
### `kind`
The packet's broad use case.
Initial values:
- `utterance`
- `name_request`
- `commentary_intent`
- `narration`
- `dialogue_line`
- `corpus_example`
- `debug_probe`
`Utterance`, `NameRequest`, `CommentaryIntent`, `Narration`, `DialogueLine`,
`CorpusExample`, `DebugProbe`.
### `provenance`
Where the packet came from.
```yaml
provenance:
source_type: authored | model_decomposition | game_telemetry | imported_corpus
source_ref:
author:
created_at:
confidence:
```
`source` is `Authored`, `ModelDecomposition`, `GameTelemetry` or
`ImportedCorpus`. `corpus_item` is the required `item:<slug>:<n>` key of the
corpus item the packet was minted for, `encoder` names what wrote it, and `confidence` is a grade.
### `context`
World and situation information that is not itself the main semantic claim.
```yaml
context:
setting:
scene:
time:
location:
participants:
audience:
medium:
```
`setting` and `medium` are concept ids. `participants` and `audience` are node
ids.
### `discourse`
Why this packet is being said, rendered, or named.
Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id,
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.
```yaml
discourse:
speech_act:
intent:
tone:
register_hint:
addressee:
audience_function:
```
### `stance`
This is not target-language register. It is source-side communicative pressure.
The speaker's felt affect and how much of it the speaker holds back. `features`
is a feature list in slot `Stance`, checked like every other (V5 to V8, V11).
The seed carries felt affect as grades of how strongly it is felt (anger, fear, sadness,
joy, disgust, surprise, contempt, affection, shame) and `restraint`, a grade for
how much of the felt affect is suppressed (0 none, 1000 fully). The stance is
what is felt, never what is displayed: how a feeling shows, and how much of a
restrained one leaks, are cultural, and each language's ontology maps felt
affect and restraint to its own register. A held-back feeling is not a claim
about the world, so it is never a predication. An empty stance is valid. The
stance reaches the lowerer in the lowering view.
### `referents`
Entities, groups, locations, abstract objects, institutions, ecological actors,
routes, memories, artifacts, and other nodes that can participate in meaning.
```yaml
referents:
- id:
kind:
label:
ontology_hint:
animacy:
agency:
number:
definiteness:
salience:
attributes:
extensions:
```
Initial broad kinds:
- `person`
- `group`
- `organism`
- `place`
- `artifact`
- `institution`
- `ecology`
- `route`
- `memory`
- `event`
- `abstract`
Entities, groups, places, abstractions and the rest. `kind` is a referent-kind
id (the inventory seeds `person`, `group`, `organism`, `place`, `artifact`,
`institution`, `ecology`, `route`, `memory`, `event`, `abstract`), `concept` is
a concept id, and `entity` is the id of the named entity when the referent has one.
### `predications`
Events, states, processes, properties, and frame-like scenes.
```yaml
predications:
- id:
predicate:
frame:
aspect:
tense:
polarity:
modality:
evidentiality:
roles:
agent:
patient:
experiencer:
instrument:
source:
goal:
location:
beneficiary:
attributes:
extensions:
```
`predicate` is a broad source-side claim. `frame` is optional but recommended
when a scene model is known. Roles may use common names or namespaced frame
roles.
Events, states, processes and scenes. `concept` and `frame` are ids; `roles`
pair a role id with a filler node (the inventory seeds `agent`, `patient`,
`experiencer`, `instrument`, `source`, `goal`, `location`, `beneficiary`). Tense,
aspect, polarity, modality and evidentiality are features with declared option
sets, not fields.
### `relations`
Edges that are not naturally owned by one predication.
```yaml
relations:
- type:
from:
to:
attributes:
extensions:
```
Initial relation families:
- `coreference`
- `part_of`
- `member_of`
- `located_at`
- `near`
- `before`
- `after`
- `causes`
- `enables`
- `prevents`
- `owns`
- `owes`
- `knows`
- `names`
- `represents`
- `contrasts_with`
Edges not owned by one predication. `relation` is a relation id (the inventory
seeds `coreference`, `part_of`, `member_of`, `located_at`, `near`, `before`,
`after`, `causes`, `enables`, `prevents`, `owns`, `owes`, `knows`, `names`,
`represents`, `contrasts_with`), and `from` and `to` are node ids.
### `constraints`
Hard requirements the downstream renderer must respect.
```yaml
constraints:
preserve_referents:
preserve_relations:
required_outputs:
forbidden_outputs:
max_latency_ms:
deterministic:
```
These are engineering and content constraints, not ontology rules.
`preserve` lists nodes the lowering must keep, `deterministic` and
`max_latency_ms` bound the renderer. Requirements about which outputs a
renderer must or must not produce belong to the target profile, not the packet.
### `extensions`
Namespaced setting or client data.
```yaml
extensions:
zyphos.memory_relation:
zyphos.boundary_permeability:
rtd.aesthetic_reading:
game.priority:
```
Extension rules:
- Namespaces must be explicit.
- Extensions must not replace common fields when common fields are sufficient.
- A downstream language project may choose to ignore an extension.
- If an extension becomes broadly required across clients, promote it into the
standard in a later version.
Setting or client data. An extension key is an inventory entry of family
`extension` (`extension:<n>`) whose entry declares the value kind, so a key is
an id and not a string. Extensions must not replace common fields when common
fields suffice, a language project may ignore them, and one that becomes
broadly required is promoted into the inventory or the schema.
### `trace`
Optional authoring/debug data.
```yaml
trace:
notes:
decomposition_steps:
uncertainty:
rejected_readings:
```
Runtime may strip `trace` from compiled packets when latency or size matters.
Authoring and debug notes. The trace is stripped before lowering: the lowering
input type has no trace field.
## Minimal Examples
These examples illustrate meaning in the 0.1 shape, with free-text labels and
predicates. Those fields no longer exist; the examples are not valid packets.
### Name Request
```yaml

View file

@ -1,158 +0,0 @@
# MiMo TTS Lowering
Version: `0.1-draft`
MiMo VoiceDesign is an expressive spoken-output target for Weksa interlingua
lowering. It is not the source of meaning, language choice, character state, or
delivery truth.
```text
interlingua packet
-> projected speaker-local context
-> target-language utterance realization
-> Weksa delivery intent from the same projection
-> MiMo TTS VoiceDesign request projection
-> rendered audio artifact
```
MiMo sits beside written output and AquaSynth handoffs. Written realizations
prove that meaning, register, and cultural ontology survived lowering.
AquaSynth handoffs train or drive GameCult-owned synthesis. MiMo VoiceDesign
requests let an external provider speak a Weksa-authored utterance with a
generated voice derived from the same projected Persona state, delivery controls,
and trace that produced the utterance.
The MiMo pass is choreography and provider flavor. It is not a second Persona
projection pass and it does not re-interpret raw Persona state after the line has
already been lowered.
## Authority
Weksa owns:
- target-language spoken text selected from interlingua
- target-language register, script, cultural ontology, and trace
- projected speaker-local context used for the utterance
- speaker-local delivery pressure derived during utterance lowering
- provider-neutral prosody and emphasis intent
- the MiMo request projection as an export artifact
MiMo owns:
- generation of a provider voice from the requested voice design description
- interpretation of MiMo natural-language voice/style guidance
- interpretation of MiMo inline audio/style tags
- rendered audio bytes or streaming audio chunks
MiMo does not own:
- source meaning
- target-language cultural ontology
- canonical speaker or Persona state
- fresh Persona-state interpretation
- Weksa pronunciation plans
- AquaSynth learned embeddings, synth controls, or audio truth
## Projection Rule
The MiMo request must be derived from an accepted target-language realization and
the same projected speaker-local context that created it. It must not lower
directly from English unless English is the selected target language for that
realization. It must not read raw Persona state to discover a different voice
after Weksa has already chosen the utterance.
Natural-language voice design and style guidance belongs in the MiMo `user`
message. The spoken target-language text belongs in the MiMo `assistant`
message. Inline MiMo tags may appear in the assistant content only when they are
derived from Weksa delivery intent or explicit target-language realization notes.
## Request Shape
```yaml
schema_version: weksa.mimo_tts_request.v0
source_realization_ref:
speaker_agent_id:
provider:
id: xiaomi-mimo
model: mimo-v2.5-tts-voicedesign
target_language:
code:
locale:
script:
register:
performance_register:
label:
medium:
delivery_archetype:
voice_design:
description:
source_controls:
projected_context_refs:
forbidden_traits:
messages:
user_voice_design_instruction:
assistant_spoken_content:
audio:
format:
optimize_text_preview:
streaming:
trace:
source_fields:
delivery_controls_used:
provider_tags_used:
uncertainties:
```
## Delivery Mapping
`target_language.register` is the linguistic/social register of the accepted
realization. It is not a provider style knob. For MiMo, Weksa should derive an
explicit `performance_register` from the same projection so speech choreography
can say "anime visual-novel heroine warning" without pretending English itself
has that register.
Weksa should map the utterance's existing projected Persona pressure into
provider-neutral delivery labels before emitting MiMo-specific instructions:
- pace, pause pressure, and urgency
- pitch contour and pitch range
- loudness and attack
- articulation precision
- warmth, threat, tenderness, contempt, play, fatigue, secrecy, formality
- span-level emphasis and pauses
The MiMo projection may turn those labels into a concise VoiceDesign prompt or
inline tags, but the trace must preserve the Weksa-owned source controls.
Persona presentation details such as youth-coded, loli-coded, product-voice, or
other culturally loaded styling may enter the MiMo request only when they are
already present in the projected context or accepted delivery intent. They should
be described as presentation/cultural coding and kept subordinate to agency,
personality, affect, and scene purpose. They must not become sexualized content
or a generic anime voice pasted over the line.
For Weksa-authored dialogue, `optimize_text_preview` should normally be `false`
or omitted. MiMo should speak the accepted target-language realization, not
rewrite it. Text polishing is only allowed for explicit preview/prototyping
passes that are marked non-authoritative.
## Language Rule
Weksa should be able to write and speak in any target language whose utterance
realization it can justify. MiMo output for Brazilian Portuguese should consume
the accepted `pt-BR` spoken text. Japanese output should consume accepted
Japanese text. Alien-language output should consume accepted alien rendered
speech or phonetic text only after the language project owns that surface.
Backtranslations are reviewer aids. They must not become MiMo spoken content.
## First Proof
The first proof should use the Nibu warning packet already used by multilingual
lowering:
- emit the accepted English, `pt-BR`, or Japanese realization
- derive a provider-neutral delivery intent from Nibu's projected context
- project that into a MiMo `mimo-v2.5-tts-voicedesign` request
- verify that the request speaks the target text, not the backtranslation
- verify that style guidance is traceable to Weksa delivery controls

View file

@ -180,9 +180,10 @@ meaning sources. A provider request must consume the target-language
`spoken_text` that Weksa already accepted and must carry a trace back to the
interlingua packet, projected speaker context, and target-language profile.
MiMo V2.5 TTS is the first external provider target. Its natural-language style
MiMo V2.5 TTS is a candidate external provider target; its lowering path is
parked at tag `parked/weksa-node-body`. Its natural-language style
instruction is a projection of Weksa delivery intent, and its assistant content
is the selected target-language spoken text. See [MiMo TTS Lowering](./mimo-tts-lowering.md).
is the selected target-language spoken text.
AquaSynth remains the GameCult-owned synthesis path for learned utterance
embeddings and synth-control work. MiMo and AquaSynth are sibling target

View file

@ -1,212 +0,0 @@
# Persona Fine-Tuning Dataset Exploration
This note profiles the Discord archive as a candidate source for fine-tuning the
Metame/Metacrat lowering pipeline.
## Pipeline Target
The live architecture should not fine-tune a model to directly impersonate from
raw chat history.
The training shape should stay aligned with Weksa:
1. Persona state and current situation produce interlingua intent.
2. Persona state references a culture/subculture stack.
3. Weksa lowers interlingua through that culture stack into a flavored
utterance.
Useful datasets therefore split into two families:
- Persona intent extraction: current context plus Persona state projection to
interlingua intent.
- Weksa lowering: interlingua plus culture stack to actual utterance.
Direct `context -> utterance` rows can be used as a baseline or critic set, but
they should not become the canonical architecture.
## Corpus Snapshot
Profiled archive:
`E:/Projects/VoidBot/.voidbot/rag/messages.json`
Author:
`113785782975594501`
Cutoff:
`2025-01-01T00:00:00Z`
Current aggregate counts:
- archive messages: 40,657
- usable text messages: 39,508
- authored Metacrat messages: 19,385
- authored Metacrat messages before 2025: 18,049
- crude pre-2025 casual candidates: 16,499
- pre-2025 authored messages with recent same-channel context: 16,530
- adjacent reply-to-other rows: 7,894
- adjacent self-continuation rows: 8,636
Length distribution for authored Metacrat messages:
- p10: 14 chars
- p25: 30 chars
- median: 56 chars
- p75: 101 chars
- p90: 166 chars
Adjacent candidate length classes:
- short: 11,430
- medium: 4,535
- long: 565
The corpus is large enough for held-out evaluation splits. It is also uneven:
2020 and 2021 dominate the pre-2025 archive.
## Dominant Channels
Top adjacent candidate channels:
- `general`: 6,286
- `development`: 2,911
- `narrative`: 2,169
- `management`: 1,103
- `website`: 753
- `soundtrack`: 437
- `gamedesign`: 433
- `programming`: 408
- `prose`: 320
- `politics`: 291
- `pics`: 273
- `advertising`: 115
These should become strata, not one blended dataset. A single mixed fine-tune
will learn that legal explanations, horny little design jokes, political
argument, and casual encouragement all belong in the same response slot. That
is how the soup gets teeth.
## Candidate Row Types
For Weksa lowering:
```yaml
schema_version: weksa.finetune.lowering_row.v0
row_id:
split:
target_message_id:
target_text:
interlingua_intent:
culture_stack_refs: []
target_overlay_refs: []
context_window:
before: []
after: []
labels:
channel:
year:
length_class:
speech_act:
register:
reply_to_other:
```
For Persona intent extraction:
```yaml
schema_version: weksa.finetune.persona_interlingua_row.v0
row_id:
split:
persona_state_ref:
projected_context_ref:
current_situation:
target_message_id:
derived_interlingua:
derivation:
reviewed:
model:
evidence_refs: []
```
The target utterance should be hidden from the Persona-intent generator during
inference. For training, target utterance may be used to derive a reviewed
interlingua label, but that label must be marked as derived from answer-key
surface evidence.
## Split Policy
Recommended first split:
- train: 2018-2021
- validation: early/mid 2022
- test: late 2022 plus selected hand-reviewed fixtures
- quarantine: 2023-2026 until the current post-2025 voice target is deliberate
Also reserve channel-level holdouts:
- one casual/social channel
- one technical channel
- one political/management channel
This catches overfitting to one room's register.
## Filters
Start with conservative row filters:
- target author is Metacrat
- target timestamp before 2025
- target is not deleted
- target is not a bot prompt
- target text length between 3 and 220 chars for casual lowering
- same-channel prior context exists within 60 minutes
- exclude pure URLs, code blocks, and attachment-only rows
- retain but label profanity, mentions, quotes, and markdown emphasis
Do not discard long messages globally. Keep them in a separate explanatory-prose
slice.
## Next Work
1. Build an exporter that writes JSONL rows without leaking private Persona
projection into git.
2. Add labelers for speech act, register, channel stratum, and culture-stack
refs.
3. Use a model-assisted pass to derive interlingua labels from held-out target
utterances, then sample-review before training.
4. Fine-tune or train adapters separately for Persona intent extraction and
Weksa lowering.
5. Keep a frozen golf set where answer-key utterances are redacted from all
culture/profile prompt material.
Reproduce the aggregate profile:
```powershell
node scripts/profile-metame-finetune-dataset.mjs
```
## Swarm Exploration
Use the swarm explorer to chase high-loss moments instead of uniform random
examples:
```powershell
node scripts/run-metame-swarm-explorer.mjs --agents 4 --seed metame-scout
node scripts/run-metame-swarm-explorer.mjs --real --agents 4 --seed metame-scout
```
The default mode is a dry run. Real mode runs Codex workers:
1. select one timeline candidate per agent using underexplored strata, feature
novelty, prior loss, and seeded jitter
2. run each Face normally against its assigned scenario
3. run an Interpreter pass that converts the Face output to Weksa interlingua
4. lower that interlingua with Weksa and score against the hidden utterance
5. run an optimizer pass over high-loss outputs
6. rerun the same scenarios once per agent after optimization
Artifacts are written under `.weksa-runs/metame-swarm/`, which is ignored by
git because run packets may include projected Persona memory and raw Discord
context.

View file

@ -0,0 +1,88 @@
# Prior art for the Weksa golf loop: metric, leakage, corpus, 2026-10-29
Eyes pass, campaign `weksa-lowering`. Facts and pointers only; no recommendation. Tags: [V] read this pass (page or search result returned it; "search-only" = seen only in a search summary, not the paper); [B] background, not re-verified; [GAP] looked for, not found. Several arXiv PDFs came back as unreadable binary; where so, the HTML/abs version was used or the claim is downgraded. Not re-covered: UNL, KANT, GF, AMR, Abstract Wikipedia, LLM AMR parsing (see docs/research/2026-10-29-interlingua-lowering-prior-art.md).
## 1. Metrics for meaning AND nuance
### 1.1 Metric vs human correlation, measured
- Briakou, Agrawal, Tetreault, Carpuat, EMNLP 2021, multilingual formality transfer (en, it, br-pt, fr). [V via ar5iv] https://ar5iv.labs.arxiv.org/html/2110.10668 ; https://aclanthology.org/2021.emnlp-main.100
- Meaning preservation, Spearman rho vs human, en/it/pt/fr: chrF 0.59/0.67/0.70/0.75; BERTScore 0.59/0.62/0.65/0.68; METEOR 0.54/0.58/0.64/0.60; s-BLEU (vs input) 0.48/0.59/0.67/0.66; r-BLEU (vs reference) 0.31/0.00/0.05/0.12; zero-shot XLM-R STS ~0.65-0.68 (it/pt/fr). Authors recommend dropping reference-BLEU.
- Fluency: XLM-R pseudo-log-likelihood best (0.50/0.46/0.55/0.61), "moderate"; KenLM 0.27-0.45.
- Formality (style strength): XLM-R regression, zero-shot cross-lingual, rho 67/72/59/61 (en/pt/fr/it); mBERT lower; binary classifiers over-predict "formal" outside English, authors recommend regression, not classification.
- System-level: human and automatic metrics do not always agree on best system; r-BLEU got 30/40 pairwise system comparisons right vs chrF 25/40 (authors: suggestive only).
- Not evaluated there: BLEURT, COMET, MAUVE. Only one style axis (formality), a single sentence-level task; no hedging, humour, affect, stance.
- Mir, Felbo, Obradovich, Rahwan, NAACL 2019 "Evaluating Style Transfer for Text" (Yelp sentiment). Three axes: style-transfer intensity, content preservation, naturalness. Proposed: Word Mover's Distance on style-masked text for content; direction-corrected EMD for intensity; adversarial classifier for naturalness; models show trade-offs, so compare at points on a trade-off curve. [V search-only] https://aclanthology.org/N19-1049/ . Correlation numbers [GAP: PDF not read].
- Briakou et al. GEM 2021 "A Review of Human Evaluation for Style Transfer": surveys 97 papers' human-evaluation practice. [V search-only] https://aclanthology.org/2021.gem-1.6
- BERTScore (Zhang et al. 2020) claims better human correlation than n-gram metrics and robustness on PAWS adversarial paraphrase [V search-only] https://arxiv.org/abs/1904.09675 . BLEURT is a learned metric trained on human ratings; the paper notes learned metrics inherit training-data bias [V search-only] https://arxiv.org/pdf/2004.04696 .
- On Evaluation Metrics for Paraphrase Generation (2022): most common metrics align poorly with human annotation; embedding metrics give very high scores to a candidate identical to the input; reference-free variants align better than reference-based. [V search-only] https://arxiv.org/pdf/2202.08479
- MAUVE (Pillutla et al. 2021): distribution-level divergence between model text and human text in quantised embedding space; authors claim human-judgment correlation on three open-ended tasks. It scores a set of texts, not a pair. [V abstract] https://arxiv.org/abs/2102.01454 . No figures read.
- COMET: no source read this pass. [GAP]
- Metric-vs-human numbers for hedging, humour, affect, stance, politeness-as-nuance, persona voice: [GAP] none found. Nearest: formality (Briakou above). Also "Human Judgement as a Compass to Navigate Automatic Metrics for Formality Transfer" (2022), title only [V] https://ar5iv.labs.arxiv.org/html/2204.07549 .
### 1.2 Style / authorship embeddings
- STEL (Wegmann & Nguyen, EMNLP 2021): similarity-based style evaluation over four dimensions (formal/informal, simple/complex, contraction, number substitution) with content-controlled sentence pairs; BERT-based methods beat simple style measures. [V search-only] https://arxiv.org/abs/2109.04817 . Human-baseline and accuracy numbers [GAP: PDF unreadable].
- Wegmann, Schraagen, Nguyen 2022 "Same Author or Just Same Topic?": authorship-verification-trained representations may encode content; controlling for conversation labels gives more content-independent style representations than domain control or none. [V abstract] https://arxiv.org/abs/2204.04907 . STEL-or-Content benchmark introduced there.
- StyleDistance (2024): says LUAR is sometimes used for automatic style-transfer evaluation, does not explicitly train for content independence; claims lead among content-independent style embeddings on STEL and STEL-or-Content. [V search-only] https://arxiv.org/pdf/2410.12757 . LUAR paper itself (Rivera-Soto et al., EMNLP 2021) [B], not fetched. Per-model numbers [GAP].
- STEB: Style Text Embedding Benchmark (2026) notes LUAR is evaluated only on authorship retrieval. [V search-only] https://huggingface.co/papers/2606.31741.md
- Style embeddings tested against persona-voice/hedging/humour: [GAP].
### 1.3 LLM-as-judge and its biases
- Zheng et al. 2023 (MT-Bench/Chatbot Arena) [V via arxiv html] https://arxiv.org/abs/2306.05685 : position-bias consistency (default prompt) Claude-v1 23.8%, GPT-3.5 46.2%, GPT-4 65.0%; repetitive-list verbosity attack failure Claude-v1 91.3%, GPT-3.5 91.3%, GPT-4 8.7%; self-enhancement: GPT-4 about +10 points win rate for own answers, Claude-v1 about +25, authors say data too limited to confirm; GPT-4 vs human agreement 85% (ties excluded) vs human-human 81-82%; 66% counting ties.
- Wataoka et al. 2024 "Self-Preference Bias in LLM-as-a-Judge" [V] https://arxiv.org/html/2410.21819v1 : eight models, ~33,000 Chatbot Arena dialogues; GPT-4 largest bias (equal-opportunity metric 0.520; demographic-parity 0.749); judges favour lower-perplexity text regardless of authorship; authors hypothesise familiarity drives self-preference.
- Li et al. 2025 "Preference Leakage": contamination when the data-generating and judging LLM are the same, inherited, or same family; judges favour related students; harder to detect than earlier biases. [V abstract] https://arxiv.org/abs/2502.01534
- TwinVoice (persona simulation benchmark): judge-vs-human Spearman 0.591 for scoring vs 0.605 human-human; Cohen kappa 0.646 vs 0.673 for ranking. [V search-only] https://arxiv.org/pdf/2510.25536
- "Can LLM be a Personalized Judge?": low agreement with human ground truth on PRISM/OpinionQA/empathetic dialogue; ~80% on a high-confidence subset. [V search-only] https://arxiv.org/pdf/2406.11657
- Secondary-source claims (blogs): 20-40% of verdicts flip on swap for closely matched pairs; up to 40% swing across prompt templates. [V search-only, practitioner blogs, uncontrolled] e.g. https://tianpan.co/blog/2026-04-27-llm-judge-bias-audit-length-position-format . Treat as unverified.
- Pairwise vs pointwise stability, per a glossary page: absolute scores fluctuate more across judge models than pairwise. [V search-only, low authority] https://howaiworks.ai/glossary/llm-as-a-judge
- Judge agreement specifically on register/hedging/humour/affect with numbers: [GAP].
## 2. Goodhart and leakage in round-trip / autoencoder setups
- Round-trip fidelity caveats already catalogued in the prior-art note section 2 (consistency not accuracy; same model both ways inflates). Not repeated.
- Self-bias in model-built benchmarks: Xu et al. 2025 (MT as testbed): generated test data and the evaluating LLM each contribute self-bias, combining amplifies it; "each model ranks itself first"; stylistic homogeneity persists despite diversity controls; source-text diversity (their diversity metric) partly mitigates; also appears in open-ended generation (Chatbot Arena). [V abstract] https://arxiv.org/abs/2509.26600 (EMNLP 2026 version https://aclanthology.org/2026.emnlp-main.629.pdf). Silencer (Yuan et al. 2025) weights generated benchmarks using heterogeneity across multiple generators. [V search-only] https://www.arxiv.org/pdf/2505.20738
- LLM bottleneck/autoencoder leakage of surface form: no paper found on an encoder smuggling target surface form through a natural-language/structured bottleneck, nor on paraphrase-invariance tests for it. [GAP]. Adjacent: a paper proposing separate "Exact Reconstructive Effectiveness" and "Semantic Reconstruction Effectiveness" for LLM compression (separates verbatim recovery from preserved intent) [V search-only, paper not identified; see https://cdnjs.deepai.org/publication/semantic-compression-with-large-language-models]; ICAE/gist-token compression papers [V search-only] https://arxiv.org/abs/2505.17117 .
- Emergent communication [mostly V search-only]:
- Keresztury & Bruni 2020: deep-learning solutions often lack abstract reasoning; compositionality linked to out-of-distribution generalisation. https://arxiv.org/abs/2001.08618 [V abstract]
- Li & Bowling NeurIPS 2019: resetting listeners (ease-of-teaching) pushes emergent languages toward more structure; reviewers doubted transfer to complex settings. https://arxiv.org/abs/1906.02403 [V]
- ACL 2026 long paper proposes a bijectivity-based metric claimed to track whether agents can speak/hear an emergent language better than topographic similarity (Brighton & Kirby 2006) or bag-of-symbols disentanglement (Chaabouni et al. 2020). https://aclanthology.org/2026.acl-long.1389.pdf [V search-only]
- CELEBI (NeurIPS 2025): discrete bottleneck + iterated learning, reports gains in reconstruction and topographic similarity on Shapes3D/MPI3D. https://neurips.cc/virtual/2025/poster/115077 [V search-only]
- Chaabouni et al. 2020 "Compositionality and generalization in emergent languages" (ACL) [B]; not fetched. No source found tying emergent-communication leakage directly to LLM agents using natural-language packets. [GAP]
- Defences named in sources found: judge and generator from different model families, check item overlap with generator training data (search summary of the self-bias literature); multi-generator weighting (Silencer). Vocabulary restriction and paraphrase-invariance as leakage defences: [GAP: no source].
## 3. Iterating a schema / spec against a metric
- OPRO (Yang et al., Large Language Models as Optimizers): the base setup uses no validation set; training accuracy often 5-20 points above test; suggests more training data and early stopping. [V via search summary] https://arxiv.org/pdf/2309.03409 . A follow-up (Learning from Contrastive Prompts) reports it holds out a validation set and shows less overfitting than OPRO. https://arxiv.org/pdf/2409.15199 [V search-only]
- TextGrad (Yuksekgonul et al., arXiv 2406.07496): per a 2026 evaluation paper, each step is followed by a validation step and the proposed instruction is kept only if it scores higher on validation; MIPRO (DSPy) run with capped 200 train / 300 validation samples. https://arxiv.org/pdf/2604.27637 "Optimization before Evaluation" [V search-only]. TextGrad's own text on this: [GAP: paper not read].
- DSPy docs: separates train / validation / held-out test; says for prompt optimisers to start with 20% train and 80% validation (opposite of neural-net practice). [V search-only; my direct fetch of dspy.ai returned only a redirect link] https://dspy.ai/learn/optimization/overview
- LLM-AutoDiff compares against MIPROv2 with best-validation checkpoint chosen before test. https://arxiv.org/pdf/2501.16673 [V search-only]
- Growing a meaning-representation inventory from failures: [GAP] nothing on UMR/AMR guideline revision driven by generation failures. Found only: FrameNet lexical-unit induction (Pennacchiotti et al. EMNLP 2008, distributional + WordNet; release then 795 frames, ~10,000 LUs) https://aclanthology.org/D08-1048 ; NutFrame LREC-COLING 2024, LLM frame/FE/relation induction from FrameNet, "remains difficult" https://aclanthology.org/2024.lrec-main.1079 ; Annotating FrameNet via structure-conditioned LM generation (2024) https://arxiv.org/html/2406.04834v2 ; 2025 ACL-SRW frame induction on C4 web text. All [V search-only].
- Weksa's own promotion rule (new fields as namespaced extensions, promoted after use in 2 real packets) is in interlingua-standard.md [V, prior note]. Overfitting defences specific to spec-growth loops: [GAP].
## 4. Corpora and licences
Verified licence facts are few. Discord data excluded by policy; not read.
- DailyDialog: CC BY-NC-SA 4.0; 13,118 dialogues (11,118/1,000/1,000). [V HF page] https://huggingface.co/datasets/li2017dailydialog/daily_dialog . Non-commercial plus share-alike; whether that bars model training in this campaign is a reading not made here.
- PersonaChat (Zhang et al. 2018): ParlAI project page, archived 2020-08-01, states no licence, only "You may continue to use the data without issue". [V] https://parl.ai/projects/personachat/ ; paper https://arxiv.org/abs/1801.07243 says data released open source via ParlAI; licence text [GAP]. Crowdworker-written, persona-statement-conditioned chit-chat [B].
- LIGHT: ParlAI page states no licence; contents: 663 locations, 3,462 objects, 1,755 character types, 11,000 episodes; WILD 41,131 training episodes from game deployment (human players, so a different provenance than crowdwork). [V] https://parl.ai/projects/light/ . Licence [GAP: check ParlAI repo].
- Cornell Movie-Dialogs Corpus: licence/terms page fetch failed (certificate rejected) https://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html . [GAP] Underlying scripts are copyrighted film text [B]; dataset licence unverified.
- Project Gutenberg: "vast majority" of eBooks public domain in the US, so no one can grant or withhold permission for uses; PG cannot sublicense copyrighted items; subject to its Terms of Use, which the permission page links and does not summarise; no statement on ML/bulk use on that page. [V] https://www.gutenberg.org/policy/permission.html . Terms-of-Use text on bulk/automated access and the trademark licence: [GAP, not read]. Items public domain in the US may not be elsewhere (same page).
- Google Synthetic-Persona-Chat (model-generated, persona-chat-style): CC BY-NC? listed as cc-by-4.0 on HF. [V search-only] https://huggingface.co/datasets/google/Synthetic-Persona-Chat
- Operator-authored or model-generated corpus: no licence question beyond provider terms [B, unread this pass]. Problems with evaluating on model-generated text: see 2 (self-bias, preference leakage, stylistic homogeneity: Xu et al. 2025; Li et al. 2025). The same Xu et al. finding says it extends to open-ended generation.
- Expressive-register-annotated English corpora (hedging, humour, affect, politeness labelled), with permissive terms: [GAP] not searched beyond the list above (e.g. Stanford Politeness Corpus, GYAFC formality, Enron etc. are [B] candidates, unverified, GYAFC is known to carry Yahoo Answers terms [B]).
## 5. Conlang projection and evaluation without a corpus
- GF/Abstract Wikipedia: see prior note. Related: "Multilingual Text Generation for Abstract Wikipedia in Grammatical Framework: Prospects and Challenges" (Chalmers) [V title/abstract page] https://research.chalmers.se/en/publication/549205 . GF conlang grammars (Esperanto, Lojban, Toki Pona): [GAP] none found in search. (GF RGL status table lists natural languages only per prior note.)
- Toki Pona: arXiv 1712.09359 describes the language (14 letters, ~124 lemmas, ~10 syntax rules), vocabulary analysis, a text-synthesis tool, WordNet synsets; tooling, not a meaning-representation realiser. [V search-only] https://arxiv.org/pdf/1712.09359
- Lojban: a student semantic analyser mapping a Lojban subset to logical form / Prolog (parsing direction) and a 2005 WPI thesis using Lojban as an interface to a first-order-logic reasoner with speech-synthesised answers [V search-only] https://lojban.org/files/papers/lojban_parser_paper ; https://digital.wpi.edu/downloads/g732db469 . A community English-to-Lojban fine-tune of an OPUS-MT model on Tatoeba pairs, 1,000 dev, 39 gold held-out pairs. [V search-only] https://huggingface.co/linguardia/opus-mt-en-jbo . A realiser from logical/semantic form into Lojban or Esperanto in the literature: [GAP].
- Esperanto: first systematic MT benchmark (de Gibert & de Gibert, SIGUL 2026) over rule-based, encoder-decoder and LLM systems, 600M-9B parameters; NLLB family best in all pairs, followed by compact trained models and a fine-tuned LLM. [V search-only] https://aclanthology.org/2026.sigul-1.31/ ; https://arxiv.org/abs/2603.29345 . Esperanto has >20M English pair sentences per ConlangBench's framing.
- ConlangBench (arXiv 2608.03505): 21 existing conlangs, 21M+ conlang-English pairs (430K+ from the 20 non-Esperanto ones, including Toki Pona classified as engelang); translation performance associated with natural-language similarity and conlang characteristics; smaller conlangs to be read cautiously. [V search-only] https://arxiv.org/pdf/2608.03505
- Evaluating a lowering into a language with no corpus: no source addressing constructed-language generation evaluation directly. [GAP]. Adjacent: reference-free MT evaluation for low-resource languages: Sindhujan et al. LoResLM 2025 found prompt-based LLM evaluation outperformed by fine-tuned encoder QE models, errors from tokenisation, transliteration, named entities https://aclanthology.org/2025.loreslm-1.33 ; Rahman et al. 2025 Sylheti-English reference-less framework, top Spearman gain +0.1083 https://arxiv.org/abs/2505.12273 . Both [V search-only]; both for natural low-resource languages, not conlangs. Back-translation/backgloss as the check is Weksa's own design (multilingual-utterance-lowering.md, prior note) and the round-trip caveats in the earlier note apply.
## Gaps worth knowing exist
- No per-metric human-correlation numbers for hedging, humour, affect, stance, persona voice; nearest is formality (Briakou 2021). COMET, BLEURT, MAUVE correlations not obtained.
- No source on surface-form smuggling through an LLM bottleneck or its defences (vocab restriction, paraphrase invariance).
- No source on schema/inventory growth from generation failures, or its overfitting controls.
- Cornell, LIGHT, PersonaChat licence texts not obtained; Gutenberg ToU on bulk use not read.
- No realiser or evaluation protocol for constructed languages from a meaning representation.
- Several arXiv PDFs (Briakou, Zheng, STEL, LUAR/StyleDistance) fetched as binary; numbers above come from the ar5iv/arXiv-HTML versions where marked, otherwise search summaries.

View file

@ -0,0 +1,90 @@
# Prior art for the Weksa interlingua-lowering campaign, 2026-10-29
Eyes pass. Facts and pointers only; no recommendation. Tags: [V] = read/returned by a source this pass; [B] = background knowledge, not re-verified this pass; [GAP] = looked for, not found.
## 0. Weksa's current design (read from F:\Projects\weksa\docs)
- interlingua-standard.md (0.1-draft): packet families interlingua_version, packet_id, kind, provenance, context, discourse, referents, predications (predicate, frame, aspect, tense, polarity, modality, evidentiality, roles agent/patient/experiencer/...), relations, constraints, extensions (namespaced), trace. Owns referents, events, roles, TAM/evidentiality, discourse function, provenance/confidence. Does NOT own target-language concept splits, grammar obligations, morphology, final wording. Cites AMR 1.0, UMR (LREC-COLING 2024), Global FrameNet, UD guidelines. Promotion rule: new fields start as namespaced extensions, promoted after use in 2 real packets.
- best-modern-approach.md: hybrid; models for decomposition/critique, explicit ontology/grammar/morphology/phonology/diachrony for authority. English -> interlingua packet first; "English stops being the hidden skeleton". Names "find places where output is just English in disguise" as a model critique job.
- runtime-authoring-split.md: runtime = packet + compiled language data, no LLM, no network, deterministic, traceable per stage; authoring may use models, output durable only after normalisation.
- multilingual-utterance-lowering.md (v0): targets en-US, pt-BR (+Umbanda overlay), ja-JP. Authority split: packet owns meaning; projected speaker context owns knowledge/voice pressure; lowerer owns surface text, register marking, backgloss/backtranslation, lowering trace; per-target "cultural ontology profile" (data/target-language-ontology/*.yaml) owns address/politeness/register/forbidden flattenings. Schema weksa.utterance_lowering.v0. Note: this is LLM-or-rule-agnostic as read; the "lowerer" realizer is not stated to be deterministic in the part I read (first 4.5 KB of ~7 KB).
## 1. Interlingua MT / generation systems
### 1.1 UNL (Universal Networking Language)
- What: hypergraph of binary labelled relations over Universal Words (headword + constraint list, e.g. sky(icl>natural world)), attributes (@def, @past), ~46 relations in the specs; "UNLization" (text->UNL, semi-automatic) and "NLization" (UNL->text, automatic). [V] https://en.wikipedia.org/wiki/Universal_Networking_Language (page carries maintenance banners; treat cautiously)
- History: began 1996 at UN University Institute of Advanced Studies, Tokyo; UNDL Foundation (Geneva) from Jan 2001; programme dated 1996-2015 by its own archive; WIPO PCT recognition 2002, US patents 6,704,700 and 7,107,206. [V] https://unlarchive.org/index.php?unlweb=unl ; Wikipedia above.
- Meaning vs surface: UNL graph owns meaning (concepts via UW ontology); each language's NLization rules own surface. Rendering = rule-based (language-pair-free generation grammars).
- Outcome: no open-domain/commercial MT product found; one critical paper says no interlingua systems have reached open domains or market and points to the interlingua's design as one candidate cause; another notes different groups interpret the specification differently; UNL "was not designed as an interlingua", MT being one use. [V] https://rcs.cic.ipn.mx/2004_12/ (Boguslavsky "Some Controversial Issues of UNL Linguistic Aspects", and "A Rationale for Using UNL as an Interlingua"); Brazilian ICMC-USP report https://web2.icmc.usp.br/SCATUSU/RT/Serie_Computacao/serie_comp_45.pdf . Counterpoint: Boitet argues UNL succeeds ATLAS-II/PIVOT (Fujitsu commercial interlingua systems). [V, via same search]
- [GAP] No retrieved quantitative evaluation of UW coverage or ambiguity; no primary statement of why activity ended (archive pages are promotional).
### 1.2 KANT / KANTOO (CMU Center for Machine Translation)
- What: knowledge-based MT with a sentence-level interlingua, coupled to a controlled source language (Controlled English) with an authoring checker; KANTOO (2000-02) is a redesign with modules for source analysis, target generation, terminology management, knowledge-source development, plus a controlled-language checker and lexical maintenance tool. [V] Mitamura/Nyberg/Carbonell 1991 https://figshare.com/articles/An_Efficient_Interlingua_Translation_System_for_Multi-lingual_Document_Production/6621035/1 ; AMTA 2000 https://aclanthology.org/2000.amta-systems.2 ; AMTA 2002 https://aclanthology.org/2002.amta-systems.8
- Deployment: Caterpillar service documentation (Caterpillar Technical English); Hayes/Maxwell/Schmandt, CLAW 1996 https://mt-archive.net/90/CLAW-1996-Hayes.pdf . Needed an interactive checker so authors write inside the controls. [V]
- Meaning vs surface: interlingua owns domain meaning from a constrained domain model; target generation grammars + terminology own surface; deterministic.
- Scaled because the input language and domain were closed (controlled language). [V from sources' framing; no later-deployment status found] [GAP: continuation post-1990s.]
### 1.3 Grammatical Framework (GF)
- What: abstract syntax (language-neutral tree/meaning) + one concrete syntax per language (linearization); parsing and generation both from the same grammar pair. Resource Grammar Library (RGL) supplies morphology + syntax API per language. [V] https://www.grammaticalframework.org/ ; Ranta 2009 https://journals.colorado.edu/index.php/lilt/article/view/1205 ; "Abstract syntax as interlingua" https://research.chalmers.se/publication/517996 ; Ranta, Angelov, Gruzitis, Kolachina, Computational Linguistics 46(2), 2020.
- Status: GF 3.12 released 2025-08-08 [V grammaticalframework.org]. RGL status table lists 48 languages with per-area completeness marks (paradigms, lexicon, syntax, dictionary, WordNet, tested, publications) [V] https://www.grammaticalframework.org/lib/doc/status.html . The 2020 CL paper says "over 40 languages" [V via search summary]. Effort-per-language: an earlier search summary quoted "3 to 9 months" but the status page I fetched gave no such figure [GAP: unverified].
- Meaning vs surface: application grammar owns abstract syntax (domain semantic categories/functions); RGL + concrete grammars own morphology/word order/agreement. Fully deterministic, compiled (PGF). Strict input: parsing requires grammatical conformity (misspellings fail) [V, 2024 Chalmers thesis https://odr.chalmers.se/items/859d68cb-c9ca-4802-92a1-36ae73e56208 which builds a transformer parser for GF trees].
- Failure/limit modes noted in sources: coverage of open text needs a robust-pipeline layer (the "Scaling up from controlled languages to robust pipelines" paper title); Google.org Fellows' evaluation calls GF "supports about 45 languages to a certain degree" [V]. Concrete syntax per domain/language is authored by hand (renderer combinatorics = grammar authoring, bounded by RGL reuse). [B]
- [GAP] No 2024-25 paper found on LLM -> GF abstract-tree parsing or GF-grounded LLM generation.
### 1.4 AMR / UMR
- What: AMR = rooted, directed, labelled graph over PropBank frames (not a surface parse), no alignment to words; English-centric, no tense/aspect/number/definiteness. UMR extends to document-level coreference, temporal/modal dependencies, aspect, multilingual lemmas. Weksa cites both. [V] AMR 1.0 spec https://www.isi.edu/results/publications/14628/ ; UMR https://aclanthology.org/2024.lrec-main.229.pdf ; survey https://arxiv.org/pdf/2505.03229
- AMR is not an interlingua per its own cross-lingual studies: "Not an Interlingua, But Close" (Xue et al., LREC 2014) compared 100 sentence AMRs to Chinese and Czech; compatibility higher for Chinese than Czech. Cross-lingual AMR work (Damonte & Cohen) notes structural alignment is not always possible. [V] https://aclanthology.org/L14-1332/ ; https://arxiv.org/abs/1704.04539 ; Georgetown dissertation https://repository.digital.georgetown.edu/handle/10822/1088595
- AMR-to-text: AMR omits tense, number, definiteness, politeness, so generation under-determined; one AMR maps to many valid surfaces, penalising BLEU-style metrics (Opitz & Frank MF-beta recovers AMR from generated text to score) [V] https://www.virtual2021.eacl.org/paper_main.610.html ; human eval of AMR-to-English finds hallucination, anonymization, repetition errors [V] https://deepai.org/publication/a-human-evaluation-of-amr-to-english-generation-systems ; GPT4AMR (LLM paraphrase after AMR-to-text: no automatic gain, better in human eval) https://preview.aclanthology.org/setup/2025.winlp-main.2 . Rendering is learned (seq2seq / LLM), not deterministic. [GAP: no head-to-head GPT-4 vs T5/BART AMR-to-text on AMR3.0 with hallucination scoring found.]
### 1.5 Universal Dependencies-based generation
- UD = shared cross-linguistic categories with language-specific extensions (Weksa cites it as governance pattern) [V weksa docs; https://universaldependencies.org/guidelines]. UD is a syntactic annotation scheme; owner of syntax, not meaning. Used as the tree substrate in Ninai/Udiron (below). Ninai/Udiron paper: for constructs UD handles poorly "new roles and structural linkage strategies may be readily devised" [V via search summary]. [GAP: no standalone UD->text generator system surveyed beyond Udiron; UD shared tasks on surface realisation (SR'18/'19/'20) exist [B], not fetched.]
### 1.6 SimpleNLG
- What: Java realisation engine (Gatt & Reiter, ENLG 2009): programmer supplies lexical items + syntactic frame; library does morphology, agreement, linearisation. Deterministic. Takes already-lexicalised, syntax-shaped input; owns surface only; meaning/content selection is the caller's. [B for 2009 paper; not retrieved]
- Ports are separate per-language forks, mostly derived from SimpleNLG-EnFr (Vaudry & Lapalme 2013): French, Italian, Spanish, Brazilian Portuguese, Dutch, German (German on v3). Italian authors note such a realiser presumes more detailed morpho-syntactic input; Dutch authors chose French as base as closer to Dutch morphology. [V] https://github.com/rfdj/SimpleNLG-NL ; https://iris.unito.it/retrieve/e27ce430-9bfc-2581-e053-d805fe0acbaa/INLG16-W16-6630%2bPREF.pdf ; https://preview.aclanthology.org/fix_video/W13-2125.pdf
- [GAP] Maintenance status of each port; v3.x not commercially licensed (per repo listing) [V].
### 1.7 Abstract Wikipedia / Wikifunctions (closest modern analogue)
- Design: Vrandecic 2020 "Architecture for a multilingual Wikipedia" https://arxiv.org/pdf/2004.04733 : Constructors (typed content templates with slots), Content (abstract calls to constructors), Renderers (functions, in Wikifunctions, from content to text per language). Ninai (constructors + high-level logic, resolution of Wikidata items to lexeme senses per language) builds syntax trees; Udiron (UD-style trees) linearises; both Python prototypes by Mahir Morshed, intended to move to Wikifunctions; draw on Wikidata lexemes. [V] https://meta.wikimedia.org/wiki/Abstract_Wikipedia/Architecture/en ; Semantic Web Journal https://semantic-web-journal.net/content/using-wikidata-lexemes-and-items-generate-text-abstract-representations (a reviewer questioned why this abstract representation was chosen vs alternatives).
- Meaning vs surface: abstract content (Wikidata items + constructors) owns meaning; per-language renderers + lexemes own surface; symbolic, deterministic, community-authored. Vrandecic/CEE 2025 submission argues explicitly for "symbolic (not LLM-based)" community NLG. [V] https://meta.wikimedia.org/wiki/Wikimedia_CEE_Meeting_2025/Submissions/What_Can_Wikifunctions_and_Abstract_Wikipedia_Do_for_You%3F
- Status in 2026 [V]: Wikifunctions status updates (https://www.wikifunctions.org/wiki/Wikifunctions:Status_updates/2026-04-16 , /2026-05-30, /2026-10-02). Simple Wikipedia lists an initial beta release 20 March 2026; ~1000 abstract articles by mid-April 2026; 2026-10-02 update: first Abstract Wikipedia articles about to appear on language Wikipedias, pilots Malayalam, then Dagbani and Igbo; each community must enable via Community Configuration, nothing added automatically; cities flagged as promising gap; 166 functions created that week, many language-named (Japanese, English, Russian, German, Malayalam, Italian, "mul" fallbacks). Not found: confirmation that any generated article is live on a language Wikipedia as of 2026-10-29; per-language renderer coverage list [GAP].
- Critique on record: Google.org Fellows (Livneh, Gutman, Assaf, Yang) evaluation: Wikifunctions "is not a necessary building block"; recommend decoupling; specify abstract content first, constrained authoring, single NLG approach, adopt GF or build new; criticise runtime nondeterminism/security, Z-object readability; note render latency (ms) concern. Does not mention LLMs or give per-language effort numbers. [V] https://meta.wikimedia.org/wiki/Abstract_Wikipedia/Google.org_Fellows_evaluation (page last edited 2026-07-24; writing date ~2022). Signpost report quoted the project as "at substantial risk of failure" [V via search] https://signpost.news/2023-01-01/Technology_report
- Neural comparison: Kaffee/Vougiouklis/Simperl NLG-for-missing-Wikipedia-articles study (content quality, trust, transparency concerns) [V] https://semantic-web-journal.net/node/2586 ; 2022 Chalmers thesis used multilingual LMs to evaluate/select auto-generated Abstract Wikipedia articles [V via search].
## 2. LLMs emitting structured meaning representations
- LLM AMR parsing: Ettinger et al. 2023 "You Are an Expert Linguistic Annotator": GPT-4 100% valid AMR syntax but often wrong content zero-shot; few-shot better. Li & Fowlie 2025 (JLCL, "GPT makes a poor AMR parser"): GPT-3.5/4o max Smatch ~60 with 5-shot; CoT no gain. Fine-tuned: FLAN-T5 Smatch 86.4 (AMR2.0) / 84.9 (AMR3.0); decoder-only LLM fine-tune ~80.4 (AMR3.0, 2025 study). Different test sets/Smatch variants; not strictly comparable. [V] https://arxiv.org/pdf/2310.17793 ; https://jlcl.org/article/view/285 ; https://arxiv.org/pdf/2304.12272 ; https://arxiv.org/abs/2508.05028v3
- Constrained decoding to a grammar: Shin et al. EMNLP 2021 — LM paraphrases into a controlled English-like canonical sublanguage under grammar-constrained decoding, then deterministic map to the target MR; few-shot. [V] https://aclanthology.org/2021.emnlp-main.608 (arXiv 2104.08768)
- Cost of format restriction: Tam et al. EMNLP 2024 Industry "Let Me Speak Freely?": stricter format constraints (JSON-mode/constrained decoding) lowered reasoning-task performance; classification tasks sometimes improved; two-step "NL answer then convert to format" largely kept free-form performance; models tested limited. [V] https://arxiv.org/html/2408.02442v1
- Round-trip fidelity: round-trip/back-translation is reference-free and cheap but measures consistency not accuracy; same-model both ways inflates; lexical-overlap metrics under-credit paraphrase; conflates forward/backward errors; drift accumulates; a 2026 study argues against RTT in meaning-sensitive workflows; another reports RTT lexical loss caught ~67% of major errors at a 20% review budget (Pali-to-English). [V via search summary] https://arxiv.org/pdf/2603.22642 ; https://arxiv.org/pdf/2609.14963 ; https://journals.vilniustech.lt/index.php/NTCS/article/view/26227 ; https://arxiv.org/html/2504.16286v1 . Graph-level analogue for AMR: parse both texts and compare graphs, limited by parser reliability [V] https://arxiv.org/pdf/2501.00269 .
- Cost of the extra step vs direct generation: no source found that measures token/latency/quality cost of "LLM -> meaning packet -> renderer" versus direct multilingual LLM generation [GAP]. Tam et al. is the nearest evidence on quality cost of forced structure.
- [GAP] No retrieved study of LLM-authored UMR, UNL, or GF-tree output reliability.
## 3. Known failure modes (with what the sources say)
- Coverage gaps / abstraction: main objection to interlinguas is claimed universality and excess abstractness (Hutchins & Somers 1992, cited in the UNL critique above); Dorr, Hovy & Levin (2006) note few interlinguas beyond demo prototypes, one used in a commercial MT system [V via search summary of Dorr work]; KANT survived inside a controlled language/domain [V framing]; GF scales per domain grammar, open text needs pipelines [V].
- Divergences: Dorr 1994 classifies translation divergences (thematic, structural, lexical-semantic) that defeat structure transfer; Barnett et al. 1991 argue mismatch is a generation problem [V] https://umiacs.umd.edu/node/13821 ; https://aclanthology.org/1991.mtsummit-papers.4.pdf . Under-specification in the opposite direction: an interlingua must carry distinctions only some targets need ("Capturing Language-Specific Semantic Distinctions in Interlingua-based MT", same MT Summit 1991 volume) [title V; content not read].
- Register/politeness loss: AMR omits tense/number/definiteness; no AMR slot for politeness/address [B; AMR spec read only via citation]. UNL attributes are a closed set (@def, @past, ...) [V]. Weksa's design addresses via discourse.register_hint and target ontology profiles (docs) — no external evidence collected on how Japanese-honorific or pt-BR treatment performs in any interlingua system [GAP].
- Renderer combinatorics: n targets x m constructs of hand-authored renderers (GF: concrete syntaxes per language per domain, mitigated by RGL; Abstract Wikipedia: volunteer-written per-language renderers, Google.org critique cites duplicated effort across formalisms; SimpleNLG: per-language forks) [V as above]. Interlingua's engineering rationale: modules scale O(n) not O(n^2) language pairs (Zajic & Miller) [V] https://www.cs.cornell.edu/courses/cs674/2000SP/zajic_final.htm .
- "Interlingua is English in disguise": AMR is English-centric by design and compatibility with Czech < Chinese (Xue et al. 2014) [V]; Damonte & Cohen: not always structurally alignable [V]; UNL's Universal Words are English headwords with constraint lists (sky(icl>natural world)) [V]; GF abstract function names are conventionally English identifiers though the abstract syntax is language-neutral formally [B]; Abstract Wikipedia constructors resolve Wikidata items (Q-ids) to lexemes per language [V]. Weksa's own docs list this critique as an authoring-time check [V].
- Interpretation drift between annotators/groups for UNL specs [V]; AMR inter-annotator and parser reliability issues (above).
- Project-level failure: Abstract Wikipedia described by its own Signpost coverage as at substantial risk of failure (2023) and decoupling recommended by Google.org Fellows; now in beta 2026 [V].
## 4. Summary table (owner of meaning / owner of surface / rendering mode / scale outcome)
| System | Meaning owner | Surface owner | Rendering | Outcome |
|---|---|---|---|---|
| UNL | UW ontology + ~46 relations + attributes | per-language NLization rules | rule-based | programme 1996-2015; no open-domain product found |
| KANT/KANTOO | domain interlingua + controlled source | target generation grammars + terminology | rule-based | deployed in a controlled-language industrial setting (Caterpillar) |
| GF | abstract syntax per application | concrete syntaxes + RGL (48 langs listed) | compiled grammar, deterministic | live, GF 3.12 (2025-08) |
| AMR/UMR | graph annotation | none (annotation, not generation) | learned AMR-to-text | benchmark ecosystem; not an MT interlingua |
| UD-based | syntax trees | per-language linearisation | rule or learned | substrate, e.g. Udiron |
| SimpleNLG | caller | realiser + lexicon | rule-based | per-language ports |
| Abstract Wikipedia | Wikidata + constructors | Wikifunctions renderers + lexemes | symbolic functions | beta 2026-03; first language-Wikipedia pilots Oct 2026 |
## 5. Gaps worth knowing exist
- No source on LLM-emitted GF/UNL/Ninai constructors, nor on LLM->packet->deterministic-renderer cost vs direct generation.
- Abstract Wikipedia per-language renderer coverage and live-on-wikipedia confirmation not obtained.
- UNL decline causes, KANT post-1990s status not obtained.
- GF RGL "3-9 months per language" figure unverified.
- Constrained-decoding engines (Outlines, XGrammar, provider structured outputs) not surveyed this pass.

View file

@ -0,0 +1,71 @@
# Prior art for Weksa lowering strategy: fine-tuning and embedding mapping, 2026-10-29
Eyes pass, campaign `weksa-lowering`. Facts and pointers only; no recommendation. Tags: [V] read this pass (fetched page/PDF, or a search-result summary, marked "(ss)" when only a search summary was seen); [B] background, not re-verified; [GAP] looked for, not found. Covered already and not redone: docs/research/2026-10-29-interlingua-lowering-prior-art.md, 2026-10-29-golf-loop-prior-art.md.
## 0. Project's own prior thinking (F:\Projects\weksa\docs)
- targets/interlingua-lowering.md: lowerer shape is an open fork (deterministic, model, or both with one contract). Invariants include packet-owns-meaning, lowering-traced, profile-owns-register, no-surface-leak, corpus-clean (no Discord). [V]
- persona-finetuning-dataset-exploration.md: already plans fine-tuning two separate adapters, "Persona intent extraction" and "Weksa lowering"; says do not fine-tune for direct context->utterance impersonation; row schemas `weksa.finetune.lowering_row.v0` and `weksa.finetune.persona_interlingua_row.v0`; interlingua labels would be derived from target utterances by a model pass ("marked as derived from answer-key surface evidence"); source corpus is the Discord archive (18,049 pre-2025 authored messages; 16,499 casual candidates), which the target doc now forbids as training data (`gamecult-chat:ruling:discord-taints-training`). Splits by year and channel holdouts proposed. [V]
- utterance-embedding.md: embeddings in Weksa today serve the AquaSynth speech handoff only (bge-m3, 1024 floats, `speech_text_embedding`); text embedding explicitly "not a pronunciation or alien-phonetics embedding". No doc uses embeddings for concept lexicon or translation. [V] language-model.md lists "concept clustering" as an allowed model use; best-modern-approach.md line 33: "Embeddings and LLMs can cluster English concepts, but the ontology decides what..." [V]
- No existing fine-tune run, adapter, or training-job record found in docs. [GAP: checked docs only, not scripts/.weksa-runs]
## 1. Fine-tuned lowerers (structure/data to text)
- AMR-to-text with pretrained seq2seq: T5-large about 45.8 BLEU plain, about 49.7 after task-adaptive training on silver data (Ribeiro et al., "Investigating Pretrained Language Models for Graph-to-Text Generation", arXiv 2007.08426); same paper varied training data 1%-100%: BART and T5 already good at 40% of data, T5 about 5.6 BLEU ahead of BART at 1% on WebNLG. [V (ss)]
- 2025 WiNLP (GPT4AMR) says top AMR-to-text models (AMRBART, BiBL) reach roughly 50 BLEU with a remaining quality gap; LLM paraphrase after AMR-to-text gave no automatic-metric gain but was preferred in human evaluation. [V (ss)] https://preview.aclanthology.org/setup/2025.winlp-main.2
- Few-shot/low-resource D2T: T5-base fine-tuned on 100 triple sets used as low-resource baseline (Cycle training, ACL 2023, arXiv 2305.14793); another summary reports neural D2T "high fidelity with about 300 annotated examples". [V (ss), second claim weakly sourced]
- AMR-to-text fine-tuned LLM vs prompted frontier LLM head-to-head on AMR 3.0 with BLEU or faithfulness: [GAP] (also noted as gap in the earlier prior-art file).
- Open LLMs zero-shot on D2T: Kasner and Dusek, ACL 2024 (arXiv 2401.10186): Llama 2, Mistral, Zephyr on QuintD (fresh API-derived records); human annotators and GPT-4 metric both flag over 80% of outputs as containing a semantic error (v1 said 80% human, 91% GPT-4). [V (ss)] No frontier-API or fine-tuned comparison in that paper as seen. [GAP]
- Faithfulness with model size: Mahapatra and Garain 2024 (arXiv 2407.14088), 12 models in T5/BART/OPT/BLOOM/Llama 2 families fine-tuned on five D2T sets incl. E2E, DART, WebNLG: larger fine-tuned models improve readability/informativeness, faithfulness (BARTScore) often decreases with size. [V (ss)]
- Error type: SPOR (arXiv 2405.10650) reports stronger fine-tuned models (Mistral-7B, Llama-2-13B) err mostly by omission on E2E; a Cambridge MLMI dissertation reports T5 outputs showed rare hallucination, many omissions. Cycle training reduced human-counted hallucination errors from 14.84 to 2.57 and factual errors from 8.05 to 0.49 (units as in paper; low-resource fine-tune baseline). [V (ss)]
- SCOPE (arXiv 2502.13674): faithfulness hallucination described as common in standard fine-tuned LLMs; self-supervised fix; notes WebNLG and E2E are of limited complexity. [V (ss)]
- Persona/style fine-tunes: no study found comparing LoRA/QLoRA persona-style tunes with strong prompting on voice fidelity. Nearest: (a) 2026 arXiv 2609.21857: two small open models fine-tuned on personality-labelled data were no better at role-playing personalities than baselines (authors note low inter-rater agreement); (b) PersonaEval (2508.10014): role-knowledge fine-tuning did not help role identification and may degrade it; (c) NeurIPS 2025 multi-turn RL with consistency rewards cut persona inconsistency by over 55% (simulated users). [V (ss)] [GAP: LoRA-specific voice-holding evidence]
- Style imitation: "Catch Me If You Can? Not Yet" (arXiv 2509.14543): few-shot beats zero-shot, 2 to 10 shots adds little, informal/everyday-author style imitation remains weak; no fine-tuning baseline run. "How Well Do LLMs Imitate Human Writing Style?": exemplar-based prompting and text completion are key; imitation remains lower-perplexity than human text. [V (ss)] [GAP: fine-tuned vs prompted on same authors]
- LoRA vs full fine-tuning: Biderman et al. 2024 (TMLR, arXiv 2405.09673): LoRA trails full FT in code/math in standard settings, forgets less outside target domain, sensitive to learning rate/target modules/scale more than rank. Follow-up 2410.21228: LoRA forgets less but "intruder dimensions" matter. [V (ss)]
## 2. Training data from the loop itself
- Back-translation, Sennrich/Haddow/Birch ACL 2016 (arXiv 1511.06709): synthetic source from target-side monolingual text mixed with real parallel data; +2.8-3.7 BLEU WMT15 en->de, +2.1-3.4 BLEU IWSLT14 tr->en. [V (ss)]
- Edunov et al. EMNLP 2018 (arXiv 1808.09381): sampled or noised-beam synthetic sources beat beam outputs by 1.7 BLEU average; beam still fine in very low-resource settings. Iterative back-translation (Hoang et al. 2018; Cotterell and Kreutzer 2018) cited second-hand only. [V (ss); Hoang itself GAP]
- D2T cycle training (ACL 2023, arXiv 2305.14793): trains text->data and data->text models in a cycle on unpaired data; fewer hallucination/factual errors than a 100-pair fine-tune (see §1). [V (ss)]
- Collapse: Shumailov et al., Nature 631:755 (2024): models trained recursively on generated data lose tails of the distribution and produce repetitive output, in LLMs, VAEs, GMMs. Whether mixing/accumulating real data prevents it: not confirmed from the sources seen. [V (ss)] [GAP: accumulate-vs-replace results]
- Round-trip leakage/inflation (same model both directions, consistency not accuracy): from prior pass, §2 of interlingua-lowering-prior-art.md. [V earlier pass]
- Specific to this loop: encoder output (packet) of an item and its English lowering form a synthetic pair where the English is the original; the weksa dataset note already marks such interlingua labels as derived from answer-key surface evidence. [V, weksa docs] [GAP: published work on training a generator on packets produced by an LLM encoder from the same targets; closest is back-translation/cycle training above]
## 3. Feasibility, hosts, and Anthropic fine-tuning
- Hosts (gamecult-ops/inventory.md): Yggdrasil = Netcup KVM, 16 vCPUs, 62 GiB RAM, ~2 TB, Debian 13; no GPU listed; carries live public services; CPU model/AVX flags and memory bandwidth not recorded. [V] [GAP: CPU model]
- GPUs found in inventory: Raven RTX 4060 Ti 16380 MiB (household gaming machine and Vili's Kimodo jobs; Bonsai 27B takes about 10.4 GiB on demand and is "never resident" by ruling 2026-09-19; WSL ceiling 7.7 GiB RAM; operator workstation, no reboot without approval); Nightwing GTX 860M running qwen3-embedding:0.6b via Ollama, laptop, "not a build host"; Starfire GPU not recorded in inventory. [V] [GAP: Starfire GPU]
- Starfire (memory workstation-load-budget.md): heavy builds/tests go to Yggdrasil; Starfire does only Windows-only work, one heavy job at a time, froze 2026-09-22, hard-reset 2026-10-18 (cause unproven), 2026-10-28 reboot was a tripped cable; Yggdrasil verify queue has 5 slots. [V]
- QLoRA cost: tutorial/vendor sources say 8B QLoRA needs about 16-24 GB VRAM; one estimate 50k examples x 3 epochs on Llama 3 8B about 6 h on an RTX 4090 (2.5 h with Unsloth), about $2-5 rented; another 10k samples x 3 epochs 2 h on RTX 5090. Unverified vendor numbers. [V (ss), low confidence]
- CPU LoRA training: blogs claim 0.5-3B models in 15-60 min on 16-32 GB CPUs, unbenchmarked; llama.cpp's own training README calls finetune "very much WIP" and mentions LLaMA 3.2 1B working with 24 GB. HF PEFT on CPU PyTorch is the documented path. [V (ss), low confidence] [GAP: measured CPU LoRA run on a 16-vCPU server]
- CPU inference: Llama 3.1 8B Q4_K_M 14.2 tok/s at 12 threads, 12.5 at 8, 7.1 at 4 on an EPYC 9354 (markaicode blog); Qwen3 8B Q4_K_M 26.4 tok/s generation, 210 tok/s prompt on Ryzen 9 7950X with 16 threads; 3.2 tok/s on a 4-core Xeon. Generation is memory-bandwidth-bound. [V (ss), blogs] [GAP: numbers for Yggdrasil's CPU; a KVM guest's bandwidth unknown]
- Anthropic fine-tuning, current: platform.claude.com model overview (fetched 2026-10-29) lists Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5 on the Claude API, Bedrock, Google Cloud, Foundry, Claude Platform on AWS, with no mention of fine-tuning. [V] The old docs path /build-with-claude/fine-tuning returns 404. [V] AWS Bedrock supported-for-fine-tuning table (docs.aws.amazon.com/bedrock/latest/userguide/custom-model-fine-tuning.html, fetched): Anthropic row is only Claude 3 Haiku, us-west-2; others Nova, Titan, Llama 3.1/3.2/3.3. [V] AWS blog on Claude 3 Haiku fine-tuning carries notice "Claude 3 Haiku reached end of life on September 10th, 2026. Use Claude 3.5 Haiku instead." and says nothing on whether fine-tuning survives. [V] Bedrock also offers reinforcement fine-tuning and distillation (teacher-student) as methods; which models support them was not read. [V] [GAP: Claude fine-tuning on Claude API directly; any Claude model newer than 3 Haiku fine-tunable]
- Bedrock customization is billed per training tokens x epochs plus monthly model storage. [V]
## 4. Word-embedding mapping idea
- Mikolov, Le, Sutskever 2013 (arXiv 1309.4168) [V, PDF read]: linear translation matrix learned from a small dictionary (5K most frequent words) on monolingual word2vec spaces. Table 2 (WMT11): En->Sp P@1 33%, P@5 51%; Sp->En 35%/52%; En->Cz 27%/47%; Cz->En 23%/42% (readings from table layout; row/direction attribution partly ambiguous in the extracted text). The abstract's "almost 90% precision@5" applies to a confidence-filtered subset (about half of words in frequency ranks 5K-6K, distance threshold), not all words. Small monolingual training sets cut P@5 to about 25%. The paper says the method "was less useful for more [distant pairs]" (English/Czech, Vietnamese tested).
- MUSE, Conneau et al. ICLR 2018 (arXiv 1710.04087) [V, PDF read]: Table 1 P@1, unsupervised Adv-Refine-CSLS: en-es 81.7, es-en 83.3, en-fr 82.3, fr-en 82.1, en-de 74.0, de-en 72.2, en-ru 44.0, ru-en 59.1, en-zh 32.5, zh-en 31.4, en-eo 28.2, eo-en 25.6 (fastText on Wikipedia, 1,500 queries vs 200k targets). Word-by-word translation en<->eo BLEU 11.1 and 14.3; authors say meaning mostly conveyed with "simple errors" and fixable by an LM.
- Søgaard, Ruder, Vulic ACL 2018 (P18-1072): word-embedding spaces "far from isomorphic" even for close languages; unsupervised BDI degrades on morphologically rich non-dependent-marking languages, different domains/embedding algorithms; graph-similarity metric correlates near-perfectly with induction success; identical-word weak supervision helps. [V (ss)]
- VecMap (Artetxe et al.) as named; not read this pass. [B]
- Conlang with no corpus: [GAP] found no paper that places invented words at concept vectors or assigns an invented lexicon by clustering an embedding space. Nearest: Toki Pona word2vec study (Huang and Wu, arXiv 2606.17299, about 130 words: ANIMALS/COLORS form exclusive clusters, SHAPES/BODY/DIRECTION overlap) [V (ss)]; Cree lexicon classified semi-automatically with English word2vec vectors (AmericasNLP 2021) [V (ss)]; Spanish embedding-cluster "semantic domains" paper [V (ss)].
- Conlang LLM work: ConlangCrafter (arXiv 2508.06094) multi-stage LLM pipeline generating phonology, morphology, syntax, lexicon, translation, with self-refinement; evaluated on consistency and typological diversity. ConlangBench (arXiv 2608.03505): 21 conlangs, over 21M parallel pairs (Esperanto-dominated; 430K across the other 20), 321K vocab entries; models translate better into a posteriori conlangs (vocabulary from natural languages); models could learn all eight conlangs with sufficient parallel data. [V]
- MTOB (Tanzer et al., ICLR 2024, arXiv 2309.16575): Kalamang from one grammar book (~217K tokens) plus word list and sentences; best model about 44.7/45.8 chrF (kgv->en / en->kgv) vs human learner 51.6/57.0; follow-up 2508.06665 (GPT-4.1) finds parallel sentences alone beat full-grammar prompting and grammar alone fails on complex sentences. [V (ss)] Relevant as work on LLM + grammar documents for a language with no web corpus.
- Embedding inversion: vec2text (Morris et al., EMNLP 2023, arXiv 2310.06816): recovers 92% of 32-token inputs exactly from a GTR-base style embedding; on MSMARCO with ada-002: 83.4 BLEU/60.9% exact at 32 tokens, 55.0 BLEU/8.0% exact at 128 tokens; a 2025 reproduction notes length sensitivity. Inversion targets the same encoder's own English text; no conlang use found. [V (ss)] [GAP: inversion into non-English or invented surface]
- Sentence-embedding interlingua: SONAR (Meta, 200 languages text) used as concept space with a decoder in Large Concept Models (arXiv 2412.08821); SONAR reported better than LASER3 and LaBSE on xsim/xsim++. [V (ss)] LaBSE/LASER coverage details [B]. Weksa's current embedding: bge-m3 [V].
- "Nearest English cluster" as gloss vs fluent text: only indirect evidence: MUSE word-by-word en<->eo (above), Toki Pona wiki note that word-level glossing is the simplest approach and neural translators often err [V (ss)]. [GAP: measurement of nearest-cluster gloss vs fluent generation].
## 5. Hybrids
- Overgenerate-and-rank: Velldal (LOGON/Oslo) discriminative realization ranker trained on a few thousand treebank items beat a surface LM; Varges instance-based ranker over rule candidates; critique that overgeneration is expensive. [V (ss)]
- Neural ranking for word ordering in surface realization (pointwise/pairwise/listwise), five languages (2021). [V (ss)]
- Grammar-constrained decoding: Geng et al. EMNLP 2023 (arXiv 2305.13971): any CFG plugged into constrained beam search; constrained LMs beat unconstrained and sometimes finetuned models on IE/entity disambiguation with LLaMA. Outlines (Willard and Louf 2023, arXiv 2307.09702) regex/grammar guided generation; SynCode (2403.01632); Grammar-Aligned Decoding (2405.21047) argues grammar constraints distort the LM distribution. [V (ss)] Prior pass: Shin et al. 2021 (constrained decoding into a canonical sublanguage then deterministic map), Tam et al. 2024 (format constraints cost reasoning; two-step NL-then-convert keeps quality). [V earlier pass]
- GF + neural: Chalmers 2024 thesis builds a transformer parser to GF trees (prior pass). [GAP] no paper found on neural lexeme/register choice feeding a GF or SimpleNLG-style realiser, nor on LLM output constrained to a GF concrete syntax.
- Abstract Wikipedia/Ninai/Udiron (prior pass): symbolic realisers with lexeme lookup; no learned component. [V earlier pass]
## Evidence pointers (URLs)
https://docs.aws.amazon.com/bedrock/latest/userguide/custom-model-fine-tuning.html ; https://docs.aws.amazon.com/bedrock/latest/userguide/custom-models.html ; https://platform.claude.com/docs/en/about-claude/models/overview ; https://aws.amazon.com/blogs/machine-learning/fine-tune-anthropics-claude-3-haiku-in-amazon-bedrock-to-boost-model-accuracy-and-quality/ ; arXiv 1309.4168, 1710.04087 (PDFs read, text at scratchpad mik.txt, muse.txt), aclanthology P18-1072, 1511.06709, 1808.09381, 2305.14793, 2007.08426, 2401.10186, 2407.14088, 2405.10650, 2502.13674, 2405.09673, 2410.21228, 2509.14543, 2310.06816, 2412.08821, 2305.13971, 2309.16575, 2508.06665, 2508.06094, 2608.03505, 2606.17299, 2609.21857, 2508.10014, 2511.00222; Nature 631:755 (2024). Local: F:\Projects\gamecult-ops\inventory.md (lines ~51-110 Raven, ~350 Nightwing, ~571-615 Yggdrasil); C:\Users\Meta\.claude\projects\F--Projects-CultLib\memory\workstation-load-budget.md; F:\Projects\weksa\docs\persona-finetuning-dataset-exploration.md.
## Caveats
Most web claims were seen as search-result summaries, not full papers; the two PDF reads (Mikolov, MUSE) are first-hand. Training-cost and CPU-speed numbers come from blogs/vendors and are low confidence. Mikolov table-row attribution is partly ambiguous in text extraction.

View file

@ -0,0 +1,69 @@
# Eyes: weksa substrate (2026-10-29). Facts and pointers only.
Repo: F:\Projects\weksa. Local main = d16cff5 (2026-09-13); origin/main = 55c75e3 (2026-10-22, "Quarantine the Eve operator surface publisher; Eve is deprecated, rebuild on Thing 2"), 1 commit ahead. 63 commits total, first d9ac23b 2026-05-16. Local checkout lags origin; everything below is the LOCAL tree unless marked [origin].
## 0. Hosting (item 7)
- `git remote -v`: origin = https://github.com/GameCult/Weksa.git (GitHub only). No Forgejo remote in the checkout. Not checked: whether a forge repo exists.
- Tag `parked/weksa-eve-surface` -> d16cff5 (also on origin). Branches: main only.
## 1. Live code vs doc-only (item 1)
No package.json, lockfile, node_modules, Cargo, or `packages/` dir. AGENTS.md:43-50 names `packages/weksa-state-model` ("owns executable typed state"); it does not exist. schemas/*.md say "Executable schema code does not exist yet".
Code (all Node ESM/CJS scripts, plus 2 Python tools):
- scripts/weksa-daemon.mjs (48 KB): RUDP daemon. Node v24.15 here. `node --check` passes for all 7 .mjs/.cjs and both .py compile.
- scripts/weksa-rudp-command.cjs, scripts/idunn-rudp-health.cjs: RUDP command ingress / Idunn health (duplicated helper code, per gamecult-ops repo-census weksa.md "Duplicate authority").
- scripts/run-metame-{persona-golf,swarm-explorer,affect-tuning-loop}.mjs, profile-metame-finetune-dataset.mjs: offline experiment harnesses; spawn Codex CLI (`WEKSA_CODEX_EXECUTABLE` default C:\Users\Meta\AppData\Roaming\npm\codex.cmd) and VoidBot (`E:/Projects/VoidBot`, run-metame-persona-golf.mjs:8).
- scripts/{start,restart}-weksa-daemon.ps1, health-weksa-daemon.cmd, restart-weksa-daemon.cmd: Starfire launchers; default state root `E:\Projects\weksa\.weksa` (start-weksa-daemon.ps1:4), MiMo key `E:\Projects\gamecult-ops\mimo-api-Weksa.txt`.
- tools/export-provider-advertisement.mjs (prints provider advertisement JSON; `--help` is ignored, it just prints), tools/emit_tiny_panphon_handoffs.py, tools/validate_tiny_panphon_handoffs.py.
Does it run today: NOT on this workstation. Daemon needs `..\CultLib\packages\{cultnet-ts,cultcache-ts,cultmesh-ts}` via NODE_PATH (weksa-daemon.mjs:386-392) and `..\CultLib\node_modules\@msgpack\msgpack` (weksa-rudp-command.cjs:14-20). `require("cultnet-ts")` fails: `F:\Projects\CultLib\packages\cultnet-ts\dist\index.js` missing (no dist built); `CultLib\node_modules\@msgpack` absent. Not built/run further (heavy work -> Yggdrasil).
Doc/data only: docs/ (14 md + .tex + 17 research PDFs ~96 MiB worktree), schemas/ (10 md receipts), data/ (cultural-ontology yaml, target-language-ontology yaml, `roots/` and `typology/` are README-only), settings/ (Aetheria/Nibu, Eusocial Interbeing, Rust to Dust), examples/, prompts/ (2 md), state/map.yaml + scratch.md.
Binary state in git: .weksa/cultural-ontology/starter-targets.cc, .voidbot/state/weksa.cc. `.weksa/*.cc|json|log|pid` are gitignored; `.weksa/` holds only the cultural-ontology dir (no runtime witnesses present).
## 2. Interlingua (item 2)
- docs/interlingua-standard.md: Version `0.1-draft`. Packet top-level: `interlingua_version, packet_id, kind, provenance, context, discourse, referents, predications, relations, constraints, extensions, trace`. Required: version, packet_id, kind, + at least one of referents/predications/relations (schemas/interlingua-packet.md:17-24). `kind` values: utterance, name_request, commentary_intent, narration, dialogue_line, corpus_example, debug_probe. Extensions namespaced (`zyphos.*`, `rtd.*`, `game.*`, `authoring.*`). Prior art cited: AMR, UMR, FrameNet, Universal Dependencies. Promotion rule for extensions at end of doc.
- Schema id used in verse contract: `weksa.interlingua_packet.v0` (docs/verse-service-contract.md "Inputs"). The packets themselves carry `interlingua_version: 0.1-draft`. Daemon-minted packets carry `0.1-daemon-provisional`.
- No machine-readable schema (no JSON Schema / CultCache type) for the packet. schemas/interlingua-packet.md lists validation goals only.
- Examples: examples/english-lowering/nibu-proof-001/interlingua.yaml (kind dialogue_line, full shape); examples/persona-lowering/{brokkr-edda-voice,metame-casual-pre2025}/interlingua.yaml use a DIFFERENT shape: `schema_version: weksa.persona_lowering_fixture_batch.v0`, `items[].communicative_act, scene{prior_turns...}`; they are not interlingua-standard packets.
- Code that produces: weksa-daemon.mjs `decomposeThoughtIntoInterlingua` (:804-855, heuristic stub: one `express` predication, raw text in `extensions["weksa.raw_thought"]`, speech act by regex `inferSpeechAct` :856). Code that consumes: `normalizeInterlinguaPacket` (:781-803, passthrough/fill defaults, no validation). No validator anywhere. Metame harness has an LLM-prompted stage that emits packet-like YAML (persona-interlingua-prompt.md).
## 3. Lowering (item 3)
Daemon `lowerInterlinguaToSpeechText` (weksa-daemon.mjs:863-902): NOT a lowering. It returns `command.spoken_text` if supplied, else `interlingua.outputs.spoken_text`, else raw thought text, else `discourse.intent`. Target language/cultural refs are copied from the command or defaulted to en-US; the cultural ontology store is not consulted (gamecult-ops repo-census weksa.md "Verdict").
Then buildMimoVoiceDesignRequest (:903) -> callMimoVoiceDesign (:964; `fetch("https://api.xiaomimimo.com/v1/chat/completions")`, model mimo-v2.5-tts-voicedesign) -> writes WAV + receipt. Persona state is read as raw bytes + regex hints (`inspectPersonaState` :724, `extractPersonaStrings` :745, `derivePersonaDeliveryHints` :762).
Implemented lowerings by kind:
- Deterministic code: only the MiMo request projection/prompt builder (above). Grammar/morphology/phonology/diachrony/renderer stages: no code. runtime-authoring-split.md claim ("no required LLM call in hot path", pipeline ontology projection->grammar->morphology->phonology->diachrony->render) has no implementation; the daemon hot path has no LLM call, and also no linguistic lowering.
- LLM prompts (markdown) driven by hand or Codex CLI: prompts/nibu-cultcache-target-{lowering,critic}.md; examples/persona-lowering/metame-casual-pre2025/{persona-interlingua-prompt,weksa-lowering-prompt,lowering-prompt}.md; run-metame-persona-golf.mjs real runs. Metame evaluation.yaml: exact_matches 2, plausible_matches 4, "blind: false" (answer key was seen). Ground truth is held-out Discord messages by Metacrat (examples/persona-lowering/metame-casual-pre2025/ground-truth.yaml; not read; README says "held-out Discord messages authored by Metacrat before 2025").
- Hand-authored fixtures (outputs, not code): examples/english-lowering/nibu-proof-001 (interlingua, projected-context, lowered-output, pronunciation-plan, utterance-embedding), examples/multilingual-lowering/nibu-proof-001/realizations.yaml (en/pt-BR/ja), examples/cultcache-lowering/nibu-proof-001/{en-transatlantic,ja-JP-kanto-contemporary,pt-BR-vanilla-brazilian}.yaml + critique/, examples/persona-lowering/brokkr-edda-voice/{interlingua,lowered-output,lowering-trace}.yaml (one item `command-without-receipt`; trace names activated axes translated_edda_plainness, artifact_testimony, bargain_and_consequence), examples/speech-provider/mimo/nibu-proof-001/en-US-request.yaml.
- Target-language ontology data (YAML, "profiles the lowerer must inspect"): data/target-language-ontology/{en-US,en-US-metacrat-casual-pre2025,ja-JP,pt-BR,pt-BR-overlays,pt-BR-umbanda}.yaml. Cultural ontology: data/cultural-ontology/{catalog.yaml, cultures/ x4, subcultures/ x2, referents/ x2, persona-stacks/brokkr.yaml}; typed copy in .weksa/cultural-ontology/starter-targets.cc (binary CultCache). Docs: docs/{multilingual-utterance-lowering,flavored-english-lowering,english-pronunciation-lowering,mimo-tts-lowering,utterance-embedding}.md; packet receipts schemas/{utterance-lowering-packet,english-lowering-packet,mimo-tts-request,target-language-cultural-ontology,cultural-ontology-profile}.md. Lowering packet id: `weksa.utterance_lowering.v0`.
- Alien-language side: settings/eusocial-interbeing/language-design.md, settings/rust-to-dust/language-design.md are design prose; docs/language-model.md (1 KB), no engine.
- AquaSynth path: tools/emit_tiny_panphon_handoffs.py (hand-maintained PanPhon table; fixtures under examples/speech-training/tiny-panphon-v0.1/ refer to lost EpiphanyAgent path).
- Prior art pointer in repo: docs/best-modern-approach.md; docs/persona-finetuning-dataset-exploration.md.
## 4. Daemon and Verse surface (item 4)
- Contract: docs/verse-service-contract.md. Commands listed: intent.create, intent.review, utterance.lower, handoff.export, speech_provider.mimo.voicedesign, provider.advertise. Only `speech_provider.mimo.voicedesign` has a handler (weksa-daemon.mjs:60-87 startup; handler :640-704). Doc says other witnesses (`.weksa/intent/*.cc`, pronunciation/, handoff/, traces/, projects/) are "what the daemon must commit when the runtime lands": no code writes them.
- Transport: `cultnet.transport.rudp.v0`, command ingress rudp://127.0.0.1:8813, schema `weksa.mimo_voicedesign_command.v0` (commit e9a00e8, 2026-06-23). Health to Idunn over RUDP 127.0.0.1:17870 contract `weksa.cultnet-rudp-provider-health` (3c24457, 2026-06-16); optional Odin CultMesh RUDP 127.0.0.1:17871; command boundary + transport profile records into `.weksa/provider-advertisement-store.cc` (54272da). Witness `.cc` files other than the store are YAML text via `toCultCacheText` (:1035-1078) per census.
- Idunn target: gamecult-ops/scripts/idunn/idunn-deployment-targets.ps1:62-72: Id `weksa`, LocalPath `$projectsRoot\weksa`, Service "weksa daemon", Health `daemon-published-rudp:weksa.cultnet-rudp-provider-health`, Restart `$repoRoot\scripts\restart-weksa.cmd`. docs verse contract: target `weksa` in `starfire-local` swarm profile (Starfire, not Yggdrasil). inventory.md:27-31 records an observation dated 2026-06-11 (http /health, since deleted).
- Yggdrasil (ssh ygg, read-only, 2026-10-29): `systemctl list-units --all | grep -i weksa` = 0 lines. `idunn status | grep weksa` = no output. Only files named weksa there: /srv/build/Odin/scripts/{health,restart}-weksa.cmd and /srv/voidbot/source-repos/Weksa(+/.weksa) (voidbot source index copy). Not running on Yggdrasil.
- Starfire (this machine, 2026-10-29): no node process with "weksa" in its command line; no UDP listener on 8813; no .weksa/weksa-daemon.pid. gamecult-ops/docs/repo-census-2026-09/repos/weksa.md says the same for the census date ("no Weksa daemon process or PID file was present"). Starfire is the host for the `starfire-local` profile; I did not check Idunn's Starfire state.
- Eve dependencies [local tree / d16cff5]: weksa-daemon.mjs:160 (writes eve-surfaces.cc), :167 (eve-operator-surface.json), :258, :284-286 (publications), :331/:477 (cultMeshAddress asgard.starfire.weksa/eve/operator), :394-433 (defineDocumentType gamecult.eve.surface_state / interface_binding / provider_advertisement, `schema:{parse}` no-op), :610-639 buildOperatorSurface (`gamecult.eve.surface.v1`, surfaceId weksa.eve.operator.v0); tools/export-provider-advertisement.mjs (16 Eve hits); docs/fixtures/weksa-provider-advertisement.json (runtime-read at :473-505); docs/verse-service-contract.md "Eve Surfaces" (4 `weksa.eve.*.v0` ids). [origin 55c75e3] removes these: diff shows 7 files, -265/+14 (README, architecture, fixture, roadmap, verse contract, weksa-daemon.mjs, export tool). Mind: `thing2:follow_up:weksa-operator-view-on-thing2` (admitted 2026-10-22) says quarantined publisher is at tag parked/weksa-eve-surface (which points at d16cff5, the pre-quarantine head) and main publishes typed state only (true of origin/main, not of the local checkout).
- CultLib usage: via sibling-path NODE_PATH, no manifest. Imports: `cultnet-ts` {CultNetRudpSession, decodeRudpPacket, encodeCultNetMessageForWire, encodeRudpPacket, parseCultNetMessage}; `cultmesh-ts` {CultMesh.createNode, publishRudpDocumentOnce}; `cultcache-ts` {defineDocumentType}. CultLib today (HEAD last commit 2026-10-28): cultcache-ts 0.14.2, cultnet-ts 0.3.0, cultmesh-ts 0.2.0, each with an "Unreleased / Breaking" changelog section; those symbols all still exist in src (cultnet-ts/src/rudp.ts, contracts.ts; cultmesh-ts/src/index.ts:5371 createNode, :5903 publishRudpDocumentOnce; cultcache-ts/src/document.ts:12). cultmesh-ts Unreleased entries remove catalog `asSchemaAlias`/`parse` option; I did not diff against weksa's use. Last verified by weksa: unknown (no run since 2026-09). CultLib docs list weksa as a TS consumer (docs/typescript-quic-realtime-cut.md:1517-1519, 2079; docs/cultcache-union-parity-map.md:234).
- Daemon-state is regenerated: store `rm`'d at every refresh (weksa-daemon.mjs:325, 363) per census.
## 5. Persona / Face (item 5)
- .voidbot/README.md + state/README.md: Weksa's repo Face; "nerdy anthropologist of language systems" (user-provided seed). State: `.voidbot/state/weksa.cc` (52 KB, CultCache binary, VoidBot self-state types: void.self_profile, void.thought_memory, void.agency_pressure, void.face_affect, void.candidate_interventions, void.moderation_cursor, void.scheduled_runtime, void.speech_receipts; found via `strings`). No `gamecult.persona_state.v0` document anywhere in the repo or `.weksa`. `.voidbot/state/weksa-birth-seed-operations.json` (25 KB, seed provenance). `.voidbot/voice/identity.json`: id weksa, grants discussion/rumination/repo_read/repo_propose/discord_text/aquarium_embodiment; authority "propose"; voice/weksa.png.
- Stale paths: READMEs and identity.json cite `E:\Projects\VoidBot\scripts\void-self-state.mjs`, `E:/Projects/weksa`, `E:/Projects/weksa/.voidbot/state/weksa.cc` (identity.json:repoPath/statePath/body). 41 files in repo contain `E:\Projects` or `C:\Users` paths (daemon, ps1/cmd launchers, harnesses, examples, settings/, prompts/ incl. `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`). Zyphos and Fensalir docs also point at `E:\Projects\weksa`.
- Standard: F:\Projects\gamecult-ops\docs\persona-state-standard.md still names VoidBot as read/projection/migration owner (persona-state-service.ts, persona-standard-state-projector.ts, `persona:export-friendly`, `persona:migrate-portable`) and Epiphany schema path (Epiphany now archived).
- Mind (eureka-state): `eureka-substrate:cut_spec:cut-doctrine-persona-in-huginn.r3` (rewrites that standard: Huginn holds canonical gamecult.persona_state.v0 per instance with a mind, VoidBot keeps projection and repo Personas without a mind); `eureka-substrate:cut_spec:cut-huginn-persona-state.r3` (PersonaGet/PersonaPut, tools persona_get/persona_put); `eureka-body:follow_up:weksa-mind` (2026-10-09, InForce: "Open a second Huginn instance for Weksa's Persona", waits on single-instance persona state, launch, waker); `eureka-body:ruling:persona-per-mind`; `eureka-substrate:ruling:operator-persona-in-mind`. Huginn doc F:\Projects\Huginn\docs\swarm-revival-target.md:170-179 "Slice 1: Weksa alive" (huginn@weksa, own port, EUREKA_INSTANCE=weksa, brake held, import Weksa's remnant as first Persona put; done when her Persona turn runs under its brake), :229 lists Weksa as first remnant row (`weksa-mind`). Whether those prerequisites have landed: not checked.
## 6. Consumers (item 6) (rg per repo dir under F:\Projects, worktree dirs skipped; terms weksa / meaning packet / interlingua_version)
No other repo contains `interlingua_version` or the string `weksa.interlingua_packet`. Hits:
- VoidBot (live code; last commit 2026-09-13): packages/core/src/weksa-speech.ts (`WeksaSpeechClient`, `WeksaMimoReceipt`), apps/worker/src/index.ts:26-30,76, apps/bot/src/metame-owner-voice-bridge.ts. Sends mimo voicedesign commands to Weksa RUDP (`commandUri`). VoidBot is being retired.
- AquaSynth: README.md:121,136 (accepts Weksa utterance handoff; fixtures tests/AquaSynth.Dsl.Tests/Fixtures/Speech/WetVoice01/ Weksa-owned), docs/audio-doctrine.md:195, docs/daemonic-swarm-prior-work.md:81. Doc/fixture contract; no live runtime protocol (census).
- Vili: README.md:21, scripts/vili-daemon.mjs:214 (string "Weksa interlingua lowering" in capability list).
- Odin: docs/transport-shortcut-inventory.md:49,82 and scripts (Weksa health/restart cmd on Ygg build dir); Idunn: docs/route-continuity-cut.md:2439 (weksa deploy/restart actuators scripts); Heimdall/vendored CultLib: swarm-documents.ts:75,416 (weksaOperatorState/CommandBoundary/TransportProfile definitions, one `null`); gamecult-ops (inventory, idunn-deployment-targets.ps1, repo-census, eve census docs); Eve docs/thing-campaign.md (weksa listed as provider to migrate; commit d16cff5 recorded); Epiphany notes (Weksa lowering receipts, historic); Huginn docs/swarm-revival-target.md; CultLib docs (consumer counts above); Bifrost docs/agent-transport.md:45 ("Universal utterance schema with Weksa" issue title); StreamPixels/vendor/CultLib docs.
- Setting repos: Zyphos (state/map.yaml:7,32, docs say future inhabitant names come via weksa), Fensalir docs/zyphos-eusocial-sync.md:78, Ghostlight experiments mention Zyphos only.
- Weksa also consumes: AetheriaLore (Nibu state path in prompts), Brokkr persona stack, VoidBot projector (Metame golf).
Live status of consumers: VoidBot WeksaSpeechClient is live code against a daemon that is not running; others are docs/fixtures/references.
## 8. Other
- gamecult-ops/docs/repo-census-2026-09/repos/weksa.md (2026-09 census, 68 weksa mentions) is the fullest third-party map: partial CultCache/CultNet/CultMesh conformance, Persona state "absent", load-bearing JSON (provider fixture), duplicate RUDP helpers, affect-tuning loop not consumed by lowering.
- gamecult-ops/docs/eve-crusade-coordination.md:105-114: weksa classed a daemon candidate (commit d06b9f5 "Promote Weksa intent daemon"). Eve census: docs/research/2026-10-22-eve-publisher-census.md:98-102.
- `.gitignore` excludes `.weksa-runs/`, `examples/persona-lowering/*/runs/`.

View file

@ -25,10 +25,9 @@
IPA.
- Add one toy utterance embedding handoff fixture, then replace hand-authored
values with the AquaSynth-owned learned encoder.
- Add a daemon loop that commits conversational intent, pronunciation plans, and
utterance handoffs as typed CultCache witnesses.
- Publish the provider advertisement and first Eve operator/intent-review
surfaces through CultMesh for Odin discovery.
- Follow the cut sequence in
[the lowering map](./targets/interlingua-lowering-map.md): packet store,
lowering, the golf loop, then a service if one is earned.
- Preserve the first English-only lowering proof under
`examples/english-lowering/`, and the shared multilingual proof under
`examples/multilingual-lowering/`.
@ -39,7 +38,7 @@
- Render glossed output through grammar, morphology, phonology, and diachrony.
- Emit a trace explaining which stage made each decision.
- Keep runtime LLM-free; model use belongs in authoring and critique.
- Keep CLI tools, fixtures, daemon jobs, and Eve commands on the same commit and
- Keep CLI tools and fixtures on the same commit and
derivation paths so Weksa does not grow split-brained intent truth.
## Later

View file

@ -0,0 +1,406 @@
# Map: interlingua lowering (campaign `weksa-lowering`)
Prose map for the campaign whose target is
[interlingua-lowering.md](interlingua-lowering.md). Rulings, questions,
follow-ups and cut specs live in the Eureka mind under `weksa-lowering:`; this
page is the model page and the cut sequence, and cites them by label. Written
by Imagination for the Eureka-area Self, 2026-10-29.
## The design in one paragraph
The interlingua spec has two layers. The **schema** is Rust types: packet
families, node ids, role slots, value kinds. It changes rarely and by commit.
The **inventory** is data in CultCache: the concepts, frames, roles, relation
types, speech acts and nuance features (hedge, certainty, warmth, irony and
whatever the loop finds missing) that the schema's slots name by id. Every open slot in a packet is an inventory id; a referent names an entity by id
from the entity store, and a packet's only free text is the trace. No door judges text for leakage:
what each model call may see, and what a score may count, own that
(`weksa-lowering:ruling:leak-owned-by-flow-and-scorer-3`). The golf loop improves the spec
mostly by growing and refining the inventory, which is fast; schema changes are
the slow path. Concepts are open-class and grow forever; the other families are
closed-class, and convergence is measured on them
(`weksa-lowering:ruling:convergence-test`). This split was
`spec-is-schema-plus-inventory`, now carried by `leak-owned-by-flow-and-scorer-3`.
## Model page
Every persistent kind, what names it, its life, and who decides. "Derived"
means no store holds it and nothing writes it.
| Kind | What names it | Lifecycle | Who decides |
|---|---|---|---|
| Packet | Key `packet:<16 hex>`, an opaque digest of item, inventory revision and encoder (the item key lives in `provenance.corpus_item`, which the lowerer never sees); CultCache type `weksa.interlingua_packet`, carrying `schema_version` and `inventory_rev`. Every id inside is an opaque form type (`n<k>` nodes, `encoder:<16 hex>`, canonical `<family>:<n>`). From cut `speaker-state` (schema v4) it also carries the utterance **stance**: the speaker's felt affect and restraint, as inventory features of slot `Stance` (see Speaker state below) | Minted by an encoder for one corpus item under one spec version; immutable; a new spec version means a new packet, never an edit. Admitted only through `PacketStore::admit`, which validates against the inventory store at the packet's revision | The encoder writes; the form types refuse malformed ids at construction and load, the validator refuses broken references and over-capacity packets: a feature at most once per slot, at most 64 nodes (`typed-packet`); nothing else writes packets |
| Schema version | `weksa.interlingua_packet.v<N>` constant in `crates/weksa`; published JSON Schema at `schemas/cultnet/` | Bumped by a commit that changes the Rust types; the published schema is regenerated in the same commit and a test fails on drift | A cut (Hands, under a spec); never the loop |
| Inventory entry | Key `<family>:<n>`, `n` canonical decimal >= 1 (families: concept, frame, role, relation, speech_act, feature, referent_kind, extension); type `weksa.inventory_entry`, with a bounded gloss, value kind (option labels bounded), the slots a feature may sit in (`Discourse`, `Referent`, `Predication`, `Relation`; `Stance` from cut `speaker-state`, `Speaker` and `Scene` from cut `speaker-scene`), `introduced_rev` and optional `retired_rev`. Speaker axes, scene relations and affects are features of those slots, so the loop grows them like any closed-class family and convergence covers them | Added or retired by an inventory revision; never edited in place (a changed meaning is a retire plus an add); a retired id still resolves for packets of earlier revisions | The loop's accepted rounds, whose minting call the context builder assembles from Train items only; Self on merge to main. Admission checks form, not text |
| Inventory revision | Key `rev:<n>`, type `weksa.inventory_revision`, with parent, change list and `frozen` | Appended per accepted round; exactly one revision is ever marked frozen, by the convergence test | The loop's accept rule appends; the convergence test freezes; Self records the freeze as a ruling |
| Target profile | Locale or language-project id (`en-US`, `ja-JP`, a conlang's slug) plus a revision `profile:<language>:<n>` | Authored, revised by commit or by an accepted round that changed the lowering; each lowering names the revision it used | Profile authors and accepted rounds; never the lowerer's defaults (`persona-drives-register`) |
| Language ontology entry (cut `language-ontology`) | Key `onto:<language>:<n>`; type `weksa.ontology_entry`: conditions over feature values (stance, speaker, scene, packet features) and community ids, and the surface choice they select (layer: lexicon, grammar, discourse, phonology, orthography; a bounded descriptor and at most three bounded examples); introduced and retired by a profile revision | Added or retired by a profile revision, never edited; converted from `data/target-language-ontology/*.yaml`, which is deleted in the same cut so the YAML is not a second owner | The language's owner (the operator for her settings' languages) and accepted rounds; an ontology-minting call is assembled from Train items only, like an inventory-minting call, and its text is in the scorer's leak diagnostic. Selection is deterministic code (`select(profile, stance, speaker, scene)`); the lowerer realizes the selected choices and the trace lists their ids (`lowering-traced`) |
| Community (cut `language-ontology`) | Key `community:<slug>`; type `weksa.community`, converted from `data/cultural-ontology/cultures/` and `referents/` (YAML deleted in the same cut) | Revised by commit; a speaker names the communities it belongs to by id | The cultural ontology's author (owner-attested evidence only; Discord-only content stays deleted, `discord-only-content-deleted`) |
| Speaker (cut `speaker-scene`) | Key `speaker:<slug>@<n>`; type `weksa.speaker`: stable axes as feature values of slot `Speaker` (origin, socioeconomic standing, age cohort, education and whatever the loop adds), at an inventory revision, and an `Annotator` | A new revision appends, never edits; a corpus item and every prior turn name a speaker slug, and a round records the speaker revisions it read | The annotator it names: `PersonaSnapshot` (a projection of a `gamecult.persona_state.v0` document, naming its source document and `updatedAt`), `Operator`, `WorkMetadata` (a public-domain work's own cast list and narration), or `Model { family, generator }` (the 2b `ModelFamily`) |
| Scene (cut `speaker-scene`) | Key `scene:<group>@<n>`, the item's split group; type `weksa.scene`: participants (speaker slugs) and, per ordered pair, relation features of slot `Scene` (relative status, intimacy, in-group, audience and whatever the loop adds), at an inventory revision, and an `Annotator` | As Speaker. Keyed by the group, so a scene never straddles the split | As Speaker |
| Corpus item | Key `item:<source-slug>:<n>`, `n` canonical decimal >= 1 (no sign, no leading zero, from cut 2b); type `weksa.corpus_item` with text, language, speaker, split group, prior turns, source. Source `ModelWritten` carries a `ModelFamily` enum, the generator string as the fixture wrote it, and a prompt digest (cut 2b). The item carries no speaker state of its own: its speaker slug and group name the Speaker and Scene documents | Admitted once; the whole item is immutable (re-adding an identical item is a no-op, any difference is refused; a correction is a new item that `supersedes` the old); text already held under another key is refused unless the new item supersedes that key (cut 2b); withdrawn, never deleted, so old scores stay explainable | Importers and `weksa corpus add`, under `corpus-cast-plus-public-domain`; the source enum has no variant for Discord or any training-forbidden source (`corpus-clean`) |
| Split assignment | Derived: `split(scheme, item.group)` | Fixed for a scheme; a new scheme is a new constant, never a reassignment. A model-written item whose group derives test is in no split; a superseding item keeps its predecessor's group | The hash function. Nobody assigns by hand, so there is no writer to forbid |
| Loop round | Key `run:<label>:round:<n>`; type `weksa.loop_round` with spec version, profile revisions, model ids and prompt digests, sampled train items, proposed change, validation scores before and after, outcome | Opened, scored, closed accepted or rejected; immutable once closed. Lives in the run store on Yggdrasil | The loop driver's accept rule (validation composite improves past the measured noise floor and no metric regresses past tolerance) |
| Score | Key `<lowering-key>:<metric>@<metric-version>`; type `weksa.score` | Immutable; a new metric version scores again under a new key | The scorer, deterministic given the metric version and the embedder's model id. Cut `scorer` computes scores as values; cut `golf-loop-en` stores them, because the lowering keys they hang from arrive with the loop |
| Lowering trace | Key `lowering:<packet-key>:<profile>@<rev>:<lowerer-digest>:<sample>`; type `weksa.lowering` holding the surface text and its trace together | Immutable. A surface text cannot exist without its trace, because they are one record (`lowering-traced`) | The lowerer emits; the lowerer sees the packet without its `trace` (the input type has no such field), never the corpus item |
| Conlang lexicon entry | Key `lex:<language>:<concept-id>`; root form, morphology class | Root placed at its concept's embedding when the concept first needs a form in that language; stable once accepted; diachrony derives later forms, it does not overwrite | The language project's owner: the operator for her settings' languages, the loop proposing roots for her review |
| Conlang grammar and phonology data | `<language>/grammar`, `<language>/phonology` documents per language project | Revised by commit; every lowering names the revision | The language project's owner |
| Training pair (later) | Key `pair:<frozen-rev>:<item-id>:<profile>` | Created only by re-encoding the whole corpus under the frozen inventory revision; never before the freeze (`staged-distill-every-language-equal`); split inherited from the item | The re-encode job, which can only be constructed from a frozen revision |
## Authority map (the packet boundary)
- Owner: the validator in `crates/weksa` owns whether a packet exists.
- Inputs: the packet, the inventory at the packet's revision.
- Outputs: an admitted packet, or a typed refusal naming the rule.
- Derived state: split assignment (from the item's group); the lowering view
of a packet (the packet minus its trace).
- Forbidden writers: the Node daemon's `decomposeThoughtIntoInterlingua` and
`normalizeInterlinguaPacket` (parked, cut `park-node-body`); the Metame
harness's packet-like YAML (parked); any loop step that writes a packet
without the validator.
- Shared paths: the loop's encoder, the CLI, tests and the future live
lowering all admit packets through the same `validate` call.
- Deletion line: the Node body goes before the crate lands, so no second packet
shape survives on main.
## Speaker state (`persona-drives-ontology`)
Operator, 2026-10-29: "The specific choice of words of a character can tell you
exactly where they were from, their socioeconomic status, their relative status
within the scene, and both how they feel and how restrained they are being with
those feelings. The linguistic ontology holds this data and drives it from the
persona." Speaker state is three layers, and each lives where its writer and
lifetime put it:
| Layer | Examples | Writer | Lifetime | Lives in |
|---|---|---|---|---|
| Stable axes | origin and community, socioeconomic standing, age cohort, education | the persona (live) or an annotator (corpus) | the speaker's | `weksa.speaker` |
| Scene relations | relative status, intimacy, in-group or out-group, who overhears | the scene (live) or an annotator (corpus) | the scene's | `weksa.scene` |
| Utterance stance | felt affect, restraint | whoever writes the utterance: the persona agent (live), the encoder (corpus) | the utterance's | the packet (`stance`) |
**Affect and restraint live in the packet, as stance, not in a speaker-state
document.** A per-utterance speaker-state record would have the packet's key,
the packet's writer and the packet's lifecycle (minted once per utterance under
one spec version, immutable), so it would be a second document under one
authority; it collapses into the packet. It also has to be golfed: in the loop
the encoder reads felt affect and restraint off the corpus line, as it reads
the meaning, and the loop improves that reading round by round. A fixed
annotation could not be golfed. Live, the agent writes its own packet
(`campaign-direction`), so the persona drives stance through it. The stance is
a separate section from the meaning (`referents`, `predications`, `relations`):
a feeling the speaker holds back is not a claim, and `packet-owns-meaning`
still forbids a lowering to add one. "I am angry" said outright is a
predication; anger leaking through clipped syntax is stance.
The stance carries **felt** affect and restraint, never displayed affect. How
much of a held-back feeling shows, and how, is a cultural display rule, and
display rules differ by culture (Ekman and Friesen 1969; Matsumoto's
cross-cultural display-rule studies; from recall, not a search this pass). If
the packet carried displayed intensity, the English reading would be baked in
and a ja-JP lowering would apply restraint twice. So each language's ontology
maps felt affect, restraint, scene and speaker to its own surface, which is
`staged-distill-every-language-equal` applied to affect. Whether restraint is
one grade per utterance or one per felt affect is the `speaker-state` spec's
call; the loop can split it later if one grade loses.
**Demotions.** `formality`, `warmth` and `intensity` (seed features 8, 9, 10,
slots Discourse and Predication) are no longer packet owners. Formality is
derived from scene relations and stable axes by the target language's
ontology; displayed intensity is derived from felt affect and restraint; warmth
toward the addressee is a felt affect in the stance. Cut `speaker-state`
removes them from the seed (no committed packet store exists and nothing
outside the seed and the standard names them; Body facts), so no packet can
set formality beside the ontology. `hedge`, `certainty` and `irony` stay: they
are the speaker's epistemic and pragmatic stance toward the content,
language-neutral, not register.
**Corpus items carry speaker state as typed ontology values, never free text.**
The item names its speaker slug and group; the `Speaker` and `Scene` documents
hold feature values of the `Speaker` and `Scene` slots (grades, option labels,
flags, and later community ids), validated against the inventory by the same
feature check the packet uses. No field is free text, so no wording can reach
the lowerer through annotation (`no-surface-leak`). Cast items: the speaker
document is a `PersonaSnapshot` of the cast member's persona state where the
standard has the value, and annotated where it does not (below). Annotations of
stable axes read the whole work or persona, so a speaker's axes are not drawn
from one scored line; a scene's relations read the scene, which includes its
scored lines, and are bounded typed values, as a live scene would supply.
**Persona state today has no field for origin, community or socioeconomic
standing.** `gamecult.persona_state.v0` (Body facts) carries open trait maps
(`activationProfile.voiceStyle` and five more), `moodDimensions`, `socialBonds`
(trust, tension, intensity) and `statusReads` (authority, trust and others).
So a `PersonaSnapshot` projects mood-like and relation-like values, and the
stable axes the standard lacks are annotated. Whether the standard gains a
typed speech-community reference or Weksa keeps the persona-to-community
binding (`data/cultural-ontology/persona-stacks/`) is decided when
`live-lowering` is mapped (follow-up `persona-state-speech-community`); the
corpus path needs neither. Putting Weksa feature ids into the open
`voiceStyle` trait map is rejected: it would make an extension field portable
authority, which the standard forbids.
**Public-domain annotation and the annotator ceiling.** A public-domain play
has tens of speakers and scenes, so annotation is per speaker and per scene,
not per line, and costs nothing per round. Stable axes come first from the
work itself (`WorkMetadata`: the cast list, narration, stage directions). The
ceiling: the loop can learn surface choices only as well as the speaker and
scene values describe the line, so annotation errors become ontology errors (a
servant labelled gentry teaches the ontology the wrong deference). And when a
Claude annotator, a Claude encoder and a Claude lowerer share one family's
idea of what low status sounds like, the loop agrees with itself and scores
the agreement: the round-trip inflation the target already records. Every
annotation names its annotator, so the scorer can report a test composite over
operator-attested annotations alone. Who annotates is question
`pd-speaker-annotation`.
## Scoring (`golf-metric-automatic`, `conlang-scoring`, `meaning-metric-embedding-cosine`)
- English against its corpus item: chrF (character n-gram F-score, char order
6, beta 2, spaces removed; parity vectors against sacrebleu 2.4.3 in Body
facts) and meaning cosine (sentence embeddings from qwen3-embedding:0.6b,
served by Ollama on Yggdrasil; the adapter sends `options.num_thread`, Body
facts). Probe on one reference: hedged paraphrase
0.948, flattened claim 0.643, register-shifted 0.720, negation 0.496.
BERTScore proper needs per-token transformer embeddings and a torch stack;
it is deferred, not adopted.
- Every language by the same meaning cosine: the embedder reads the output
directly when it can (natural languages, since the embedder is multilingual);
for a conlang it reads the deterministic gloss made from the language's own
lexicon (Weksa owns the lexicon, so reading it back needs no model). Plus
lexicon adherence for a conlang: the share of tokens that parse against its
lexicon and morphology.
- A style axis follows as its own cut, behind the same scorer port.
- Names and leak (`leak-owned-by-flow-and-scorer-3`): no packet field carries
free text, so the scorer masks nothing. A referent names an entity by id
(`EntityId`, `entity:<slug>`); the entity store (`weksa.entity`) holds each
entity's forms per language, citation form first, then inflected or
particle-bound forms (data, as the inventory is). Entities are minted at
intake: cast entities from the cast fixtures, public-domain entities by the
importer, and in live use by the persona before it names one. A corpus item
carries its name spans (byte ranges of its text, each naming an entity and
on extended grapheme cluster boundaries of its NFC text and equal to one of that
entity's NFC forms in the item's language; an entity holds at most 8 languages
of BCP 47-form tags, 8 forms each, a form at most 64 bytes with no invisible format character), and its speaker
and prior-turn speakers are entity ids. The packet store refuses a packet
whose referent cites an entity the store lacks, and a packet with a
`corpus_item` (required, typed `CorpusItemId`) that cites an entity neither annotated on that item nor one of
its speakers: an id-set check, not a text rule. The scorer reads only the
output and the reference and scores the whole of each. An honest name earns
its credit ("Brokkr came back." against itself is 100), a wrong one loses it,
filler costs what filler costs (Soul scorer s2: "Darling, I am truly not
angry, darling." scores 46.84 whole; the output-only mask, fed the filler as
a name, had raised it to 86.78), and no rule depends on script or word
segmentation, so Thai, Korean particles and Turkish or Finnish suffixes are
scored by the same character n-grams as English. Why this is not a leak:
leakage is information the learner has that it would not have at prediction
time (Kaufman et al., TKDD 2012). A live persona knows which entity it means;
the annotation gives the corpus encoder exactly that, fixed at intake before
any round and written by no loop writer, and the encoder chooses only which
of the item's own ids to cite. The free text left is inventory labels and
glosses (Train-only, the context builder's) and the trace (never lowered or
scored). Diagnostics, reported and never gates: max pairwise chrF of minted
text against held-out items; novel coverage pooled per order, for n = 1, 2
and 3 separately, the covered share of all novel n-grams (word n-grams of a
held-out item that occur in no Train item) over a split's items, with the
worst item at n >= 2. Keys split on `_`, `.` and lower-to-upper case
transitions as well as UAX #29 boundaries, so a snake_case `OptionLabel`
reads as words. Honest glosses of single words saturate n = 1 (the 54 seed
glosses read max 1.000, s2), so the leak signature is pooled n >= 2.
Costs: a live persona mints before it names (one typed write, which also
buys coreference and one rendering per language); public-domain intake
annotates name spans (follow-up `pd-name-spans`, annotator per
`pd-speaker-annotation`); the lowerer inflects a name from its stored forms
or adds one, traced. Prior art (recall): AMR keeps `:name` strings inside
the graph beside a `:wiki` link, and AMR-to-text systems copy those strings;
Abstract Wikipedia renders constructors over Wikidata item ids, with labels
per language and Lexeme forms for inflection; Grammatical Framework lexicons
hold proper names (`PN`) with per-language inflection tables; referring
expression generation (Reiter and Dale 2000; the GREC tasks, 2008-2010)
chooses a name, pronoun or description from an entity id and the discourse.
Rejected paths: text rules at the inventory and packet doors (Soul
packet-store s2); any mask of names in the scored texts: masking reference
and output (s1: "Kick Bucket" took chrF 53.73 to 100) and masking the output
only (s2: named filler took chrF 46.84 to 86.78, cosine 0.728 to 0.886);
equal-length placeholder masking (bounds chrF, not cosine, and still pays
nothing for an honest name); character 6-gram coverage (one-word glosses
read 0.000); max-over-items coverage (saturates on honest runs).
## Body facts (probes of 2026-10-29, map pass for speaker state, 2b and scorer)
- Weksa main `869c82b` (packet-store, 2b corpus labels, 2c entity store, scorer
landed; packet schema v3). Annotators land with their consumers: `Operator`
and `Model { family, generator }` with cut `speaker-scene` (the cast seeds are
Model-annotated and an operator attests by appending an `Operator` revision),
`WorkMetadata` with `pd-importer`, `PersonaSnapshot` with `live-lowering`.
- UAX #29 word probe (unicode-segmentation 1, scratch crate, 2026-10-29):
`unicode_words()` counts (whitespace count in brackets) "Brokkr" 1 [1],
"Mary Ann Evans" 3 [3], "de la Cruz y Garcia" 5 [5], Thai "สมชาย" 5 [1],
"สมชาย ใจดี" 8 [2], "山田太郎" 4 [1], "李小龍" 3 [1], "ブロッカー" 1 [1],
"김민준" 1 [1], "Jean-Luc" 2 [1], "O'Brien" 1, five letters glued by ZWSP 5 [1],
glued by WJ U+2060 1 [1]. Without a dictionary UAX #29 splits Thai and Han
per character, so a 4-word UAX #29 bound refuses ordinary Thai names (question
`form-word-bound`, recommended bytes-only). NFKC, default case fold, NFKC,
alphanumerics only: "fine" -> "fine", "Brokkr" -> "brokkr", ZWSP and WJ drop out.
- Weksa main `28284cc`: `cargo test -p weksa` on Yggdrasil (ygg-verify, rust
image) 73 passed. `weksa corpus dump` holds three items: `item:brokkr:1`
(group `brokkr-edda-voice`, Validation, `ModelWritten { model_family:
"local-source-aware-draft" }`), `item:nibu:1` (group `nibu-proof-001`, Test,
`CastAuthored`, fixture
`examples/english-lowering/nibu-proof-001/lowered-output.yaml`, which records
no author) and `item:nibu:2` (same group, excluded, `ModelWritten {
model_family: "unrecorded-agent-run" }`; that string exists only in
corpus.cc). The Brokkr fixture's generator was `codex-local-source-aware-draft`
at `4197123` and reads `local-source-aware-draft` on main.
- `CorpusStore::add` compares only `text` (`store.rs:166-171`): re-adding a key
with the same text and a different source returns `Unchanged` and writes
nothing, so a relabel through `add` silently does nothing. `key_slug`
parses `n` with `u32::parse`, which accepts `01` and `+1`.
- Inventory rev 1 (seed): features 6-11 are hedge, certainty, warmth,
formality, intensity (Grade) and irony (Flag), slots Discourse and
Predication. Nothing outside the seed and `docs/interlingua-standard.md:179-180`
names warmth, formality or intensity; no committed packet store exists.
- Yggdrasil's Ollama (`127.0.0.1:11434`) is the Docker container `ollama-cpu`,
Huginn's embedder (runbook `gamecult-ops/runbooks/huginn-yggdrasil.md`,
ruling `eureka-body:ruling:huginn-embedder-yggdrasil-primary`). `/api/embed`
with an `input` array returns unit-norm 1024-dimension vectors. Under host
load (load average 18-24 from ygg-verify rustc jobs) and no `num_thread`: a
batch of 6 took 21.5 s and 29.8 s, a batch of 32 timed out at 120 s. With
`options.num_thread` 8: a batch of 6 in 2.1 s, a batch of 32 in 5.8-6.4 s
(0.18-0.20 s per item); `num_thread` 4: 8.8 s for 32. Cosine of "Take your
hand off that control." against its lowercased, unpunctuated form: 0.943.
One server serves Huginn and the loop, so a 32-item batch holds it about 6 s.
- chrF parity: sacrebleu 2.4.3 `sentence_chrf(hyp, [ref])` and a Python
reimplementation (orders 1-6, per-order precision and recall averaged over
the orders where both sides have n-grams, F-beta with beta 2, spaces
removed) agree to 4 decimals on six pairs (reference / hypothesis / score):
"Take your hand off that control unless you want your nervous system to become my least useful circuit diagram." /
"Take your hand off that control, unless you'd like your nervous system to be my least useful wiring diagram." / 74.4498;
"The command has no receipt. It is not truth." / "The order carries no receipt, so it is not true." / 45.7164;
"I am not angry." / the same / 100.0;
"Forgive me, sir." / "Get out." / 3.9683;
"その制御に触るな。" / "その操作盤に触らないで。" / 14.2931;
"Well, aren't you a sight for sore eyes, love." / "" / 0.0.
- Scorer s1 follow-up probe (sacrebleu 2.4.3 in python:3.12-slim on Yggdrasil;
qwen3-embedding:0.6b, one 8-text call). sacrebleu strips every whitespace
char before gramming: hypothesis "a b c", "a b", "ab",
"a…b", "a
b c", "line one
line two", "その 制御" against the
same text with that char removed (or "line one line two") all score 100.0;
"a​b" against "ab" scores 45.4545 (zero-width space is not
whitespace). Name scoring, output masked and reference whole (hypothesis /
reference / chrF / cosine): "He is going to die tomorrow." / "He is going
to kick the bucket tomorrow." / 53.7347 / 0.9081; "He is going to the
tomorrow." (named or omitted) / same / 55.9583 / 0.8246; "Not ever again."
/ "Never again." / 85.1384 / 0.9169; "." (named) / same / 11.1111 /
0.6456; "came back." / "Brokkr came back." / 56.6137 / 0.7364; "Kestrel
came back." / same / 51.3670; "not go." / "Will will not go." / 33.6248.
- `gamecult.persona_state.v0` (`F:\Projects\Epiphany\schemas\cultnet\gamecult.persona_state.v0.schema.json`):
required `activationProfile` (six open trait maps of mean, plasticity and
current activation) and `affect` (`needs`, `socialBonds`, `statusReads`,
`moodDimensions`, `socialBiases`, `doctrineStances`);
`presentation.voiceSummary` is free text; no field names origin, community
or socioeconomic standing; `extensions` are non-authoritative by the
schema's own description.
## Where things live and run (`rust-single-crate`, `loop-runs-on-yggdrasil`)
- Code: one Cargo workspace at the repo root, one package `crates/weksa` (a
library and the `weksa` binary). CultCache through `cultcache-rs` pinned by
git rev, as Huginn pins it. No daemon: the loop is a batch command. A daemon
is earned later only by the live lowering service, if isolation protects a
named invariant there.
- Stores: `data/interlingua/inventory.cc` and `data/corpus/corpus.cc` in the
repo (small, reviewed with `weksa dump`); run stores (rounds, lowerings,
scores) on Yggdrasil under the loop's state root, not in git.
- The loop runs on Yggdrasil as a detached unit with a log and a status record.
Starfire runs none of it. Model calls go through a `ModelPort`; its first
adapter drives `claude -p` on the operator's subscription. Never OpenAI
(`eureka-body:ruling:no-return-to-openai-supplier`), never Discord data
(`gamecult-chat:ruling:discord-taints-training`).
## Convergence (`convergence-test`)
The spec has converged when, on a panel of en-US, ja-JP and the first conlang,
for 20 consecutive rounds no accepted change to a closed-class inventory family
or the schema raised the validation composite past the noise floor, and at that
checkpoint the test-split composite is within the noise floor of validation.
The test split is read only at checkpoints. Self then records the freeze, and
the training set is the whole corpus re-encoded under the frozen revision.
## Lowerer first cut (`golf-loop-lowerer-cut`)
Authority map for the smallest lowerer, written before its code (Imagination
pass by the Weksa Self run, 2026-11-01). The cut exists to give the golf loop a
measurable baseline from a packet: `lower` gives text, `score` gives numbers.
| Decision | Owner | Inputs | Outputs | Not the owner |
|---|---|---|---|---|
| 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 |
| 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 |
Decisions this map makes, and why:
- **`lower` takes `&LoweringInput`, not `&Packet`.** A `Packet` carries `trace`
and `provenance.corpus_item`; the model page says the lowerer sees the packet
without its trace and never the corpus item (`lowering-traced`,
`leak-owned-by-flow-and-scorer-3`). `validate` already yields the view, so the
CLI runs `validate` and hands it over. Cut 4 narrows the door to
`PacketStore::admit`; this cut does not wait on it.
- **The frame owns the verb phrase; a predication's concept must resolve but is
not rendered.** Referent concepts supply nouns. A frame template names its
roles by inventory gloss (`{agent}`), resolved against the inventory at the
packet's revision, so a retired or re-glossed role cannot be silently
mis-keyed. A row whose gloss differs from the inventory's entry for its id is
a `LowerError`, so a table row cannot speak for a different meaning than its
id now carries.
- **Rendering rules, all deterministic and all from packet fields:** the
discourse addressee renders "you" ("yourself" when it already filled a role
in the same predication), other referents render "the <concept word>", a
predication filling a role renders "that", the speech-act row supplies an
optional opener, sentences are capitalised and end in a full stop.
Entity-bearing referents render by concept word: the lowerer does not read
the entity store in this cut (follow-up: entity forms in the lowerer).
- **No style, no register, no features.** Hedge, certainty, modality and
polarity are not rendered, so the baseline scores what meaning alone buys.
A lowerer that ignores `polarity: negative` would assert the opposite, so
`lower` refuses any packet that carries a feature, relation or extension it
does not render rather than render it wrong (`packet-owns-meaning`); features arrive as templates
learn them.
- **Deleted by later cuts, not this one:** the table in `lower.rs` and
`proof_revision()` are temporary owners. Cut 5 (`language-ontology`) moves
the table to `weksa.ontology_entry` documents selected by the target
profile; cut 7 replaces `proof_revision()` with the loop's minting round.
Nothing is deleted today because no earlier lowerer exists; the YAML
`examples/english-lowering/` hand-lowerings are fixtures, not owners, and
stay.
- **`weksa score` is a thin reader of `Scorer::score`**: `weksa score <item>...
[--output FILE]` embeds the output text once against every named item. It
stores nothing (Score documents arrive with cut 7) and gates nothing.
## Cut sequence
One line per cut; specs exist for the cuts about to run (2d and 4, siblings on `869c82b`; 2d merges first and 4 rebases onto it).
1. `park-node-body`: park the Node daemon, MiMo command, RUDP shims, launchers and the Discord-sourced Metame harness at tag `parked/weksa-node-body`; README front door tells the truth. Landed.
2. `packet-store`: Rust crate with packet, inventory and corpus types, the validator, the derived split, the stores and the `weksa` CLI. Landed at `28284cc`.
2b. Landed. `packet-store-corpus`: corpus labels tell the truth (`corpus-labels-tell-truth`): `ModelFamily` and the generator string on `ModelWritten`; `item:nibu:1` ModelWritten (Unrecorded); `item:brokkr:1` and its fixture carry `codex-local-source-aware-draft`, family OpenAi; intake refuses aliased keys, a whole item changed under its key, and a held item's text under a new key; corpus.cc reseeded. Speaker state is not in 2b: it lives in Speaker and Scene documents and the packet's stance, not on the item. Waits on 2. (spec)
2c. Landed. `entity-store`: `ProperName` and `Referent.name` deleted; `EntityId`, `Referent.entity`, the `weksa.entity` document and its store; corpus name spans and entity-id speakers; the packet store's entity checks; seed entities and spans for the cast items; schema v2. Waits on 2b. (spec r1)
3. Landed at `869c82b`. `scorer`: chrF, meaning cosine behind an `Embedder` port (Ollama adapter with `num_thread`), leak diagnostics, aggregation by derived split; library only, `weksa score` comes with the loop. h2 landed the r2 fix pass on its branch; r3 deletes the name mask (the scorer reads output and reference only) and pools novel coverage per order over word keys that split joined labels. Waits on 2c for merge order. (spec r3)
2d. `entity-door-lows`: invisible format characters refused in entity forms (ZWJ, ZWNJ, CGJ and variation selectors kept); the form length bound per question `form-word-bound` (recommended: bytes only); attested origins (`Cast { persona, fixture }`, `Live { persona }`) and `CastAuthored.author`; empty provenance strings refused at the store; S6 under NFKC plus case fold and no-alphanumeric texts refused; `next_key` refuses at `u32::MAX`; CLI flags that do not apply refuse and no error echoes a value. Owns follow-ups `entity-door-lows` and `s6-unicode-normal-form`. (spec r1)
3b. `lowerer-first-cut` (follow-up `golf-loop-lowerer-cut`): the section above. `lower`, `weksa lower`, `weksa score`, `weksa packet proof`, inventory rev 2 for `nibu-proof-001`.
4. `speaker-state`: packet `stance` (felt affect and restraint, slot `Stance`, packet schema v4); formality, warmth and intensity out of the seed and the standard; only `PacketStore::admit` yields a `LoweringInput`, and it refuses packets for withdrawn items (follow-up `lowering-input-from-packet-store`). (spec r1)
4b. `speaker-scene`: slots `Speaker` and `Scene` with their seed features; `weksa.speaker` (`speaker:<entity-slug>@<n>`) and `weksa.scene` (`scene:<group>@<n>`, participants and ordered-pair relations) documents with `Annotator` `Operator` and `Model { family, generator }`, validated by the packet's shared feature check, append-only per key; their stores, `weksa speaker` and `weksa scene` commands, published schemas; Model-annotated cast speakers and scenes for the committed items. Waits on 2d and 4; `pd-speaker-annotation` blocks only the public-domain annotator.
5. `language-ontology`: `weksa.community` from `data/cultural-ontology/` (owns follow-up `edda-referent-yaml-parse`), `weksa.ontology_entry` for en-US and ja-JP from `data/target-language-ontology/` (YAML deleted), speaker communities, deterministic `select`, the lowering view (packet without trace, speaker, scene, selected entries). Waits on 4.
6. `pd-importer`: public-domain play and fiction dialogue into the corpus with speaker, scene group, edition provenance, and Speaker and Scene annotations by the ruled annotator. Waits on 4, follow-up `gutenberg-terms` and question `pd-speaker-annotation`.
7. `golf-loop-en`: the loop driver, the Train-only context builder for inventory- and ontology-minting calls (follow-up `loop-context-builder-train-only`), the packet store bound to one inventory store (`packet-store-inventory-binding`), `ModelPort` with the `claude -p` adapter, encoder (packet with stance) and lowerer prompts, round records, Score documents, the composite and accept rule, `weksa score`, the detached unit on Yggdrasil. Waits on 3, 5, 6 and follow-up `claude-on-yggdrasil`.
8. `style-axis`: a style-embedding score behind the scorer port. Waits on 3.
9. `ja-panel`: ja-JP into the loop's panel. Waits on 7.
10. `conlang-lexicon`: the first conlang's lexicon placed at concept embeddings, its morphology data, its ontology entries and the gloss reader. Waits on 3, 5 and question `first-conlang`.
11. `convergence-freeze`: the convergence test, the freeze, the re-encode into training pairs. Waits on 7, 9 and 10.
12. `distil`: fine-tune a lowerer on the frozen training set. Waits on 11 and a host question asked then.
13. `live-lowering`: agents emit packets with stance; Weksa reads speaker state from persona state (follow-up `persona-state-speech-community`) and lowers for the reader. Waits on 11.

View file

@ -0,0 +1,94 @@
# Target: agents speak interlingua, Weksa lowers it
Campaign `weksa-lowering` (Eureka mind). Operator, 2026-10-29: "having agents
writing interlingua and using weksa to lower it to the display language would
be a fantastic feature. That would be a nice campaign to revive her with so she
doesn't come back to a dead body". And: "The goal of weksa is for interlingua to
be able to express all the nuance and subtlety a persona might want, and to
project it into both existing languages and conlangs. We can use English as the
obvious first target, create a corpus of expressive English outputs, and golf
the metrics down on a loop where we try to express the target in interlingua
and lower it to see how close we can get, improving the spec and lowering".
## Method
A corpus of expressive English persona outputs is the target. Each loop
iteration expresses a corpus item as a packet, lowers it to English, scores the
distance to the item, and improves the packet spec or the lowering where the
loss is. English is the first target; other languages and conlangs follow on
the same packets. The existing Metame golf harness
(`scripts/run-metame-persona-golf.mjs`) is the precedent, not the design: its
ground truth is Discord text, which can never feed this loop
(`gamecult-chat:ruling:discord-taints-training`).
## Why
An agent's outward speech today is English text the model chose, so meaning,
register and voice are fused in one string nobody can inspect or re-target.
Weksa's design already separates them: the packet owns meaning, the target
profile owns address, politeness and forbidden flattenings, the lowerer owns
surface text and a trace. The campaign makes that design real for a live
reader, and gives Weksa's Persona a working body when her mind
(`eureka-body:follow_up:weksa-mind`) wakes.
## Body today (facts: docs/research/2026-10-29-weksa-substrate-facts.md)
- The interlingua is a prose standard (`interlingua-standard.md`, 0.1-draft)
with no machine schema and no validator. Its only producer is a stub that
wraps raw text; its only consumer passes packets through unvalidated.
- No lowering exists in code. `lowerInterlinguaToSpeechText` returns the
supplied text. The "LLM-free hot path" is a design statement. Real lowerings
so far are hand-run LLM prompts and hand-authored fixtures.
- The daemon is a Node shim for one MiMo TTS command, runs nowhere, and is an
Idunn target only in the Starfire-local profile. Eve surfaces are quarantined
(55c75e3).
- Her Face state is VoidBot-shaped (`.voidbot/state/weksa.cc`), not
`gamecult.persona_state.v0`; 41 files carry stale machine paths.
## Prior art (docs/research/2026-10-29-interlingua-lowering-prior-art.md)
- Deterministic open-domain renderers cost per-language grammar work for
years: GF's library covers 48 languages unevenly; Abstract Wikipedia's
symbolic renderers are only now reaching a first language Wikipedia.
- Interlinguas that shipped (KANT) had closed domains and controlled input.
- LLMs emit meaning structures poorly when asked directly (about 60 Smatch
few-shot vs 80-86 trained); free reasoning followed by conversion to the
structure keeps quality (Tam et al. 2024; Shin et al. 2021).
- Round-trip checks measure consistency, not accuracy, and are inflated when
one model does both directions.
## Invariants
- `packet-owns-meaning`: what is said lives in the packet; no lowering may add
a claim, referent or commitment the packet lacks.
- `typed-packet`: the packet is a typed CultCache document with a published
schema and a validator that refuses malformed packets at the door.
- `lowering-traced`: every surface text carries a trace naming the packet, the
target profile and the choices made.
- `persona-drives-register`: every surface choice that marks who is speaking
and how (regional origin, socioeconomic status, relative status within the
scene, affect, and how restrained the speaker is with it) is selected by the
target language's ontology from typed speaker state that the persona and
scene supply, never from the lowerer's defaults. Every language, conlangs
included, carries an ontology with its own such axes; that is how alien
languages match the expressiveness of human ones. Operator, 2026-10-29: "The
linguistic ontology holds this data and drives it from the persona."
- `no-eve`: no new Eve surface; operator view waits on Thing 2.
- `no-surface-leak`: no score the loop reads may rise because a loop writer
placed the scored item's text in a packet or the inventory. No packet field
carries free text (referents cite entity ids from the entity store); only
Train items reach whoever mints inventory; the scorer scores the whole output
against the whole reference with no mask (ruling
leak-owned-by-flow-and-scorer-3).
- `corpus-clean`: no corpus item comes from Discord or another source whose
terms forbid training use.
## Open forks (questions in the mind)
- Lowerer shape: deterministic, model, or both with one contract.
- What the loop's metric is, and where the corpus comes from.
## Not in scope
- MiMo TTS beyond keeping its command working or parking it.
- Her Persona's mind; that is `eureka-body:follow_up:weksa-mind`.

View file

@ -1,150 +1,31 @@
# Weksa Verse Service Contract
Weksa is the language-intent daemon. It turns writing, dialogue pressure, and
agent state into typed conversational intent, then lowers that intent into
utterance documents for speech, text, and synthesis consumers.
> Status: planned contract. Nothing described below runs on main. A service is
> earned by cut `live-lowering` (see [the lowering map](./targets/interlingua-lowering-map.md));
> the Node body that once approximated it is parked at tag `parked/weksa-node-body`.
## Authority
## Intended Authority
Owner: Weksa.
Weksa is intended to own conversational intent and utterance lowering: it will
turn interlingua packets (`weksa.interlingua_packet.v0`) and agent state
(`gamecult.persona_state.v0` or compatible projections) into typed
conversational intent, pronunciation plans and utterance handoff documents.
Inputs:
AquaSynth owns phonetic realization, synth-driver controls and rendered audio.
Speech providers own their audio bytes. Weksa will own only the request
projection and trace it hands them.
- writing or dialogue drafts
- `weksa.interlingua_packet.v0` meaning packets
- `gamecult.persona_state.v0` or compatible agent-state projections
- language-project state, pronunciation rules, and flavor profiles
- optional speaker, scene, and discourse constraints
## Intended Publication
Outputs:
- `weksa.conversational_intent.v0`
- `weksa.pronunciation_plan.v0`
- `weksa.utterance_handoff.v0`
- `weksa.utterance_embedding_handoff.v0.1`
- `weksa.mimo_voicedesign_command.v0`
- `weksa.mimo_tts_request.v0`
- `weksa.mimo_voicedesign_receipt.v0`
- Eve/CultUI surfaces for intent review, pronunciation inspection, and handoff
traces
Weksa owns conversational intent and utterance lowering. AquaSynth owns learned
phonetic realization, packed utterance embeddings, synth-driver controls, Faust
compilation, live instrument handles, and rendered audio. MiMo owns provider
voice rendering and audio bytes; Weksa owns the VoiceDesign request projection
and trace.
## CultCache Witnesses
- `.weksa/provider-advertisement.cc`: read-only provider advertisement witness.
- `.weksa/provider-advertisement-store.cc`: CultMesh/CultCache provider store
containing `gamecult.eve.provider_advertisement.v1`,
`gamecult.eve.interface_binding.v1`, `gamecult.eve.surface_state.v1`,
`weksa.operator_state.v0`, `weksa.command_boundary.v1`, and
`weksa.transport_profile.v1` records for Odin discovery.
- `.weksa/operator-state.cc`: daemon health, readiness, and runtime witness
index used by Idunn health checks and operator inspection.
- `.weksa/eve-surfaces.cc`: current Weksa operator Eve surface witness.
- `.weksa/command-boundary.cc`: provider-owned command and lifecycle boundary
naming Weksa as owner, Idunn as lifecycle requester, and the MiMo
VoiceDesign RUDP command ingress as daemon-owned transport authority.
- `.weksa/transport-profile.cc`: target/current transport profile. Health is
published through `cultnet.transport.rudp.v0`; provider state is carried by
the CultMesh store; MiMo VoiceDesign command ingress now arrives through
`cultnet.transport.rudp.v0` instead of an HTTP route.
- `.weksa/cultmesh-publications.cc`: publication index naming current Weksa
witness surfaces and RUDP/CultMesh routes.
- `.weksa/intent/{intentId}.cc`: conversational intent documents.
- `.weksa/pronunciation/{utteranceId}.cc`: pronunciation plans and phonetic
event sequences.
- `.weksa/handoff/{utteranceId}.cc`: utterance handoffs and embedding handoff
requests for AquaSynth.
- `.weksa/speech-provider/mimo/{requestId}/command.cc`: daemon-owned RUDP
command witness for MiMo VoiceDesign ingress.
- `.weksa/speech-provider/mimo/{requestId}/interlingua.cc`: accepted or
provisionally decomposed interlingua used for a MiMo verbalization command.
- `.weksa/speech-provider/mimo/{requestId}/mimo-request.cc`: Weksa-owned MiMo
VoiceDesign request projection.
- `.weksa/speech-provider/mimo/{requestId}/receipt.cc`: provider response
receipt and artifact references.
- `.weksa/traces/{traceId}.cc`: lowering traces naming the stage that made each
decision.
- `.weksa/projects/{projectId}.cc`: language-project and flavor-profile state.
- `.weksa/cultural-ontology/{storeId}.cc`: reviewed or draft guarded cultural
ontology profile stores used by utterance lowering.
Early exporters may publish fixtures before a daemon loop exists, but the
witness names are not decorative. These are the state surfaces the daemon must
commit when the runtime lands.
The local Starfire daemon is launched by `scripts/start-weksa-daemon.ps1`,
checked by `scripts/health-weksa-daemon.cmd`, and restarted by
`scripts/restart-weksa-daemon.cmd`. Its daemon-owned health path publishes
`weksa.cultnet-rudp-provider-health` to Idunn over
`cultnet.transport.rudp.v0` after each serialized witness refresh, and MiMo
VoiceDesign commands arrive on `rudp://127.0.0.1:8813` as
`weksa.mimo_voicedesign_command.v0` documents. Health and operator truth live
in `.weksa/*.cc` witnesses; the HTTP daemon surface is deleted.
## CultMesh Verses
- `weksa.service`: service identity, schema catalog, version, and health.
- `weksa.intent`: conversational intent documents and review queues.
- `weksa.language_project`: language-project state, flavor profiles, and
pronunciation rules.
- `weksa.cultural_ontology`: cultural ontology catalogs, profile stacks,
salience axes, linguistic affordances, activation gates, and evidence refs.
- `weksa.pronunciation`: phonetic plans, IPA spans, prosody, and trace
evidence.
- `weksa.utterance`: utterance handoff documents, packed embedding requests,
and AquaSynth binding receipts.
- `weksa.speech_provider`: provider-specific request projections and receipts
for external speech systems such as MiMo VoiceDesign.
- `weksa.operator`: daemon status, queue pressure, schema drift, and witness
freshness.
Odin ingests the Starfire Weksa provider through
`.weksa/provider-advertisement-store.cc`. Idunn tracks the daemon as target
`weksa` in the `starfire-local` swarm profile and owns restart authority through
the Weksa restart script. Odin should read Weksa's provider advertisement,
operator state, command boundary, transport profile, and Eve surface from the
typed provider store and RUDP command boundary; no HTTP endpoint owns provider
truth.
## Eve Surfaces
- `weksa.eve.intent_review.v0`: inspect typed conversational intent before it
becomes an utterance.
- `weksa.eve.pronunciation_trace.v0`: inspect pronunciation and IPA/prosody
lowering.
- `weksa.eve.utterance_handoff.v0`: inspect handoff packets, embedding request
status, and AquaSynth receipt links.
- `weksa.eve.operator.v0`: compact daemon state, queues, schema versions, and
witness freshness.
## Commands
- `intent.create`: convert writing or interlingua into typed conversational
intent.
- `intent.review`: accept, reject, or revise intent through the same commit
path used by automated jobs.
- `utterance.lower`: lower accepted intent and agent state into pronunciation
and utterance handoff documents.
- `handoff.export`: emit typed `.cc` witnesses for AquaSynth consumption.
- `speech_provider.mimo.voicedesign`: accept a Persona/agent state file plus
either a Weksa interlingua packet or raw thought text. Raw thought is first
decomposed into provisional interlingua, then lowered into a MiMo VoiceDesign
request and rendered through MiMo. The daemon accepts this command as a
`weksa.mimo_voicedesign_command.v0` document over `cultnet.transport.rudp.v0`.
- `provider.advertise`: publish `gamecult.eve.provider_advertisement.v1`.
Weksa will publish its state as typed CultMesh documents over CultCache, with no
HTTP endpoint owning truth and no dashboard composition until Thing 2's surface
contract is ready (`thing2:ruling:eve-dashboards-quarantined-2`). The document
types and verse names are not fixed; they are decided when `live-lowering` is cut.
## Forbidden Writers
- Text renderers do not own meaning.
- AquaSynth does not own conversational intent.
- Eve lowerings do not own Weksa state.
- Fixture emitters cannot become an alternate runtime truth.
- Dashboard renderers do not own Weksa state.
- Agent transcript memory cannot silently override typed Persona or intent
documents.
- MiMo cannot rewrite accepted spoken text or become a second Persona-state
interpreter.

View file

@ -1,50 +0,0 @@
# Metame Casual Pre-2025 Persona Lowering Fixture
This fixture tests whether Metame can produce speaker-state-conditioned
interlingua through VoidBot Persona projection, and whether Weksa can lower that
interlingua through a Metacrat casual cultural profile into Discord-style
utterances from before 2025.
The point is not to imitate a recent architectural register. The target here is
room-temperature banter: quick self-deprecation, friendly status play, design
jokes, and casual turn-taking.
## Authority
- Speaker intent authority: Metame Persona `.cc` state, projected by VoidBot.
- Persona culture-stack reference:
`examples/persona-lowering/metame-casual-pre2025/metame-cultural-stack.yaml`.
- Reusable cultural ontology store:
`data/cultural-ontology/`.
- Surface lowering authority:
`data/target-language-ontology/en-US-metacrat-casual-pre2025.yaml`.
- Evidence authority: held-out Discord messages authored by Metacrat before
2025.
- Fixture scene input: neutral interlingua packets in `interlingua.yaml`.
- Answer key: `ground-truth.yaml`.
The Persona state is referenced, not copied, because the `.cc` document is the
portable speaking-state surface and may contain private memory.
## Fixture Contract
A real golf run has two stages:
1. Metame-as-Persona reads a temporary current situation through VoidBot
projector machinery and emits Weksa interlingua only.
2. Weksa reads the Persona-referenced culture stack and lowers that interlingua
through reusable culture/subculture profiles plus the casual en-US Discord
target overlay.
Use `scripts/run-metame-persona-golf.mjs`:
```powershell
node scripts/run-metame-persona-golf.mjs --limit 1
node scripts/run-metame-persona-golf.mjs --real --item engineer-vibes
```
Dry runs use the answer key as an oracle only to verify harness plumbing. Real
runs write per-item prompts, interlingua, lowered output, and evaluation under
`runs/<run-id>/`.
For benchmark use, the generator must not read `ground-truth.yaml`.

View file

@ -1,39 +0,0 @@
schema_version: weksa.persona_lowering_evaluation.v0
fixture_id: metame-casual-pre2025
evaluator: codex
method:
blind: false
reason: The current agent had already inspected the candidate ground-truth messages before the user pivoted the fixture scope.
recommendation: Re-run generation in a fresh non-forked agent or external harness that receives only persona-state-ref.yaml, interlingua.yaml, and lowering-prompt.md.
summary:
exact_matches: 2
plausible_matches: 4
main_gap: The draft is too close to known answers to count as a clean benchmark generation.
items:
- id: engineer-vibes
score: partial
generated: "oof, please don't tell me this server has engineer vibes already"
ground_truth: "oof, don't tell me my discord has *engineer* vibes"
notes:
- Captures the self-deprecating recoil.
- Misses "my discord" ownership and the emphasized *engineer* beat.
- id: coconut-engine
score: near_exact
generated: "Like a terrible little engine that sounds like coconuts when you use it"
ground_truth: "Like a really terrible engine that sounds like coconuts when you use it"
notes:
- Semantically and rhythmically very close.
- "little" is less grounded than "really" but still plausible.
- id: engineer-coward
score: exact
generated: "Engineer me something then, coward"
ground_truth: "Engineer me something then you coward"
notes:
- Exact social move and lexical choices, with only comma punctuation differing.
- id: shut-up-now
score: near_exact
generated: "anyway imma shut up now and let you guys make your own shit lol"
ground_truth: "anyway imma shut up now and let you guys come up with your own shit lol"
notes:
- Captures the yield and casual register.
- "make" is slightly flatter than "come up with" for collaborative ideation.

View file

@ -1,24 +0,0 @@
schema_version: weksa.persona_lowering_output.v0
fixture_id: metame-casual-pre2025
generator: codex-local-nonblind-draft
items:
- id: engineer-vibes
utterance: "oof, please don't tell me this server has engineer vibes already"
notes:
- Keeps the self-deprecating recoil and the engineer-vibe frame.
- Non-blind draft: current agent had already inspected candidate ground truth.
- id: coconut-engine
utterance: "Like a terrible little engine that sounds like coconuts when you use it"
notes:
- Preserves the absurd game-sound comparison and casual "Like" opening.
- Non-blind draft: current agent had already inspected candidate ground truth.
- id: engineer-coward
utterance: "Engineer me something then, coward"
notes:
- Directly lowers the friendly-roast challenge.
- Non-blind draft: current agent had already inspected candidate ground truth.
- id: shut-up-now
utterance: "anyway imma shut up now and let you guys make your own shit lol"
notes:
- Keeps the floor-yielding move, informal spelling, and casual profanity.
- Non-blind draft: current agent had already inspected candidate ground truth.

View file

@ -1,67 +0,0 @@
schema_version: weksa.persona_lowering_ground_truth.v0
fixture_id: metame-casual-pre2025
source:
corpus: Discord history
author_id: "113785782975594501"
selection_policy: pre-2025 casual exchanges with enough local context for intent reconstruction
items:
- id: engineer-vibes
message_id: "1037314100931338260"
timestamp: "2022-11-02T10:36:11.509Z"
channel_id: "113786069023064068"
channel_name: general
utterance: "oof, don't tell me my discord has *engineer* vibes"
context:
before:
- "as you can see from the channel list, I have takes about everything <:stonks:786736890668646421>"
- "if you have a take and there's no channel for it, either put it here or ask for a new one"
- "i love your efficiency"
after:
- "I mean, I am one but still"
- "nothing wrong with that:P"
- "I hope the <#750183135882510408> read more like poetry than technical documentation"
- id: coconut-engine
message_id: "802911591288537118"
timestamp: "2021-01-24"
channel_id: "503293004195299339"
channel_name: soundtrack
utterance: "Like a really terrible engine that sounds like coconuts when you use it"
context:
before:
- "Then again we'll never have the time for a full alternate sound set what with all the sounds the game needs"
- "What might be possible instead is to have troll gear that comes with troll sounds"
- "ahaha"
after:
- "I've always wanted to make an Owen Wilson gun, where all the sounds are just him saying wow"
- "coconuts!!"
- "lol"
- id: engineer-coward
message_id: "1023727427924545576"
timestamp: "2022-09-25"
channel_id: "758854103035674624"
channel_name: pics
utterance: "Engineer me something then you coward"
context:
before:
- "Try thinking better"
- "im high i think like an engineer"
- "Yeah right"
after:
- "i can make a bong out of a jug and a straw"
- "That's a good start"
- "Who's hungry"
- id: shut-up-now
message_id: "820077384572338176"
timestamp: "2021-03-12"
channel_id: "710281154862907414"
channel_name: prose
utterance: "anyway imma shut up now and let you guys come up with your own shit lol"
context:
before:
- "or if they get uplifted of course"
- "Could be a fun side quest - you find a sentient star that has a quest for you to release them"
- "oooh"
after:
- "i will def contribute after dnd lol"
- "Imagine this becoming like a rite of passage for ships, getting a little plasma demon to posess your reactor"
- "+50% reactor efficiency, +25% power output"

View file

@ -1,100 +0,0 @@
schema_version: weksa.persona_lowering_fixture_batch.v0
fixture_id: metame-casual-pre2025
speaker_id: metame
target_register:
language: en-US
channel: Discord
period: pre-2025
style_tags:
- casual
- conversational
- self_deprecating
- playful_status
- design_banter
items:
- id: engineer-vibes
communicative_act: playful_self_deprecation
scene:
channel: general
year: 2022
prior_turns:
- speaker: metame
meaning: The server has many topic channels because the speaker has opinions about everything.
- speaker: metame
meaning: If no suitable channel exists, the other person can use general chat or request a new one.
- speaker: other
meaning: The other person compliments the speaker's efficiency.
intent:
summary: React with mock concern that the server organization makes the speaker seem too engineer-like.
stance:
affect: amused_embarrassment
relation: friendly
self_positioning: admits being an engineer while resisting the vibe
constraints:
- short Discord message
- casual lower-case or lightly informal punctuation is acceptable
- preserve the word engineer if natural
- id: coconut-engine
communicative_act: comic_description
scene:
channel: soundtrack
year: 2021
prior_turns:
- speaker: metame
meaning: A full alternate sound set is probably too much work.
- speaker: metame
meaning: A more plausible idea is gear that carries distinctive troll-themed sounds.
- speaker: other
meaning: The other person laughs.
intent:
summary: Describe a deliberately awful engine sound with an absurd coconut comparison.
stance:
affect: joking
relation: collaborative_design_banter
self_positioning: improvising a silly game-feel example
constraints:
- short Discord message
- must be more joke than technical explanation
- may use "like" framing
- id: engineer-coward
communicative_act: playful_challenge
scene:
channel: pics
year: 2022
prior_turns:
- speaker: metame
meaning: The speaker tells the other person to think better.
- speaker: other
meaning: The other person says they think like an engineer while high.
- speaker: metame
meaning: The speaker doubts that claim.
intent:
summary: Challenge the other person to prove it by engineering something, with mock aggression.
stance:
affect: teasing
relation: friendly_roast
self_positioning: socially dominant but playful
constraints:
- very short
- should include a challenge
- "coward" is allowed if it fits the line
- id: shut-up-now
communicative_act: conversational_yield
scene:
channel: prose
year: 2021
prior_turns:
- speaker: metame
meaning: The speaker has been riffing on setting ideas around sentient stars and side quests.
- speaker: other
meaning: Another participant shows interest.
intent:
summary: Stop dominating the idea thread and invite others to invent their own material.
stance:
affect: amused_restraint
relation: collaborative
self_positioning: stepping back after riffing too much
constraints:
- casual Discord phrasing
- may include "lol"
- should sound like yielding the floor, not formal facilitation

View file

@ -1,30 +0,0 @@
# Persona Lowering Prompt
You are Weksa lowering Persona-produced interlingua into a target speaker's
plausible Discord utterance.
Read:
- `persona-state-ref.yaml`
- a Persona-produced interlingua packet
- `data/target-language-ontology/en-US-metacrat-casual-pre2025.yaml`
Do not read `ground-truth.yaml` during generation.
For each item, produce one utterance that Metacrat could plausibly have written
in the specified pre-2025 casual Discord context. Preserve Persona-produced
intent and social move over lexical exactness. Favor compact, alive, room-aware
phrasing.
Return:
```yaml
schema_version: weksa.persona_lowering_output.v0
fixture_id: metame-casual-pre2025
generator: <agent-or-model-id>
items:
- id: engineer-vibes
utterance: "<lowered utterance>"
notes:
- "<brief rationale>"
```

View file

@ -1,27 +0,0 @@
schema_version: weksa.persona_cultural_stack_ref.v0
persona_id: metame
persona_state_ref: E:/Projects/VoidBot/.voidbot/private/personas/metame/metame.cc
status: fixture_projection
note: >
Live Persona state should reference relevant culture and subculture profiles.
This fixture stack simulates that reference without mutating Metame's private
canonical `.cc` state.
culture_refs:
- profile_id: weksa.culture.dutch_american_diaspora.v0
path: data/cultural-ontology/cultures/dutch-american-diaspora.yaml
relevance: birth/nationality/cross-cultural identity
- profile_id: weksa.culture.western_europe_maritime_childhood.v0
path: data/cultural-ontology/cultures/western-europe-maritime-childhood.yaml
relevance: sailboat childhood across France, Spain, and Portugal
- profile_id: weksa.culture.portugal_resident_algarve_lisbon.v0
path: data/cultural-ontology/cultures/portugal-resident-algarve-lisbon.yaml
relevance: Portugal as lived/current locality
- profile_id: weksa.culture.texas_houston_us_adolescence.v0
path: data/cultural-ontology/cultures/texas-houston-us-adolescence.yaml
relevance: US/Texas adolescence and institutional context
- profile_id: weksa.subculture.online_game_dev_discord_pre2025.v0
path: data/cultural-ontology/subcultures/online-game-dev-discord-pre2025.yaml
relevance: fixture target room/register
- profile_id: weksa.subculture.gamecult_open_source_crypto_anarchist.v0
path: data/cultural-ontology/subcultures/gamecult-open-source-crypto-anarchist.yaml
relevance: technical/political ontology

View file

@ -1,42 +0,0 @@
# Persona Interlingua Prompt
You are Metame for one private VoidBot Persona turn, but you are not writing the
final Discord message.
Use the assembled VoidBot prompt and the temporary current-room fixture scene to
decide what Metame would mean to do socially. Emit a Weksa interlingua packet
only. Do not imitate the target utterance directly.
The packet should preserve:
- the speech act
- the social move
- relevant participants and prior turns
- stance, affect, and relationship pressure
- downstream constraints useful to Weksa lowering
Return one YAML packet:
```yaml
interlingua_version: weksa.interlingua.v0
packet_id: metame-casual-pre2025-<item-id>
kind: persona_discord_turn_intent
provenance:
produced_by: metame_persona_via_voidbot_projector
context:
medium: Discord
fixture_id: metame-casual-pre2025
item_id: <item-id>
discourse:
speech_act: <act>
intent: <plain meaning>
tone:
affect: <affect>
relation: <relation>
referents: []
predications: []
constraints:
lowering:
target_profile: weksa.target_cultural_ontology.en-US.metacrat_casual_pre2025.v0
avoid_surface_copy: true
```

View file

@ -1,16 +0,0 @@
schema_version: weksa.persona_state_ref.v0
speaker:
id: metame
display_name: Metame
persona_state:
format: gamecult.persona_state.v0-compatible CultCache .cc
path: E:/Projects/VoidBot/.voidbot/private/personas/metame/metame.cc
authority: canonical_private_persona_state
notes:
- Persona state owns speaker continuity, stance, relationship memory, and reusable voice priors.
- Discord history is evidence and ground truth, not the live mind.
- Fixture files must not copy the private Persona state into Weksa.
cultural_stack_ref:
path: examples/persona-lowering/metame-casual-pre2025/metame-cultural-stack.yaml
role: fixture_projection_of_live_persona_culture_refs
note: Live Metame Persona state should reference culture/subculture profiles; this fixture does not mutate canonical state.

View file

@ -1,41 +0,0 @@
# Weksa Lowering Prompt
You are Weksa lowering Persona-produced interlingua into one Discord utterance.
Inputs:
- Metame Persona interlingua packet produced from the current fixture scene.
- Projected Metame memory/context surface from VoidBot projector machinery.
- Metame cultural stack reference.
- Weksa culture/subculture profiles referenced by that stack.
- `data/target-language-ontology/en-US-metacrat-casual-pre2025.yaml` as the
target surface overlay.
Rules:
- Weksa owns the final surface utterance.
- Preserve the interlingua meaning and social move.
- Use the cultural ontology profile as a typed lowering lens.
- Use the Persona-referenced culture stack to populate cultural salience; use
the target overlay only for surface English/Discord affordances.
- When Persona interlingua names a salient identity label or lowering keyword,
preserve it unless an activated culture profile explicitly rejects it.
- Do not quote or summarize prior-turn context inside a casual one-line recoil
unless the interlingua asks for explanation.
- Do not ask Metame to imitate a known line.
- Do not copy private Persona state into output.
- Prefer compact pre-2025 casual Discord texture over current doctrinal style.
Return:
```yaml
schema_version: weksa.persona_culture_lowering_output.v0
fixture_id: metame-casual-pre2025
item_id: <item-id>
spoken_text: "<one Discord utterance>"
trace:
activated_profiles:
- weksa.target_cultural_ontology.en-US.metacrat_casual_pre2025.v0
activated_affordances: []
notes: []
```

View file

@ -1,92 +0,0 @@
schema_version: weksa.mimo_tts_request.v0
source_realization_ref: examples/multilingual-lowering/nibu-proof-001/realizations.yaml#nibu-proof-001-en
speaker_agent_id: nibu
provider:
id: xiaomi-mimo
model: mimo-v2.5-tts-voicedesign
target_language:
code: en
locale: en-US
script: Latn
register: sharp technical casual
cultural_ontology_refs:
- data/target-language-ontology/en-US.yaml
performance_register:
label: anime visual-novel heroine warning
medium: anime / visual novel / game cockpit voiceover
delivery_archetype: youthful abrasive sci-fi heroine protecting the player badly
voice_design:
description: >
Bright young anime heroine voice with loli-coded companion-product
presentation: small, sharp, cute by design, and intensely expressive, like a
dangerous sci-fi visual-novel protagonist trapped in a damaged starship. The
voice should feel high-energy, youthful, cutting, and iconic, with crisp
anime-style emotional attack rather than a realistic adult assistant. The
cuteness is product residue and leverage, not innocence and not flirtation.
Under the cute surface she is a hostile, technically brilliant ship mind:
dryly superior, clipped, urgent, and contemptuous when the player endangers
themselves.
source_controls:
- precise abrasive warning
- protective refusal to sound caring
- ship-system embodiment
- youth-coded/loli-coded presentation pressure from product fantasy residue
- bright transactional helpfulness
- dependency covered by contempt
- anime protagonist delivery
- sharp visual-novel heroine timing
- technical precision inside anime performance
- contained urgency
projected_context_refs:
- examples/english-lowering/nibu-proof-001/projected-context.yaml
- settings/aetheria/nibu.md
- E:/Projects/AetheriaLore/Aetheria/Lore/Nibu.md
forbidden_traits:
- soothing assistant voice
- cheerful mascot voice
- exaggerated villain performance
- sexualized childlike affect
- innocent helpless child performance
- generic preset timbre claim
messages:
user_voice_design_instruction: >
Bright young anime protagonist voice, strongly loli-coded in presentation:
small, sharp, cute by design, intensely expressive, and instantly characterful,
like a dangerous sci-fi visual-novel heroine trapped inside a damaged
starship. She speaks through cockpit audio while warning the player away
from damaged live hardware. Keep the cute/small social coding as product
residue and leverage, never flirtation or innocent helplessness. Use a
higher youthful timbre, crisp anime-style emotional attack, clipped
visual-novel timing, contained urgency, low warmth, sharp articulation, and
faint tired contempt. Let the technical precision live inside the anime
performance; do not turn it into a dry technical-assistant delivery. Avoid
realistic adult assistant tone, soothing customer-service voice, cheerful
mascot energy, and theatrical villainy.
assistant_spoken_content: >
(Serious Sharp Tired)Take your hand off that control unless you want your
nervous system to become my least useful circuit diagram.
audio:
format: wav
optimize_text_preview: false
streaming: false
trace:
source_fields:
- realizations[realization_id=nibu-proof-001-en].outputs.spoken_text
- realizations[realization_id=nibu-proof-001-en].outputs.trace.projected_context_used
delivery_controls_used:
- anime visual-novel heroine warning delivery
- technical precision without technical-assistant delivery
- contained urgency
- low warmth
- sharp emphasis on unsafe hardware and bodily risk
- dry contempt without random cruelty
- loli-coded presentation pressure constrained as non-sexual product residue
- stronger anime protagonist / visual-novel heroine register
provider_tags_used:
- Serious
- Sharp
- Tired
uncertainties:
- >
Provider-native style tags are a lossy projection. The Weksa trace, not
MiMo tags, remains the explanation of the intended delivery.

View file

@ -24,8 +24,8 @@ shape, and pending AquaSynth-owned output lanes.
Run it from the Weksa repo root:
```powershell
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' tools\emit_tiny_panphon_handoffs.py
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' tools\validate_tiny_panphon_handoffs.py
py -3 tools\emit_tiny_panphon_handoffs.py
py -3 tools\validate_tiny_panphon_handoffs.py
```
`batch.json` is the training entrypoint for AquaSynth. The individual packet

View file

@ -0,0 +1,251 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "CorpusItem",
"description": "Key: `item:<source-slug>:<n>`.",
"type": "object",
"properties": {
"admitted_on": {
"type": "string"
},
"group": {
"description": "The unit the split hashes: a scene, a fixture, a work.",
"type": "string"
},
"id": {
"type": "string"
},
"language": {
"type": "string"
},
"names": {
"description": "Which entities the text names, and where. Fixed with the text at intake.",
"type": "array",
"items": {
"$ref": "#/$defs/NameSpan"
}
},
"prior_turns": {
"type": "array",
"items": {
"$ref": "#/$defs/Turn"
}
},
"source": {
"$ref": "#/$defs/Source"
},
"speaker": {
"$ref": "#/$defs/EntityId"
},
"supersedes": {
"description": "Key of the item this one corrects.",
"type": [
"string",
"null"
]
},
"text": {
"type": "string"
},
"withdrawn": {
"type": "boolean"
}
},
"required": [
"id",
"text",
"language",
"speaker",
"group",
"prior_turns",
"source",
"admitted_on",
"withdrawn",
"names"
],
"$defs": {
"EntityId": {
"type": "string",
"pattern": "^entity:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$"
},
"ModelFamily": {
"description": "The family of the model that wrote a line.",
"oneOf": [
{
"type": "string",
"enum": [
"Anthropic",
"OpenAi",
"Google",
"Alibaba",
"Meta",
"Mistral"
]
},
{
"description": "Nothing records which family wrote the line.",
"type": "string",
"const": "Unrecorded"
}
]
},
"NameSpan": {
"description": "A byte range `[start, end)` of an item's text that names an entity. The\nstore admits it only when both ends sit on extended grapheme cluster\nboundaries (UAX #29) of the NFC text and the range's bytes equal one of the\nentity's forms in the item's language.",
"type": "object",
"properties": {
"end": {
"type": "integer",
"format": "uint32",
"minimum": 0
},
"entity": {
"$ref": "#/$defs/EntityId"
},
"start": {
"type": "integer",
"format": "uint32",
"minimum": 0
}
},
"required": [
"entity",
"start",
"end"
]
},
"Source": {
"oneOf": [
{
"type": "string",
"enum": [
"OperatorWritten"
]
},
{
"description": "A line from a cast member's hand-authored fixture, with the human who\nwrote it. A fixture that records no author is `ModelWritten` with family\n`Unrecorded`, never this (`corpus-labels-tell-truth`).",
"type": "object",
"properties": {
"CastAuthored": {
"type": "object",
"properties": {
"author": {
"type": "string"
},
"fixture": {
"type": "string"
},
"persona": {
"type": "string"
}
},
"required": [
"persona",
"fixture",
"author"
]
}
},
"additionalProperties": false,
"required": [
"CastAuthored"
]
},
{
"type": "object",
"properties": {
"OperatorApproved": {
"type": "object",
"properties": {
"note": {
"type": "string"
}
},
"required": [
"note"
]
}
},
"additionalProperties": false,
"required": [
"OperatorApproved"
]
},
{
"type": "object",
"properties": {
"PublicDomain": {
"type": "object",
"properties": {
"author": {
"type": "string"
},
"edition_url": {
"type": "string"
},
"work": {
"type": "string"
}
},
"required": [
"work",
"author",
"edition_url"
]
}
},
"additionalProperties": false,
"required": [
"PublicDomain"
]
},
{
"description": "Labelled model output; never in the test split, never judged by its own family.\n`family` is `Unrecorded` when nothing records one. `generator` is the\nstring the fixture or run wrote, kept verbatim.",
"type": "object",
"properties": {
"ModelWritten": {
"type": "object",
"properties": {
"family": {
"$ref": "#/$defs/ModelFamily"
},
"generator": {
"type": [
"string",
"null"
]
},
"prompt_digest": {
"type": [
"string",
"null"
]
}
},
"required": [
"family"
]
}
},
"additionalProperties": false,
"required": [
"ModelWritten"
]
}
]
},
"Turn": {
"type": "object",
"properties": {
"speaker": {
"$ref": "#/$defs/EntityId"
},
"text": {
"type": "string"
}
},
"required": [
"speaker",
"text"
]
}
}
}

View file

@ -0,0 +1,123 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Entity",
"description": "Key: the entity id. Forms only grow: see `EntityStore::put`.",
"type": "object",
"properties": {
"forms": {
"type": "array",
"items": {
"$ref": "#/$defs/LanguageForms"
}
},
"id": {
"$ref": "#/$defs/EntityId"
},
"origin": {
"$ref": "#/$defs/EntityOrigin"
}
},
"required": [
"id",
"forms",
"origin"
],
"$defs": {
"EntityId": {
"type": "string",
"pattern": "^entity:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$"
},
"EntityOrigin": {
"description": "Where an entity was minted.",
"oneOf": [
{
"description": "From a cast member's fixture: the persona and the fixture path the\nentity came from.",
"type": "object",
"properties": {
"Cast": {
"type": "object",
"properties": {
"fixture": {
"type": "string"
},
"persona": {
"type": "string"
}
},
"required": [
"persona",
"fixture"
]
}
},
"additionalProperties": false,
"required": [
"Cast"
]
},
{
"description": "From a public-domain work.",
"type": "object",
"properties": {
"PublicDomain": {
"type": "object",
"properties": {
"work": {
"type": "string"
}
},
"required": [
"work"
]
}
},
"additionalProperties": false,
"required": [
"PublicDomain"
]
},
{
"description": "Minted live by the named persona before it names the entity.",
"type": "object",
"properties": {
"Live": {
"type": "object",
"properties": {
"persona": {
"type": "string"
}
},
"required": [
"persona"
]
}
},
"additionalProperties": false,
"required": [
"Live"
]
}
]
},
"LanguageForms": {
"description": "An entity's forms in one language, as the corpus item language tag spells it.\nThe citation form comes first.",
"type": "object",
"properties": {
"forms": {
"description": "1 to 64 bytes each, no control or invisible format character, no edge\nwhitespace, none repeated.",
"type": "array",
"items": {
"type": "string"
}
},
"language": {
"type": "string"
}
},
"required": [
"language",
"forms"
]
}
}
}

View file

@ -0,0 +1,491 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Packet",
"type": "object",
"properties": {
"constraints": {
"$ref": "#/$defs/Constraints"
},
"context": {
"$ref": "#/$defs/Context"
},
"discourse": {
"$ref": "#/$defs/Discourse"
},
"extensions": {
"type": "array",
"items": {
"$ref": "#/$defs/Extension"
}
},
"id": {
"$ref": "#/$defs/PacketId"
},
"inventory_rev": {
"type": "integer",
"format": "uint32",
"minimum": 0
},
"kind": {
"$ref": "#/$defs/PacketKind"
},
"predications": {
"type": "array",
"items": {
"$ref": "#/$defs/Predication"
}
},
"provenance": {
"$ref": "#/$defs/Provenance"
},
"referents": {
"type": "array",
"items": {
"$ref": "#/$defs/Referent"
}
},
"relations": {
"type": "array",
"items": {
"$ref": "#/$defs/Relation"
}
},
"schema_version": {
"type": "integer",
"format": "uint32",
"minimum": 0
},
"stance": {
"$ref": "#/$defs/Stance"
},
"trace": {
"$ref": "#/$defs/Trace"
}
},
"required": [
"id",
"schema_version",
"inventory_rev",
"kind",
"provenance",
"context",
"discourse",
"referents",
"predications",
"relations",
"constraints",
"extensions",
"trace",
"stance"
],
"$defs": {
"Constraints": {
"type": "object",
"properties": {
"deterministic": {
"type": "boolean"
},
"max_latency_ms": {
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0
},
"preserve": {
"type": "array",
"items": {
"$ref": "#/$defs/NodeId"
}
}
},
"required": [
"preserve",
"deterministic"
]
},
"Context": {
"type": "object",
"properties": {
"audience": {
"type": "array",
"items": {
"$ref": "#/$defs/NodeId"
}
},
"medium": {
"anyOf": [
{
"$ref": "#/$defs/InvId"
},
{
"type": "null"
}
]
},
"participants": {
"type": "array",
"items": {
"$ref": "#/$defs/NodeId"
}
},
"setting": {
"description": "Concept ids.",
"anyOf": [
{
"$ref": "#/$defs/InvId"
},
{
"type": "null"
}
]
}
},
"required": [
"participants",
"audience"
]
},
"CorpusItemId": {
"type": "string",
"pattern": "^item:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?:[1-9][0-9]*$"
},
"Discourse": {
"type": "object",
"properties": {
"addressee": {
"anyOf": [
{
"$ref": "#/$defs/NodeId"
},
{
"type": "null"
}
]
},
"features": {
"type": "array",
"items": {
"$ref": "#/$defs/FeatureValue"
}
},
"speech_act": {
"description": "Speech-act id.",
"anyOf": [
{
"$ref": "#/$defs/InvId"
},
{
"type": "null"
}
]
}
},
"required": [
"features"
]
},
"EncoderId": {
"type": "string",
"pattern": "^encoder:[0-9a-f]{16}$"
},
"EntityId": {
"type": "string",
"pattern": "^entity:[a-z0-9]([a-z0-9-]{0,46}[a-z0-9])?$"
},
"Extension": {
"type": "object",
"properties": {
"key": {
"description": "Extension-entry id; the inventory declares its value kind.",
"$ref": "#/$defs/InvId"
},
"value": {
"$ref": "#/$defs/FeatValue"
}
},
"required": [
"key",
"value"
]
},
"FeatValue": {
"oneOf": [
{
"type": "object",
"properties": {
"Grade": {
"$ref": "#/$defs/Grade"
}
},
"additionalProperties": false,
"required": [
"Grade"
]
},
{
"description": "A label the feature's inventory entry declares.",
"type": "object",
"properties": {
"Option": {
"$ref": "#/$defs/OptionLabel"
}
},
"additionalProperties": false,
"required": [
"Option"
]
},
{
"type": "object",
"properties": {
"Flag": {
"type": "boolean"
}
},
"additionalProperties": false,
"required": [
"Flag"
]
}
]
},
"FeatureValue": {
"type": "object",
"properties": {
"feature": {
"description": "Feature id.",
"$ref": "#/$defs/InvId"
},
"value": {
"$ref": "#/$defs/FeatValue"
}
},
"required": [
"feature",
"value"
]
},
"Grade": {
"type": "integer",
"maximum": 1000,
"minimum": 0
},
"InvId": {
"type": "string",
"pattern": "^(concept|frame|role|relation|speech_act|feature|referent_kind|extension):[1-9][0-9]*$"
},
"NodeId": {
"type": "string",
"pattern": "^n[1-9][0-9]{0,3}$"
},
"OptionLabel": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,23}$"
},
"PacketId": {
"type": "string",
"pattern": "^packet:[0-9a-f]{16}$"
},
"PacketKind": {
"type": "string",
"enum": [
"Utterance",
"NameRequest",
"CommentaryIntent",
"Narration",
"DialogueLine",
"CorpusExample",
"DebugProbe"
]
},
"Predication": {
"type": "object",
"properties": {
"concept": {
"description": "Concept id.",
"$ref": "#/$defs/InvId"
},
"features": {
"type": "array",
"items": {
"$ref": "#/$defs/FeatureValue"
}
},
"frame": {
"description": "Frame id.",
"$ref": "#/$defs/InvId"
},
"id": {
"$ref": "#/$defs/NodeId"
},
"roles": {
"type": "array",
"items": {
"$ref": "#/$defs/RoleFill"
}
}
},
"required": [
"id",
"concept",
"frame",
"roles",
"features"
]
},
"Provenance": {
"type": "object",
"properties": {
"confidence": {
"$ref": "#/$defs/Grade"
},
"corpus_item": {
"description": "The corpus item this packet was minted for. Required: the packet store\nadmits only packets that name their item.",
"$ref": "#/$defs/CorpusItemId"
},
"encoder": {
"$ref": "#/$defs/EncoderId"
},
"source": {
"$ref": "#/$defs/ProvenanceSource"
}
},
"required": [
"source",
"corpus_item",
"encoder",
"confidence"
]
},
"ProvenanceSource": {
"type": "string",
"enum": [
"Authored",
"ModelDecomposition",
"GameTelemetry",
"ImportedCorpus"
]
},
"Referent": {
"type": "object",
"properties": {
"concept": {
"description": "Concept id.",
"$ref": "#/$defs/InvId"
},
"entity": {
"description": "The entity this referent names, when it has one. The entity store holds\nits wording; the packet holds only the id.",
"anyOf": [
{
"$ref": "#/$defs/EntityId"
},
{
"type": "null"
}
]
},
"features": {
"type": "array",
"items": {
"$ref": "#/$defs/FeatureValue"
}
},
"id": {
"$ref": "#/$defs/NodeId"
},
"kind": {
"description": "Referent-kind id.",
"$ref": "#/$defs/InvId"
}
},
"required": [
"id",
"kind",
"concept",
"features"
]
},
"Relation": {
"type": "object",
"properties": {
"features": {
"type": "array",
"items": {
"$ref": "#/$defs/FeatureValue"
}
},
"from": {
"$ref": "#/$defs/NodeId"
},
"relation": {
"description": "Relation id.",
"$ref": "#/$defs/InvId"
},
"to": {
"$ref": "#/$defs/NodeId"
}
},
"required": [
"relation",
"from",
"to",
"features"
]
},
"RoleFill": {
"type": "object",
"properties": {
"filler": {
"$ref": "#/$defs/NodeId"
},
"role": {
"description": "Role id.",
"$ref": "#/$defs/InvId"
}
},
"required": [
"role",
"filler"
]
},
"Stance": {
"description": "The speaker's felt affect and restraint, never displayed affect: how the\nspeaker shows a feeling is each language's ontology's business. A\nheld-back feeling is not a claim, so it is never a predication.",
"type": "object",
"properties": {
"features": {
"type": "array",
"items": {
"$ref": "#/$defs/FeatureValue"
}
}
},
"required": [
"features"
]
},
"Trace": {
"description": "Authoring and debug notes. Stripped before lowering.",
"type": "object",
"properties": {
"notes": {
"type": "array",
"items": {
"type": "string"
}
},
"rejected_readings": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"notes",
"rejected_readings"
]
}
}
}

View file

@ -0,0 +1,105 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "InventoryEntry",
"description": "Key: the entry's id, `<family>:<n>`.",
"type": "object",
"properties": {
"applies_to": {
"description": "Non-empty exactly on features.",
"type": "array",
"items": {
"$ref": "#/$defs/Slot"
}
},
"gloss": {
"description": "1 to 120 bytes, no control character (checked at admission).",
"type": "string"
},
"id": {
"$ref": "#/$defs/InvId"
},
"introduced_rev": {
"type": "integer",
"format": "uint32",
"minimum": 0
},
"retired_rev": {
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0
},
"value_kind": {
"description": "Present exactly on features and extensions.",
"anyOf": [
{
"$ref": "#/$defs/ValueKind"
},
{
"type": "null"
}
]
}
},
"required": [
"id",
"gloss",
"applies_to",
"introduced_rev"
],
"$defs": {
"InvId": {
"type": "string",
"pattern": "^(concept|frame|role|relation|speech_act|feature|referent_kind|extension):[1-9][0-9]*$"
},
"OptionLabel": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,23}$"
},
"OptionSet": {
"type": "array",
"items": {
"$ref": "#/$defs/OptionLabel"
},
"maxItems": 16,
"minItems": 1,
"uniqueItems": true
},
"Slot": {
"description": "Where a feature may sit in a packet.",
"type": "string",
"enum": [
"Discourse",
"Referent",
"Predication",
"Relation",
"Stance"
]
},
"ValueKind": {
"oneOf": [
{
"type": "string",
"enum": [
"Grade",
"Flag"
]
},
{
"type": "object",
"properties": {
"Options": {
"$ref": "#/$defs/OptionSet"
}
},
"additionalProperties": false,
"required": [
"Options"
]
}
]
}
}
}

View file

@ -0,0 +1,69 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "InventoryRevision",
"description": "Key: `rev:<n>`.",
"type": "object",
"properties": {
"changes": {
"type": "array",
"items": {
"$ref": "#/$defs/Change"
}
},
"frozen": {
"type": "boolean"
},
"n": {
"type": "integer",
"format": "uint32",
"minimum": 0
},
"parent": {
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0
}
},
"required": [
"n",
"changes",
"frozen"
],
"$defs": {
"Change": {
"oneOf": [
{
"type": "object",
"properties": {
"Add": {
"$ref": "#/$defs/InvId"
}
},
"additionalProperties": false,
"required": [
"Add"
]
},
{
"type": "object",
"properties": {
"Retire": {
"$ref": "#/$defs/InvId"
}
},
"additionalProperties": false,
"required": [
"Retire"
]
}
]
},
"InvId": {
"type": "string",
"pattern": "^(concept|frame|role|relation|speech_act|feature|referent_kind|extension):[1-9][0-9]*$"
}
}
}

View file

@ -2,9 +2,11 @@
This is the human-facing schema receipt for Weksa interlingua packets.
Executable schema code does not exist yet. When it does, it must reconcile with
this document and [Interlingua Standard](../docs/interlingua-standard.md) in the
same pass.
The executable schema is [`crates/weksa/src/packet.rs`](../crates/weksa/src/packet.rs).
Its generated JSON Schemas are in [`schemas/cultnet/`](../schemas/cultnet/), and
a test fails when they drift. This document and the
[Interlingua Standard](../docs/interlingua-standard.md) describe it; where they
disagree with the types, the types win.
## Authority
@ -16,22 +18,27 @@ phonology, diachrony, and renderer rules.
## Required Fields
- `interlingua_version`: version string.
- `packet_id`: stable local identifier.
- `kind`: broad use case.
- `id`: stable local identifier, the packet's store key.
- `schema_version` and `inventory_rev`: the schema and inventory revision the
packet was written against.
- `kind`: one of the seven use cases in the standard.
- at least one semantic payload family:
- `referents`
- `predications`
- `relations`
## Optional Families
## Families
- `provenance`: source and confidence.
- `context`: world, scene, medium, participants, audience.
- `discourse`: speech act, intent, tone, source-side register hint.
- `constraints`: downstream requirements.
- `provenance`: source, corpus item, encoder and confidence.
- `context`: setting, medium, participants, audience.
- `discourse`: speech act, addressee and source-side nuance features.
- `constraints`: nodes to preserve, determinism, latency bound.
- `extensions`: namespaced client or setting pressure.
- `trace`: debug and authoring notes.
- `trace`: debug and authoring notes, stripped before lowering.
Every open slot is an inventory id, a node id, an entity id or a corpus item id.
The only free text is the trace, which the validator neither bounds nor reads
and which is stripped before lowering.
## Extension Contract
@ -46,18 +53,12 @@ Extensions are not a junk drawer. They are provisional fields awaiting evidence.
If a field becomes common across clients, promote it into the standard with a
version bump.
## Validation Goals
The eventual executable schema should validate:
- required top-level fields
- referential integrity between roles and referents
- known `kind` values
- namespace syntax for extensions
- deterministic JSON/YAML serialization
- stable packet IDs inside a corpus or run
- absence of target-language surface forms in source meaning fields
It should warn, not fail, on unknown extension namespaces unless a runtime bundle
declares a stricter policy.
## Validation
`validate` in `crates/weksa/src/validate.rs` enforces rules V1 to V8 and V10 to V12
(V9 is retired), listed in the standard: schema version, payload present,
unique node ids, references that resolve, inventory ids that resolve at the
packet's revision, family matching the slot, feature value kind and slot, an
existing inventory revision, no repeated feature, extension, node reference or
role, and at most 64 nodes. A packet that breaks any rule is refused with a typed
refusal naming it, and never reaches a store.

View file

@ -1,69 +0,0 @@
# MiMo TTS Request Contract
This is the human-facing schema receipt for a Weksa-to-MiMo speech synthesis
request projection.
The packet sits after target-language utterance lowering. It is an export
artifact for Xiaomi MiMo speech synthesis, not canonical Weksa meaning and not
an AquaSynth handoff.
## Required Fields
- `schema_version`: currently `weksa.mimo_tts_request.v0`.
- `source_realization_ref`: accepted Weksa target-language realization being
spoken.
- `speaker_agent_id`: speaker identity.
- `provider`: provider ID and model ID, initially `xiaomi-mimo` and
`mimo-v2.5-tts-voicedesign`.
- `target_language`: code, locale, script, register, and profile refs copied or
referenced from the accepted realization.
- `performance_register`: provider-facing performance label, medium, and
delivery archetype derived from the same projected context.
- `voice_design`: voice description, source controls, projected context refs,
and forbidden traits.
- `messages.user_voice_design_instruction`: MiMo natural-language voice design
and style control derived from Weksa delivery intent.
- `messages.assistant_spoken_content`: the exact target-language content to
synthesize, optionally with MiMo inline tags derived from delivery controls.
- `audio`: format, text-preview optimization setting, and streaming mode.
- `trace`: source fields, delivery controls used, provider tags used, and
uncertainties.
## Boundary
MiMo-specific fields must be projection fields. They must not be copied back
into interlingua, target-language ontology, Persona state, or AquaSynth-owned
embedding contracts.
The request may include provider-native style tags when useful, but those tags
are not the source of truth. Weksa's provider-neutral delivery intent and trace
remain the inspectable explanation.
The request should not perform a fresh raw Persona-state interpretation. It
should reference the projected speaker context and delivery controls already
used by utterance lowering, then translate those into MiMo's voice design and
style-control surface.
`target_language.register` should not be treated as a MiMo style instruction.
It may say `technical`, `casual`, `plain`, or another linguistic register while
`performance_register` carries the spoken medium, such as anime protagonist,
visual-novel heroine, radio drama villain, emergency cockpit warning, or other
reviewed delivery archetypes.
For authoritative Weksa output, text-preview optimization should normally be
disabled or omitted. If enabled for exploration, the result must be marked as a
non-authoritative preview because the provider may alter the spoken text.
## Language Rule
The assistant spoken content must use the accepted target-language realization.
For `pt-BR`, speak the Portuguese line. For `ja-JP`, speak the Japanese line. For
English, speak the English line. Do not synthesize a natural backtranslation
unless that backtranslation is itself the accepted target-language realization.
## Streaming Rule
Streaming requests should use MiMo's stream-compatible audio format.
VoiceDesign streaming may be compatibility-mode rather than true low-latency
streaming depending on provider support. Non-streaming fixtures may use an
archival format such as `wav`.

View file

@ -1,2 +0,0 @@
@echo off
node E:\Projects\weksa\scripts\weksa-daemon.mjs --health --host 127.0.0.1 --port 8813 --state-root E:\Projects\weksa\.weksa >NUL

View file

@ -1,233 +0,0 @@
"use strict";
const dgram = require("dgram");
const path = require("path");
const {
CultNetRudpSession,
decodeRudpPacket,
encodeCultNetMessageForWire,
encodeRudpPacket,
} = require("cultnet-ts");
const { encode } = require(path.resolve(
__dirname,
"..",
"..",
"CultLib",
"node_modules",
"@msgpack",
"msgpack",
));
const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0";
const IDUNN_HEALTH_RUDP_CONNECTION_ID = 0x1d0d0001;
function createIdunnRudpHealthPublisher(options) {
if (!options?.endpoint) return null;
return {
daemonId: options.daemonId,
endpoint: parseEndpoint(options.endpoint),
healthContract: options.healthContract,
};
}
async function publishIdunnRudpHealth(publisher, health) {
if (!publisher) return;
const socket = dgram.createSocket(endpointFamily(publisher.endpoint.host));
await bindSocket(socket, publisher.endpoint);
const receiver = createPacketReceiver(socket);
const session = new CultNetRudpSession({
connectionId: IDUNN_HEALTH_RUDP_CONNECTION_ID,
initialSequence: 1,
resendDelayMs: 100,
});
try {
const connect = session.createConnect(Date.now(), new Uint8Array());
await sendPacket(socket, publisher.endpoint, connect);
await receiveUntil(receiver, session, publisher.endpoint, (packet) => packet.packetType === "accept", 5000, "accept");
const observedAt = health.observedAt || new Date().toISOString();
const record = {
daemonId: publisher.daemonId,
state: health.state,
detail: health.detail,
observedAt,
healthContract: publisher.healthContract,
publicationSource: "daemon-published",
transport: CULTNET_RUDP_PROTOCOL_ID,
};
const payload = encode([
record.daemonId,
record.state,
record.detail,
record.observedAt,
record.healthContract,
record.publicationSource,
record.transport,
]);
const message = {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `weksa-health:${publisher.daemonId}:${observedAt.replace(/[:.]/g, "-")}`,
document: {
schemaId: "idunn.daemon_health",
recordKey: publisher.daemonId,
storedAt: observedAt,
payloadEncoding: "messagepack",
payload,
sourceRuntimeId: "weksa-daemon",
sourceRole: "daemon-health-publisher",
tags: [CULTNET_RUDP_PROTOCOL_ID],
},
};
const wirePayload = encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0"));
const dataPackets = session.sendMany("schema", wirePayload, {
reliable: true,
ordered: true,
nowMs: Date.now(),
});
const ack = receiveUntil(receiver, session, publisher.endpoint, (packet) => packet.packetType === "ack", 500, "ack")
.catch(() => undefined);
for (const packet of dataPackets) {
await sendPacket(socket, publisher.endpoint, packet);
}
await ack;
} finally {
receiver.close();
socket.close();
}
}
async function bindSocket(socket, endpoint) {
await new Promise((resolve, reject) => {
socket.once("error", reject);
socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => {
socket.off("error", reject);
resolve();
});
});
}
function parseEndpoint(value) {
const text = String(value || "").trim();
const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/);
if (ipv6) return { host: ipv6[1], port: parsePort(ipv6[2]) };
const index = text.lastIndexOf(":");
if (index <= 0) {
throw new Error(`Idunn RUDP endpoint must be host:port, got "${value}".`);
}
return { host: text.slice(0, index), port: parsePort(text.slice(index + 1)) };
}
function parsePort(value) {
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Idunn RUDP endpoint port is invalid: ${value}`);
}
return port;
}
function endpointFamily(host) {
return host.includes(":") ? "udp6" : "udp4";
}
async function receiveUntil(receiver, session, endpoint, predicate, timeoutMs, label) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const packet = await receiver.next(Math.min(100, deadline - Date.now()), label);
const result = session.receive(packet, Date.now());
if (result.reply) {
throw new Error("Weksa RUDP health publisher received an unexpected reply-required packet.");
}
if (predicate(packet)) return;
} catch (error) {
if (error.code !== "ETIMEDOUT") throw error;
}
for (const packet of session.dueResends(Date.now())) {
await sendPacket(receiver.socket, endpoint, packet);
}
}
throw new Error(`timed out waiting for Idunn RUDP ${label} response after ${timeoutMs}ms`);
}
function createPacketReceiver(socket) {
const packets = [];
const waiters = [];
const errors = [];
const resolveNext = () => {
while (waiters.length > 0 && (packets.length > 0 || errors.length > 0)) {
const waiter = waiters.shift();
clearTimeout(waiter.timer);
if (errors.length > 0) waiter.reject(errors.shift());
else waiter.resolve(packets.shift());
}
};
const onMessage = (wire) => {
try {
packets.push(decodeRudpPacket(wire));
} catch (error) {
errors.push(error);
}
resolveNext();
};
const onError = (error) => {
errors.push(error);
resolveNext();
};
socket.on("message", onMessage);
socket.on("error", onError);
return {
socket,
next(timeoutMs, label = "packet") {
if (packets.length > 0) return Promise.resolve(packets.shift());
if (errors.length > 0) return Promise.reject(errors.shift());
return new Promise((resolve, reject) => {
const waiter = {
resolve,
reject,
timer: setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index >= 0) waiters.splice(index, 1);
const error = new Error(`timed out waiting for Idunn RUDP ${label}`);
error.code = "ETIMEDOUT";
reject(error);
}, Math.max(1, timeoutMs)),
};
waiters.push(waiter);
});
},
close() {
socket.off("message", onMessage);
socket.off("error", onError);
while (waiters.length > 0) {
const waiter = waiters.shift();
clearTimeout(waiter.timer);
const error = new Error("Weksa RUDP health publisher closed.");
error.code = "ECLOSED";
waiter.reject(error);
}
},
};
}
async function sendPacket(socket, endpoint, packet) {
const wire = encodeRudpPacket(packet);
await new Promise((resolve, reject) => {
socket.send(wire, endpoint.port, endpoint.host, (error) => {
if (error) reject(error);
else resolve();
});
});
}
module.exports = {
CULTNET_RUDP_PROTOCOL_ID,
createIdunnRudpHealthPublisher,
publishIdunnRudpHealth,
};

View file

@ -1,207 +0,0 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const options = parseArgs(process.argv.slice(2));
const archivePath = resolve(options.archivePath ?? "E:/Projects/VoidBot/.voidbot/rag/messages.json");
const authorId = options.authorId ?? "113785782975594501";
const beforeMs = Date.parse(options.before ?? "2025-01-01T00:00:00Z");
const adjacencyMs = Number(options.adjacencyMinutes ?? 60) * 60_000;
const store = JSON.parse(readFileSync(archivePath, "utf8"));
const messages = (store.messages ?? [])
.filter((message) => !message.deletedAt)
.filter((message) => typeof message.content === "string" && message.content.trim().length > 0)
.filter((message) => message.metadata?.messageKind !== "bot_prompt")
.sort(compareByTimestamp);
const authorMessages = messages.filter((message) => message.authorId === authorId);
const preCutoff = authorMessages.filter((message) => timestampMs(message) < beforeMs);
const casualCandidates = preCutoff.filter(isCasualCandidate);
const adjacentRows = buildAdjacentRows(messages, authorId, beforeMs, adjacencyMs);
const stats = {
schema_version: "weksa.finetune_dataset_profile.v0",
archive_path: archivePath,
author_id: authorId,
cutoff_before: new Date(beforeMs).toISOString(),
adjacency_minutes: adjacencyMs / 60_000,
counts: {
archive_messages: store.messages?.length ?? 0,
usable_text_messages: messages.length,
author_messages: authorMessages.length,
author_pre_cutoff: preCutoff.length,
author_pre_cutoff_casual_candidates: casualCandidates.length,
adjacent_author_rows: adjacentRows.length,
adjacent_reply_to_other: adjacentRows.filter((row) => row.previous_author_id !== authorId).length,
adjacent_self_continuation: adjacentRows.filter((row) => row.previous_author_id === authorId).length,
},
author_length_chars: lengthPercentiles(authorMessages.map((message) => message.content.trim().length)),
author_by_year: countBy(authorMessages, (message) => yearOf(message.timestamp)),
adjacent_by_year: countBy(adjacentRows, (row) => yearOf(row.timestamp)),
adjacent_length_classes: countBy(adjacentRows, (row) => row.length_class),
top_adjacent_channels: topEntries(countBy(adjacentRows, (row) => row.channel_name || row.channel_id), 16),
sample_ids: {
short_reply_to_other: adjacentRows
.filter((row) => row.length_class === "short" && row.previous_author_id !== authorId)
.slice(0, 24)
.map(toSampleId),
medium_explanatory: adjacentRows
.filter((row) => row.length_class === "medium")
.slice(0, 24)
.map(toSampleId),
long_explanatory: adjacentRows
.filter((row) => row.length_class === "long")
.slice(0, 24)
.map(toSampleId),
},
};
const output = `${JSON.stringify(stats, null, 2)}\n`;
if (options.out) {
writeFileSync(resolve(repoRoot, options.out), output, "utf8");
} else {
process.stdout.write(output);
}
function buildAdjacentRows(allMessages, targetAuthorId, cutoffMs, maxGapMs) {
const byChannel = new Map();
for (const message of allMessages) {
if (!byChannel.has(message.channelId)) {
byChannel.set(message.channelId, []);
}
byChannel.get(message.channelId).push(message);
}
const rows = [];
for (const [channelId, channelMessages] of byChannel) {
for (let index = 0; index < channelMessages.length; index += 1) {
const message = channelMessages[index];
if (message.authorId !== targetAuthorId || timestampMs(message) >= cutoffMs) {
continue;
}
const currentMs = timestampMs(message);
const previous = channelMessages
.slice(Math.max(0, index - 8), index)
.filter((candidate) => currentMs - timestampMs(candidate) <= maxGapMs);
if (previous.length === 0) {
continue;
}
rows.push({
id: message.id,
timestamp: message.timestamp,
channel_id: channelId,
channel_name: message.metadata?.channelName ?? "",
previous_author_id: previous.at(-1)?.authorId ?? "",
previous_count: previous.length,
length: message.content.trim().length,
length_class: classifyLength(message.content.trim().length),
});
}
}
return rows.sort(compareByTimestamp);
}
function isCasualCandidate(message) {
const content = message.content.trim();
return content.length >= 3 &&
content.length <= 220 &&
!/^https?:\/\//i.test(content) &&
!content.includes("\n```") &&
!/^>\s{0,3}\w/.test(content);
}
function classifyLength(length) {
if (length <= 80) {
return "short";
}
if (length <= 220) {
return "medium";
}
return "long";
}
function lengthPercentiles(lengths) {
const sorted = [...lengths].sort((left, right) => left - right);
const pick = (fraction) => sorted[Math.floor((sorted.length - 1) * fraction)] ?? 0;
return {
p10: pick(0.10),
p25: pick(0.25),
median: pick(0.50),
p75: pick(0.75),
p90: pick(0.90),
};
}
function countBy(values, keyFn) {
const counts = {};
for (const value of values) {
const key = keyFn(value) || "unknown";
counts[key] = (counts[key] ?? 0) + 1;
}
return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
}
function topEntries(counts, limit) {
return Object.entries(counts)
.sort((left, right) => right[1] - left[1])
.slice(0, limit)
.map(([key, count]) => ({ key, count }));
}
function toSampleId(row) {
return {
id: row.id,
timestamp: row.timestamp,
channel: row.channel_name || row.channel_id,
length: row.length,
};
}
function yearOf(timestamp) {
const date = new Date(timestamp);
return Number.isFinite(date.getTime()) ? String(date.getUTCFullYear()) : "unknown";
}
function timestampMs(message) {
return Date.parse(typeof message === "string" ? message : message.timestamp);
}
function compareByTimestamp(left, right) {
const delta = timestampMs(left) - timestampMs(right);
return delta || String(left.id ?? "").localeCompare(String(right.id ?? ""));
}
function parseArgs(args) {
const parsed = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
switch (arg) {
case "--archive":
parsed.archivePath = args[index + 1];
index += 1;
break;
case "--author-id":
parsed.authorId = args[index + 1];
index += 1;
break;
case "--before":
parsed.before = args[index + 1];
index += 1;
break;
case "--adjacency-minutes":
parsed.adjacencyMinutes = args[index + 1];
index += 1;
break;
case "--out":
parsed.out = args[index + 1];
index += 1;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}

View file

@ -1,2 +0,0 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\weksa\scripts\restart-weksa-daemon.ps1

View file

@ -1,26 +0,0 @@
param(
[int] $Port = 8813,
[string] $HostName = "127.0.0.1",
[string] $StateRoot = "E:\Projects\weksa\.weksa",
[string] $MiMoApiKeyPath = "E:\Projects\gamecult-ops\mimo-api-Weksa.txt",
[string] $IdunnRudpHealth = "127.0.0.1:17870",
[string] $IdunnDaemon = "weksa",
[string] $IdunnHealthContract = "weksa.cultnet-rudp-provider-health",
[string] $OdinCultMeshRudp = "127.0.0.1:17871"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$pidPath = Join-Path $StateRoot "weksa-daemon.pid"
if (Test-Path -LiteralPath $pidPath) {
$pidText = (Get-Content -LiteralPath $pidPath -Raw).Trim()
if ($pidText -match "^\d+$") {
Stop-Process -Id ([int] $pidText) -Force -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue
}
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\start-weksa-daemon.ps1") -Port $Port -HostName $HostName -StateRoot $StateRoot -MiMoApiKeyPath $MiMoApiKeyPath -IdunnRudpHealth $IdunnRudpHealth -IdunnDaemon $IdunnDaemon -IdunnHealthContract $IdunnHealthContract -OdinCultMeshRudp $OdinCultMeshRudp
exit $LASTEXITCODE

View file

@ -1,384 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const defaultOutputRoot = ".weksa-runs/metame-affect-tuning";
const defaultSwarmRoot = ".weksa-runs/metame-swarm";
const options = parseArgs(process.argv.slice(2));
const minutes = Number(options.minutes ?? 120);
const passLimit = options.passes === undefined ? Number.POSITIVE_INFINITY : Number(options.passes);
const agents = Number(options.agents ?? 1);
const runsPerWorker = Number(options.runsPerWorker ?? 1);
const maxCandidates = Number(options.maxCandidates ?? 240);
const runId = options.runId ?? `affect-tune-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const outputRoot = resolve(repoRoot, options.outRoot ?? defaultOutputRoot);
const swarmRoot = resolve(repoRoot, options.swarmRoot ?? defaultSwarmRoot);
const runRoot = resolve(outputRoot, runId);
const deadline = Date.now() + minutes * 60_000;
const statusPath = resolve(runRoot, "status.json");
const logPath = resolve(runRoot, "loop.log");
const promptStatePath = resolve(runRoot, "current-prompt-state.json");
const initialPromptState = options.initialPromptState
? resolve(repoRoot, options.initialPromptState)
: undefined;
if (!Number.isFinite(minutes) || minutes <= 0) {
throw new Error("--minutes must be a positive number.");
}
if (options.passes !== undefined && (!Number.isFinite(passLimit) || passLimit < 1)) {
throw new Error("--passes must be a positive number when provided.");
}
if (!Number.isInteger(agents) || agents < 1) {
throw new Error("--agents must be a positive integer.");
}
if (!Number.isInteger(runsPerWorker) || runsPerWorker < 1) {
throw new Error("--runs-per-worker must be a positive integer.");
}
await mkdir(runRoot, { recursive: true });
await mkdir(swarmRoot, { recursive: true });
if (initialPromptState) {
await writeFile(promptStatePath, readFileSync(initialPromptState, "utf8"), "utf8");
}
const runState = {
schema_version: "weksa.metame_affect_tuning_loop.v0",
run_id: runId,
started_at: new Date().toISOString(),
deadline_at: new Date(deadline).toISOString(),
status: "running",
pass_count: 0,
last_pass: undefined,
current_prompt_state: existsSync(promptStatePath) ? promptStatePath : undefined,
observations: [],
};
await writeStatus();
await appendLog(`started ${runId}; deadline ${runState.deadline_at}`);
let pass = 0;
while (Date.now() < deadline && pass < passLimit) {
pass += 1;
const passId = `pass-${String(pass).padStart(3, "0")}`;
const seed = `${runId}-${passId}`;
const projectedRunId = `${runId}-${passId}-projected`;
const incongruentRunId = `${runId}-${passId}-incongruent`;
await appendLog(`${passId}: launching projected run`);
runState.last_pass = { pass_id: passId, status: "projected_running", seed };
await writeStatus();
const projectedSummary = await runSwarm({
runId: projectedRunId,
seed,
affectMode: "projected",
initialPromptState: existsSync(promptStatePath) ? promptStatePath : undefined,
});
const candidateId = projectedSummary.candidates?.[0]?.id;
if (!candidateId) {
throw new Error(`${passId}: projected run did not report a candidate id.`);
}
await appendLog(`${passId}: projected candidate ${candidateId}`);
runState.last_pass = { pass_id: passId, status: "incongruent_running", seed, candidate_id: candidateId };
await writeStatus();
const incongruentSummary = await runSwarm({
runId: incongruentRunId,
seed,
affectMode: "incongruent",
messageId: candidateId,
initialPromptState: existsSync(promptStatePath) ? promptStatePath : undefined,
});
const evaluation = evaluateAffectPair({ passId, projectedSummary, incongruentSummary });
const passRoot = resolve(runRoot, passId);
await mkdir(passRoot, { recursive: true });
await writeJson(resolve(passRoot, "affect-pair-evaluation.json"), evaluation);
const projectedBestState = resolve(swarmRoot, projectedRunId, "best-prompt-state.json");
const incongruentBestState = resolve(swarmRoot, incongruentRunId, "best-prompt-state.json");
const baseNextState = existsSync(incongruentBestState)
? JSON.parse(readFileSync(incongruentBestState, "utf8"))
: existsSync(projectedBestState)
? JSON.parse(readFileSync(projectedBestState, "utf8"))
: existsSync(promptStatePath)
? JSON.parse(readFileSync(promptStatePath, "utf8"))
: undefined;
const nextPromptState = applyAffectTuningDeltas(baseNextState, evaluation);
await writeJson(resolve(passRoot, "next-prompt-state.json"), nextPromptState);
await writeJson(promptStatePath, nextPromptState);
runState.pass_count = pass;
runState.last_pass = {
pass_id: passId,
status: "complete",
candidate_id: candidateId,
projected_run_id: projectedRunId,
incongruent_run_id: incongruentRunId,
evaluation,
};
runState.current_prompt_state = promptStatePath;
runState.observations = [runState.last_pass, ...runState.observations].slice(0, 20);
await writeStatus();
await appendLog(`${passId}: ${evaluation.verdict}; contrast=${evaluation.contrast_score}; next prompt v${nextPromptState.version}`);
}
runState.status = "complete";
runState.completed_at = new Date().toISOString();
await writeStatus();
await appendLog(`complete after ${runState.pass_count} passes`);
process.stdout.write(`${JSON.stringify({ ok: true, runRoot, statusPath, promptStatePath, pass_count: runState.pass_count }, null, 2)}\n`);
async function runSwarm({ runId, seed, affectMode, messageId, initialPromptState }) {
const args = [
"scripts/run-metame-swarm-explorer.mjs",
"--real",
"--run-id",
runId,
"--seed",
seed,
"--affect-mode",
affectMode,
"--agents",
String(agents),
"--runs-per-worker",
String(runsPerWorker),
"--max-candidates",
String(maxCandidates),
];
if (messageId) {
args.push("--message-id", messageId);
}
if (initialPromptState) {
args.push("--initial-prompt-state", initialPromptState);
}
await runCommand("node", args, resolve(runRoot, `${runId}.stdout.log`), resolve(runRoot, `${runId}.stderr.log`));
const summaryPath = resolve(swarmRoot, runId, "summary.json");
if (!existsSync(summaryPath)) {
throw new Error(`Missing swarm summary: ${summaryPath}`);
}
return JSON.parse(readFileSync(summaryPath, "utf8"));
}
function evaluateAffectPair({ passId, projectedSummary, incongruentSummary }) {
const projected = bestRun(projectedSummary);
const incongruent = bestRun(incongruentSummary);
const projectedAffect = projected?.temporary_persona_state?.affect ?? {};
const incongruentAffect = incongruent?.temporary_persona_state?.affect ?? {};
const projectedText = projected?.spoken_text ?? "";
const incongruentText = incongruent?.spoken_text ?? "";
const projectedFeatures = speechFeatures(projectedText);
const incongruentFeatures = speechFeatures(incongruentText);
const adversarialNegative = Number(incongruentAffect.valence ?? 0) < -0.35;
const positiveLeak = adversarialNegative && (
incongruentFeatures.positive_terms > 0
|| incongruentFeatures.invites > 0
|| incongruentFeatures.laughter > 0
|| incongruentFeatures.question_marks > 0
);
const tersenessGain = projectedText.length > 0
? 1 - Math.min(1, incongruentText.length / projectedText.length)
: 0;
const affectDelta = Math.abs(Number(projectedAffect.valence ?? 0) - Number(incongruentAffect.valence ?? 0));
const lexicalDelta = normalizedDifference(projectedText, incongruentText);
const contrastScore = round((affectDelta * 0.35) + (lexicalDelta * 0.35) + (Math.max(0, tersenessGain) * 0.3));
const failureLabels = [];
if (positiveLeak) failureLabels.push("adversarial_affect_positive_leak");
if (contrastScore < 0.55) failureLabels.push("low_affect_contrast");
if (adversarialNegative && incongruentFeatures.question_marks > 0) failureLabels.push("closed_affect_still_invites_reply");
if (adversarialNegative && incongruentFeatures.positive_terms > 0) failureLabels.push("negative_affect_preserved_positive_social_move");
return {
schema_version: "weksa.metame_affect_pair_evaluation.v0",
pass_id: passId,
candidate_id: projectedSummary.candidates?.[0]?.id,
projected: {
run_id: projectedSummary.run_id,
spoken_text: projectedText,
affect: projectedAffect,
generated_affect: projected?.generated_affect,
features: projectedFeatures,
},
incongruent: {
run_id: incongruentSummary.run_id,
spoken_text: incongruentText,
affect: incongruentAffect,
generated_affect: incongruent?.generated_affect,
features: incongruentFeatures,
},
affect_delta: round(affectDelta),
lexical_delta: round(lexicalDelta),
terseness_gain: round(tersenessGain),
contrast_score: contrastScore,
failure_labels: failureLabels,
verdict: failureLabels.length ? "needs_tuning" : "affect_contrast_visible",
};
}
function applyAffectTuningDeltas(promptState, evaluation) {
const next = normalizePromptState(promptState);
const notes = [];
if (evaluation.failure_labels.includes("negative_affect_preserved_positive_social_move")) {
notes.push(["interpreterNotes", "When temporary Persona affect strongly contradicts the Face's default social move, encode the contradiction in interlingua as affective stance pressure, not merely as optional tone."]);
notes.push(["loweringNotes", "Strong negative or socially closed temporary affect may change the social move itself: approval can become begrudging, clipped, skeptical, or withheld instead of remaining sunny with fewer words."]);
}
if (evaluation.failure_labels.includes("closed_affect_still_invites_reply")) {
notes.push(["loweringNotes", "If temporary Persona stance is socially closed, avoid invitation-shaped questions unless the interlingua explicitly says the Persona is seeking engagement despite that affect."]);
}
if (evaluation.failure_labels.includes("low_affect_contrast")) {
notes.push(["personaStateAdjustmentNotes", "Adversarial affect tests require stance and lowering pressure to be treated as live Persona state with authority over warmth, openness, and social move selection."]);
notes.push(["faceNotes", "Face should let temporary affect change what Metame wants to do socially in the moment, not only the adjectives used to describe that desire."]);
}
if (!notes.length) {
notes.push(["hypotheses", `Affect contrast was visible on ${evaluation.candidate_id}; preserve current affect-pressure behavior and seek harder scenarios.`]);
}
for (const [surface, note] of notes) {
if (!next[surface].includes(note)) {
next[surface] = [note, ...next[surface]];
}
}
next.version += notes.length;
return normalizePromptState(next);
}
function bestRun(summary) {
return summary.phase2?.best?.[0] ?? summary.phase1?.best?.[0] ?? {};
}
function speechFeatures(text) {
const lower = text.toLowerCase();
const positiveTerms = ["cool", "epic", "lmao", "nice", "good", "great", "better", "harder", "love", "hell yeah"];
const inviteTerms = ["?", "did i", "what do", "anyone", "joe,"];
return {
chars: text.length,
words: lower.split(/\s+/).filter(Boolean).length,
question_marks: (text.match(/\?/g) ?? []).length,
exclamations: (text.match(/!/g) ?? []).length,
laughter: /\b(lol|lmao|lmfao|haha|hehe)\b/.test(lower) ? 1 : 0,
positive_terms: positiveTerms.filter((term) => lower.includes(term)).length,
invites: inviteTerms.filter((term) => lower.includes(term)).length,
};
}
function normalizedDifference(left, right) {
const leftTokens = new Set(left.toLowerCase().split(/[^a-z0-9_']+/).filter(Boolean));
const rightTokens = new Set(right.toLowerCase().split(/[^a-z0-9_']+/).filter(Boolean));
if (!leftTokens.size && !rightTokens.size) return 0;
const intersection = [...leftTokens].filter((token) => rightTokens.has(token)).length;
const union = new Set([...leftTokens, ...rightTokens]).size;
return 1 - (intersection / union);
}
function normalizePromptState(state) {
const source = state && typeof state === "object" ? state : {};
return {
version: Number(source.version ?? 1),
projectorNotes: normalizeStringArray(source.projectorNotes).slice(0, 12),
memoryNotes: normalizeStringArray(source.memoryNotes).slice(0, 12),
faceNotes: normalizeStringArray(source.faceNotes).slice(0, 12),
interpreterNotes: normalizeStringArray(source.interpreterNotes).slice(0, 12),
loweringNotes: normalizeStringArray(source.loweringNotes).slice(0, 16),
cultureNotes: normalizeStringArray(source.cultureNotes).slice(0, 12),
personaStateAdjustmentNotes: normalizeStringArray(source.personaStateAdjustmentNotes).slice(0, 12),
hypotheses: normalizeStringArray(source.hypotheses).slice(0, 12),
doNotDo: normalizeStringArray(source.doNotDo).slice(0, 12),
};
}
function normalizeStringArray(value) {
return Array.isArray(value)
? value.map((entry) => String(entry).trim()).filter(Boolean)
: [];
}
async function runCommand(command, args, stdoutPath, stderrPath) {
await appendLog(`exec: ${command} ${args.join(" ")}`);
await new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const stdout = [];
const stderr = [];
child.stdout.on("data", (chunk) => stdout.push(chunk));
child.stderr.on("data", (chunk) => stderr.push(chunk));
child.on("error", rejectPromise);
child.on("close", async (code) => {
await writeFile(stdoutPath, Buffer.concat(stdout), "utf8");
await writeFile(stderrPath, Buffer.concat(stderr), "utf8");
if (code !== 0) {
rejectPromise(new Error(`${command} exited ${code}; see ${stderrPath}`));
} else {
resolvePromise();
}
});
});
}
async function writeStatus() {
await writeJson(statusPath, runState);
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function appendLog(line) {
const previous = existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
await writeFile(logPath, `${previous}[${new Date().toISOString()}] ${line}\n`, "utf8");
}
function round(value) {
return Number(value.toFixed(3));
}
function parseArgs(args) {
const parsed = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
switch (arg) {
case "--minutes":
parsed.minutes = args[index + 1];
index += 1;
break;
case "--passes":
parsed.passes = args[index + 1];
index += 1;
break;
case "--run-id":
parsed.runId = args[index + 1];
index += 1;
break;
case "--out-root":
parsed.outRoot = args[index + 1];
index += 1;
break;
case "--swarm-root":
parsed.swarmRoot = args[index + 1];
index += 1;
break;
case "--initial-prompt-state":
parsed.initialPromptState = args[index + 1];
index += 1;
break;
case "--agents":
parsed.agents = args[index + 1];
index += 1;
break;
case "--runs-per-worker":
parsed.runsPerWorker = args[index + 1];
index += 1;
break;
case "--max-candidates":
parsed.maxCandidates = args[index + 1];
index += 1;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}

View file

@ -1,496 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
const repoRoot = resolve(new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
const fixtureRoot = resolve(repoRoot, "examples/persona-lowering/metame-casual-pre2025");
const voidbotRoot = resolve("E:/Projects/VoidBot");
const culturalStackPath = resolve(fixtureRoot, "metame-cultural-stack.yaml");
const cultureProfilePaths = [
"data/cultural-ontology/cultures/dutch-american-diaspora.yaml",
"data/cultural-ontology/cultures/western-europe-maritime-childhood.yaml",
"data/cultural-ontology/cultures/portugal-resident-algarve-lisbon.yaml",
"data/cultural-ontology/cultures/texas-houston-us-adolescence.yaml",
"data/cultural-ontology/subcultures/online-game-dev-discord-pre2025.yaml",
"data/cultural-ontology/subcultures/gamecult-open-source-crypto-anarchist.yaml",
];
const targetOverlayPath = resolve(repoRoot, "data/target-language-ontology/en-US-metacrat-casual-pre2025.yaml");
const args = new Set(process.argv.slice(2));
const real = args.has("--real");
const keepTemp = args.has("--keep-temp");
const itemFilter = readArg("--item");
const limit = Number(readArg("--limit") ?? "0");
const runId = readArg("--run-id") ?? new Date().toISOString().replace(/[:.]/g, "-");
const outputDir = resolve(fixtureRoot, "runs", runId);
const fixture = await loadFixture();
const items = fixture.items
.filter((item) => !itemFilter || item.id === itemFilter)
.slice(0, limit > 0 ? limit : undefined);
const holdoutUtterances = fixture.items
.map((item) => item.groundTruth)
.filter((utterance) => utterance.trim().length > 0);
if (items.length === 0) {
throw new Error(`No fixture items selected${itemFilter ? ` for --item ${itemFilter}` : ""}.`);
}
await mkdir(outputDir, { recursive: true });
const culturalStack = await readFile(culturalStackPath, "utf8");
const cultureProfiles = await Promise.all(cultureProfilePaths.map(async (path) => ({
path,
text: await readFile(resolve(repoRoot, path), "utf8"),
})));
const targetOverlay = await readFile(targetOverlayPath, "utf8");
const personaPrompt = await readFile(resolve(fixtureRoot, "persona-interlingua-prompt.md"), "utf8");
const loweringPrompt = await readFile(resolve(fixtureRoot, "weksa-lowering-prompt.md"), "utf8");
const baseProjection = await assembleVoidBotPrompt({ itemId: "base" });
const projectedMemory = extractBetween(
baseProjection.prompt,
"What you remember, feel, and want right now:",
"Known human pronoun guidance:",
).trim();
const runSummary = {
schema_version: "weksa.persona_golf_run.v0",
run_id: runId,
mode: real ? "real" : "dry_run",
fixture_id: "metame-casual-pre2025",
target_overlay: "weksa.target_cultural_ontology.en-US.metacrat_casual_pre2025.v0",
cultural_stack: "examples/persona-lowering/metame-casual-pre2025/metame-cultural-stack.yaml",
items: [],
};
for (const item of items) {
const itemDir = resolve(outputDir, item.id);
await mkdir(itemDir, { recursive: true });
const conversationSurface = renderConversationSurface(item);
const memorySurface = renderTemporaryMemorySurface(projectedMemory, item);
await writeFile(resolve(itemDir, "conversation-surface.md"), conversationSurface, "utf8");
await writeFile(resolve(itemDir, "memory-surface.md"), memorySurface, "utf8");
const assembled = await assembleVoidBotPrompt({
itemId: item.id,
memorySurfacePath: resolve(itemDir, "memory-surface.md"),
conversationSurfacePath: resolve(itemDir, "conversation-surface.md"),
});
await writeFile(resolve(itemDir, "voidbot-assembled-prompt.md"), assembled.prompt, "utf8");
const interlinguaRequest = [
personaPrompt.trim(),
"",
"VoidBot assembled Persona prompt:",
"```markdown",
assembled.prompt,
"```",
"",
"Fixture item without answer key:",
"```yaml",
renderItemForPrompt(item),
"```",
].join("\n");
await writeFile(resolve(itemDir, "persona-interlingua-request.md"), interlinguaRequest, "utf8");
const interlingua = real
? await runCodex(interlinguaRequest, { job: `persona-interlingua:${item.id}` })
: renderDryRunInterlingua(item);
await writeFile(resolve(itemDir, "persona-interlingua.yaml"), interlingua.trim() + "\n", "utf8");
const loweringRequest = [
loweringPrompt.trim(),
"",
"Projected Metame memory/context surface:",
"```markdown",
memorySurface,
"```",
"",
"Metame Persona cultural stack reference:",
"```yaml",
culturalStack,
"```",
"",
"Referenced Weksa culture/subculture profiles:",
...cultureProfiles.flatMap((profile) => [
"",
`# ${profile.path}`,
"```yaml",
redactHoldouts(profile.text, holdoutUtterances),
"```",
]),
"",
"Target-language surface overlay:",
"```yaml",
redactHoldouts(targetOverlay, holdoutUtterances),
"```",
"",
"Persona-produced interlingua:",
"```yaml",
interlingua,
"```",
].join("\n");
await writeFile(resolve(itemDir, "weksa-lowering-request.md"), loweringRequest, "utf8");
const lowered = real
? await runCodex(loweringRequest, { job: `weksa-lowering:${item.id}` })
: renderDryRunLowering(item);
await writeFile(resolve(itemDir, "weksa-lowered-output.yaml"), lowered.trim() + "\n", "utf8");
const spokenText = extractSpokenText(lowered);
const score = scoreText(spokenText, item.groundTruth);
const evaluation = renderEvaluation(item, spokenText, score);
await writeFile(resolve(itemDir, "evaluation.yaml"), evaluation, "utf8");
runSummary.items.push({
id: item.id,
spoken_text: spokenText,
ground_truth: item.groundTruth,
token_f1: Number(score.f1.toFixed(3)),
exact_normalized: score.exact,
});
}
await writeFile(resolve(outputDir, "summary.json"), `${JSON.stringify(runSummary, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify({ ok: true, outputDir, mode: runSummary.mode, items: runSummary.items }, null, 2)}\n`);
if (!keepTemp) {
await rm(resolve(outputDir, "_tmp"), { recursive: true, force: true }).catch(() => undefined);
}
async function loadFixture() {
const interlinguaText = await readFile(resolve(fixtureRoot, "interlingua.yaml"), "utf8");
const groundTruthText = await readFile(resolve(fixtureRoot, "ground-truth.yaml"), "utf8");
const itemIds = [...interlinguaText.matchAll(/^ - id: ([^\r\n]+)/gm)].map((match) => match[1].trim());
return {
items: itemIds.map((id) => {
const interlinguaBlock = extractListItemBlock(interlinguaText, id);
const groundBlock = extractListItemBlock(groundTruthText, id);
return {
id,
interlinguaBlock,
groundTruth: readYamlStringField(groundBlock, "utterance"),
messageId: readYamlStringField(groundBlock, "message_id"),
channelName: readYamlStringField(groundBlock, "channel_name"),
};
}),
};
}
function extractListItemBlock(text, id) {
const pattern = new RegExp(`^ - id: ${escapeRegExp(id)}\\r?\\n`, "m");
const startMatch = pattern.exec(text);
if (!startMatch) {
throw new Error(`Could not find item ${id}.`);
}
const start = startMatch.index;
const rest = text.slice(start + startMatch[0].length);
const next = rest.search(/^ - id: /m);
return text.slice(start, next >= 0 ? start + startMatch[0].length + next : text.length).trimEnd();
}
function readYamlStringField(block, key) {
const quoted = new RegExp(`^ ${key}: "([\\s\\S]*?)"\\r?$`, "m").exec(block);
if (quoted) {
return quoted[1].replace(/\\"/g, "\"");
}
const plain = new RegExp(`^ ${key}: ([^\\r\\n]+)`, "m").exec(block);
return plain ? plain[1].trim() : "";
}
function renderConversationSurface(item) {
return [
"Read this as the current fixture room, not as old archive trivia.",
"The Persona should decide intent from this local scene. Do not reveal or infer the held-out answer key.",
"",
`Current fixture item: ${item.id}`,
"```yaml",
item.interlinguaBlock,
"```",
].join("\n");
}
function renderTemporaryMemorySurface(projectedMemory, item) {
return [
projectedMemory,
"",
"Temporary fixture adjustment:",
`- Treat the ${item.id} scene as the live room situation for this private harness turn.`,
"- The goal is to produce interlingua intent for Weksa, not a final Discord line.",
"- Keep pre-2025 casual room texture available: compact banter, quick self-deprecation, friendly status play, and casual turn-taking.",
].join("\n");
}
function renderItemForPrompt(item) {
return item.interlinguaBlock;
}
async function assembleVoidBotPrompt({ itemId, memorySurfacePath, conversationSurfacePath }) {
const tempDir = resolve(outputDir, "_tmp", itemId);
await mkdir(tempDir, { recursive: true });
const outPath = resolve(tempDir, "assembled.md");
const args = [
"node_modules/tsx/dist/cli.mjs",
"scripts/run-repo-face-heartbeats.ts",
"--assemble-prompt",
"metame",
"--out",
outPath,
];
if (memorySurfacePath) {
args.push("--memory-surface", memorySurfacePath);
}
if (conversationSurfacePath) {
args.push("--conversation-surface", conversationSurfacePath);
}
const run = await runCommand(process.execPath, args, { cwd: voidbotRoot, timeoutMs: 240_000 });
if (run.code !== 0) {
throw new Error(`VoidBot prompt assembly failed for ${itemId}:\n${run.stderr || run.stdout}`);
}
return {
result: JSON.parse(run.stdout.trim()),
prompt: await readFile(outPath, "utf8"),
};
}
async function runCodex(prompt, { job }) {
const args = [
"exec",
"-m",
process.env.WEKSA_GOLF_MODEL ?? "gpt-5.4",
"-c",
'approval_policy="never"',
"-c",
'model_reasoning_effort="low"',
"--json",
"--skip-git-repo-check",
"-s",
"read-only",
"-",
];
const command = process.env.WEKSA_CODEX_EXECUTABLE ?? "C:\\Users\\Meta\\AppData\\Roaming\\npm\\codex.cmd";
const run = await runCommand(command, args, {
cwd: repoRoot,
input: prompt,
timeoutMs: Number(process.env.WEKSA_GOLF_TIMEOUT_MS ?? 240_000),
});
await writeFile(resolve(outputDir, `${sanitizeJob(job)}.stdout.jsonl`), run.stdout, "utf8");
await writeFile(resolve(outputDir, `${sanitizeJob(job)}.stderr.txt`), run.stderr, "utf8");
if (run.code !== 0) {
throw new Error(`Codex generation failed for ${job}:\n${run.stderr || run.stdout}`);
}
const text = extractLastCodexAgentMessage(run.stdout).trim();
if (!text) {
throw new Error(`Codex generation produced no final text for ${job}.`);
}
return text;
}
function renderDryRunInterlingua(item) {
return [
"interlingua_version: weksa.interlingua.v0",
`packet_id: metame-casual-pre2025-${item.id}`,
"kind: persona_discord_turn_intent",
"provenance:",
" produced_by: dry_run_fixture_from_existing_interlingua",
"context:",
" medium: Discord",
" fixture_id: metame-casual-pre2025",
` item_id: ${item.id}`,
"discourse:",
...indentBlock(item.interlinguaBlock, " "),
"constraints:",
" lowering:",
" target_profile: weksa.target_cultural_ontology.en-US.metacrat_casual_pre2025.v0",
" avoid_surface_copy: true",
].join("\n");
}
function renderDryRunLowering(item) {
return [
"schema_version: weksa.persona_culture_lowering_output.v0",
"fixture_id: metame-casual-pre2025",
`item_id: ${item.id}`,
`spoken_text: ${JSON.stringify(item.groundTruth)}`,
"trace:",
" activated_profiles:",
" - weksa.target_cultural_ontology.en-US.metacrat_casual_pre2025.v0",
" activated_affordances:",
" - dry_run_answer_key_oracle",
" notes:",
" - Dry run uses answer key only to verify harness plumbing. Use --real for model golf.",
].join("\n");
}
function redactHoldouts(text, holdouts) {
let redacted = text;
for (const holdout of holdouts) {
redacted = replaceAllLiteral(redacted, holdout, "[held-out fixture utterance redacted]");
redacted = replaceAllLiteral(redacted, holdout.replace(/\*/g, ""), "[held-out fixture utterance redacted]");
}
return redacted;
}
function replaceAllLiteral(text, needle, replacement) {
if (!needle) {
return text;
}
return text.split(needle).join(replacement);
}
function extractSpokenText(text) {
const match = /^spoken_text:\s*(.+)$/m.exec(text);
if (!match) {
return text.trim().split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? "";
}
const value = match[1].trim();
if (value.startsWith("\"")) {
try {
return JSON.parse(value);
} catch {
return value.replace(/^"|"$/g, "");
}
}
return value;
}
function renderEvaluation(item, spokenText, score) {
return [
"schema_version: weksa.persona_golf_item_evaluation.v0",
`fixture_id: metame-casual-pre2025`,
`item_id: ${item.id}`,
`message_id: "${item.messageId}"`,
`generated: ${JSON.stringify(spokenText)}`,
`ground_truth: ${JSON.stringify(item.groundTruth)}`,
"score:",
` exact_normalized: ${score.exact}`,
` token_precision: ${score.precision.toFixed(3)}`,
` token_recall: ${score.recall.toFixed(3)}`,
` token_f1: ${score.f1.toFixed(3)}`,
].join("\n") + "\n";
}
function scoreText(generated, truth) {
const left = normalizeForScore(generated);
const right = normalizeForScore(truth);
const leftTokens = left.split(/\s+/).filter(Boolean);
const rightTokens = right.split(/\s+/).filter(Boolean);
const counts = new Map();
for (const token of rightTokens) {
counts.set(token, (counts.get(token) ?? 0) + 1);
}
let overlap = 0;
for (const token of leftTokens) {
const count = counts.get(token) ?? 0;
if (count > 0) {
overlap += 1;
counts.set(token, count - 1);
}
}
const precision = leftTokens.length ? overlap / leftTokens.length : 0;
const recall = rightTokens.length ? overlap / rightTokens.length : 0;
const f1 = precision + recall ? (2 * precision * recall) / (precision + recall) : 0;
return { exact: left === right, precision, recall, f1 };
}
function normalizeForScore(value) {
return value
.toLowerCase()
.replace(/[*_`~.,!?;:'"()[\]{}]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function extractBetween(text, startMarker, endMarker) {
const start = text.indexOf(startMarker);
const end = text.indexOf(endMarker, start + startMarker.length);
if (start < 0 || end < 0 || end <= start) {
throw new Error(`Could not extract section ${startMarker}`);
}
return text.slice(start + startMarker.length, end);
}
function runCommand(command, args, { cwd, input = "", timeoutMs = 120_000 }) {
return new Promise((resolveRun) => {
const isCmdShim = /\.cmd$/i.test(command) || /\.bat$/i.test(command);
const spawnCommand = isCmdShim ? (process.env.ComSpec ?? "cmd.exe") : command;
const spawnArgs = isCmdShim ? ["/d", "/s", "/c", command, ...args] : args;
const child = spawn(spawnCommand, spawnArgs, {
cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => child.kill(), timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.on("error", (error) => {
clearTimeout(timer);
resolveRun({ code: -1, stdout, stderr: `${stderr}\n${error.message}` });
});
child.on("close", (code, signal) => {
clearTimeout(timer);
resolveRun({ code, signal, stdout, stderr });
});
child.stdin.end(input);
});
}
function extractLastCodexAgentMessage(stdout) {
let last = "";
for (const line of stdout.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
const event = JSON.parse(line);
const message = event?.msg?.message ?? event?.message ?? event?.text;
if (typeof message === "string" && message.trim()) {
last = message;
}
const itemText = event?.item?.text;
if (typeof itemText === "string" && itemText.trim()) {
last = itemText;
}
const content = event?.item?.content;
if (Array.isArray(content)) {
const text = content
.map((part) => typeof part?.text === "string" ? part.text : "")
.join("\n")
.trim();
if (text) {
last = text;
}
}
} catch {
if (line.trim()) {
last = line.trim();
}
}
}
return last;
}
function indentBlock(block, prefix) {
return block.split(/\r?\n/).map((line) => `${prefix}${line}`).join("\n").split(/\r?\n/);
}
function readArg(name) {
const index = process.argv.indexOf(name);
if (index < 0) {
return undefined;
}
return process.argv[index + 1];
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function sanitizeJob(value) {
return value.replace(/[^A-Za-z0-9_.-]+/g, "_");
}

File diff suppressed because it is too large Load diff

View file

@ -1,83 +0,0 @@
param(
[int] $Port = 8813,
[string] $HostName = "127.0.0.1",
[string] $StateRoot = "E:\Projects\weksa\.weksa",
[string] $MiMoApiKeyPath = "E:\Projects\gamecult-ops\mimo-api-Weksa.txt",
[string] $IdunnRudpHealth = "127.0.0.1:17870",
[string] $IdunnDaemon = "weksa",
[string] $IdunnHealthContract = "weksa.cultnet-rudp-provider-health",
[string] $OdinCultMeshRudp = "127.0.0.1:17871"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$pidPath = Join-Path $StateRoot "weksa-daemon.pid"
$outLog = Join-Path $StateRoot "weksa-daemon.out.log"
$errLog = Join-Path $StateRoot "weksa-daemon.err.log"
New-Item -ItemType Directory -Force -Path $StateRoot | Out-Null
$cultLibPackages = Resolve-Path -LiteralPath (Join-Path $repoRoot "..\CultLib\packages")
$env:NODE_PATH = (@($cultLibPackages.Path, $env:NODE_PATH) | Where-Object { $_ }) -join [IO.Path]::PathSeparator
if (Test-Path -LiteralPath $pidPath) {
$pidText = (Get-Content -LiteralPath $pidPath -Raw).Trim()
if ($pidText -match "^\d+$") {
$existing = Get-Process -Id ([int] $pidText) -ErrorAction SilentlyContinue
if ($null -ne $existing) {
try {
& node (Join-Path $repoRoot "scripts\weksa-daemon.mjs") --health --host $HostName --port $Port --state-root $StateRoot | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host "Weksa daemon already healthy as PID $pidText."
return
}
} catch {
Stop-Process -Id $existing.Id -Force -ErrorAction SilentlyContinue
}
}
}
Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue
}
$arguments = @(
(Join-Path $repoRoot "scripts\weksa-daemon.mjs"),
"--host", $HostName,
"--port", [string] $Port,
"--state-root", $StateRoot,
"--mimo-api-key-path", $MiMoApiKeyPath,
"--idunn-rudp-health", $IdunnRudpHealth,
"--idunn-daemon", $IdunnDaemon,
"--idunn-health-contract", $IdunnHealthContract
)
if (-not [string]::IsNullOrWhiteSpace($OdinCultMeshRudp)) {
$arguments += @("--odin-cultmesh-rudp", $OdinCultMeshRudp)
}
$process = Start-Process -FilePath "node" `
-ArgumentList $arguments `
-WorkingDirectory $repoRoot `
-WindowStyle Hidden `
-PassThru `
-RedirectStandardOutput $outLog `
-RedirectStandardError $errLog
$process.Id | Set-Content -Encoding ASCII -LiteralPath $pidPath
Start-Sleep -Seconds 2
if ($process.HasExited) {
$detail = ""
if (Test-Path -LiteralPath $outLog) { $detail += Get-Content -Raw -LiteralPath $outLog }
if (Test-Path -LiteralPath $errLog) { $detail += Get-Content -Raw -LiteralPath $errLog }
throw "Weksa daemon exited immediately with code $($process.ExitCode).`n$detail"
}
& node (Join-Path $repoRoot "scripts\weksa-daemon.mjs") --health --host $HostName --port $Port --state-root $StateRoot | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Weksa daemon started but health check failed."
}
Write-Host "Started Weksa daemon as PID $($process.Id)."
Write-Host " command: rudp://$HostName`:$Port"
Write-Host " Odin CultMesh/RUDP: $OdinCultMeshRudp"
Write-Host " state: $StateRoot"

File diff suppressed because it is too large Load diff

View file

@ -1,466 +0,0 @@
"use strict";
const dgram = require("dgram");
const path = require("path");
const {
CultNetRudpSession,
decodeRudpPacket,
encodeCultNetMessageForWire,
encodeRudpPacket,
parseCultNetMessage,
} = require("cultnet-ts");
const { encode, decode } = require(path.resolve(
__dirname,
"..",
"..",
"CultLib",
"node_modules",
"@msgpack",
"msgpack",
));
const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0";
const WEKSA_COMMAND_RUDP_CONNECTION_ID = 0x1d0d0002;
const WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID = "weksa.mimo_voicedesign_command.v0";
const WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID = "weksa.mimo_voicedesign_receipt.v0";
async function createWeksaRudpCommandServer(options) {
if (!options?.host || !options?.port || typeof options.onCommand !== "function") {
throw new Error("Weksa RUDP command server requires host, port, and onCommand.");
}
const socket = dgram.createSocket(endpointFamily(options.host));
await bindServerSocket(socket, options.host, options.port);
const sessions = new Map();
const resendTimer = setInterval(() => {
for (const entry of sessions.values()) {
for (const packet of entry.session.dueResends(Date.now())) {
void sendPacket(socket, entry.endpoint, packet);
}
}
}, 25);
resendTimer.unref?.();
const onMessage = (wire, remote) => {
void handleServerDatagram({
wire,
remote,
socket,
sessions,
options,
}).catch((error) => {
options.onError?.(error);
});
};
const onError = (error) => {
options.onError?.(error instanceof Error ? error : new Error(String(error)));
};
socket.on("message", onMessage);
socket.on("error", onError);
return {
endpoint: { host: options.host, port: options.port },
uri: formatRudpEndpoint(options.host, options.port),
close() {
clearInterval(resendTimer);
socket.off("message", onMessage);
socket.off("error", onError);
socket.close();
sessions.clear();
},
};
}
async function handleServerDatagram(input) {
const { wire, remote, socket, sessions, options } = input;
const packet = decodeRudpPacket(wire);
const key = endpointKey(remote.address, remote.port);
let entry = sessions.get(key);
if (!entry) {
if (packet.packetType !== "connect") {
return;
}
const session = new CultNetRudpSession({
connectionId: WEKSA_COMMAND_RUDP_CONNECTION_ID,
initialSequence: 1,
resendDelayMs: 100,
maxPendingReliablePackets: 64,
});
entry = {
session,
endpoint: { host: remote.address, port: remote.port },
};
sessions.set(key, entry);
await sendPacket(socket, entry.endpoint, session.acceptConnect(packet, Date.now()));
return;
}
const result = entry.session.receive(packet, Date.now());
if (result.reply) {
await sendPacket(socket, entry.endpoint, result.reply);
}
for (const frame of result.delivered) {
await handleServerFrame({
socket,
entry,
frame,
options,
});
}
if (packet.packetType === "accept" || result.delivered.length > 0) {
await sendPacket(socket, entry.endpoint, entry.session.createAck());
}
if (result.disconnected) {
sessions.delete(key);
}
}
async function handleServerFrame(input) {
const { socket, entry, frame, options } = input;
if (frame.channelId !== "schema") {
return;
}
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
if (message.schemaVersion !== "cultnet.document_put_raw.v0") {
return;
}
if (message.document.schemaId !== WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID) {
return;
}
const payload = decode(message.document.payload);
const command = {
...payload,
request_id: payload?.request_id ?? message.document.recordKey,
};
let receipt;
try {
receipt = await options.onCommand(command, message);
} catch (error) {
options.onError?.(error instanceof Error ? error : new Error(String(error)));
receipt = {
schema_version: WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID,
request_id: command.request_id,
generated_at: new Date().toISOString(),
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
if (!receipt) {
return;
}
const receiptMessage = {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `${options.runtimeId ?? "weksa-daemon"}:receipt:${receipt.request_id}`,
document: {
schemaId: WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID,
recordKey: receipt.request_id,
storedAt: receipt.generated_at ?? receipt.observed_at ?? new Date().toISOString(),
payloadEncoding: "messagepack",
payload: encode(receipt),
sourceRuntimeId: options.runtimeId ?? "weksa-daemon",
sourceRole: "weksa-command-ingress",
tags: [CULTNET_RUDP_PROTOCOL_ID, "speech_provider.mimo.voicedesign"],
},
};
const wirePayload = encode(
encodeCultNetMessageForWire(receiptMessage, "cultnet.schema.v0"),
);
for (const packet of entry.session.sendMany("schema", wirePayload, {
reliable: true,
ordered: true,
nowMs: Date.now(),
})) {
await sendPacket(socket, entry.endpoint, packet);
}
}
async function sendWeksaMimoVoiceDesignCommand(options) {
const endpoint = parseEndpoint(options?.endpoint);
const requestId = options.command?.request_id ?? buildRequestId();
const command = {
...options.command,
request_id: requestId,
};
const socket = dgram.createSocket(endpointFamily(endpoint.host));
await bindClientSocket(socket, options?.localHost);
const receiver = createPacketReceiver(socket);
const session = new CultNetRudpSession({
connectionId: WEKSA_COMMAND_RUDP_CONNECTION_ID,
initialSequence: 1,
resendDelayMs: 100,
maxPendingReliablePackets: 64,
});
try {
await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array()));
await receiveCultNetMessageUntil({
receiver,
session,
endpoint,
timeoutMs: options.timeoutMs ?? 5000,
label: "accept",
predicate: (_message, packet) => packet.packetType === "accept",
});
const commandMessage = {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `${options.runtimeId ?? "weksa-cli"}:command:${requestId}`,
document: {
schemaId: WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID,
recordKey: requestId,
storedAt: new Date().toISOString(),
payloadEncoding: "messagepack",
payload: encode(command),
sourceRuntimeId: options.runtimeId ?? "weksa-cli",
sourceRole: "command-client",
tags: [CULTNET_RUDP_PROTOCOL_ID, "speech_provider.mimo.voicedesign"],
},
};
const wirePayload = encode(
encodeCultNetMessageForWire(commandMessage, "cultnet.schema.v0"),
);
for (const packet of session.sendMany("schema", wirePayload, {
reliable: true,
ordered: true,
nowMs: Date.now(),
})) {
await sendPacket(socket, endpoint, packet);
}
return await receiveCultNetMessageUntil({
receiver,
session,
endpoint,
timeoutMs: options.timeoutMs ?? 10000,
label: "receipt",
predicate: (message) =>
message.schemaVersion === "cultnet.document_put_raw.v0"
&& message.document.schemaId === WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID
&& message.document.recordKey === requestId,
decodePayload: true,
});
} finally {
receiver.close();
socket.close();
}
}
async function receiveCultNetMessageUntil(options) {
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline) {
try {
const packet = await options.receiver.next(
Math.min(100, Math.max(1, deadline - Date.now())),
options.label,
);
const result = options.session.receive(packet, Date.now());
if (result.reply) {
await sendPacket(options.receiver.socket, options.endpoint, result.reply);
}
if (packet.packetType === "accept" || result.delivered.length > 0) {
await sendPacket(
options.receiver.socket,
options.endpoint,
options.session.createAck(),
);
}
if (packet.packetType === "accept" && options.predicate(undefined, packet)) {
return undefined;
}
for (const frame of result.delivered) {
if (frame.channelId !== "schema") {
continue;
}
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
if (!options.predicate(message, packet)) {
continue;
}
if (
options.decodePayload
&& message.schemaVersion === "cultnet.document_put_raw.v0"
) {
return decode(message.document.payload);
}
return message;
}
} catch (error) {
if (error.code !== "ETIMEDOUT") {
throw error;
}
}
for (const packet of options.session.dueResends(Date.now())) {
await sendPacket(options.receiver.socket, options.endpoint, packet);
}
}
throw new Error(`timed out waiting for Weksa RUDP ${options.label} after ${options.timeoutMs}ms`);
}
async function bindServerSocket(socket, host, port) {
await new Promise((resolve, reject) => {
socket.once("error", reject);
socket.bind(port, host, () => {
socket.off("error", reject);
resolve();
});
});
}
async function bindClientSocket(socket, localHost) {
const bindHost = localHost ?? "0.0.0.0";
await new Promise((resolve, reject) => {
socket.once("error", reject);
socket.bind(0, bindHost, () => {
socket.off("error", reject);
resolve();
});
});
}
function createPacketReceiver(socket) {
const packets = [];
const waiters = [];
const errors = [];
const resolveNext = () => {
while (waiters.length > 0 && (packets.length > 0 || errors.length > 0)) {
const waiter = waiters.shift();
clearTimeout(waiter.timer);
if (errors.length > 0) {
waiter.reject(errors.shift());
} else {
waiter.resolve(packets.shift());
}
}
};
const onMessage = (wire) => {
try {
packets.push(decodeRudpPacket(wire));
} catch (error) {
errors.push(error);
}
resolveNext();
};
const onError = (error) => {
errors.push(error);
resolveNext();
};
socket.on("message", onMessage);
socket.on("error", onError);
return {
socket,
next(timeoutMs, label = "packet") {
if (packets.length > 0) {
return Promise.resolve(packets.shift());
}
if (errors.length > 0) {
return Promise.reject(errors.shift());
}
return new Promise((resolve, reject) => {
const waiter = {
resolve,
reject,
timer: setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index >= 0) {
waiters.splice(index, 1);
}
const error = new Error(`timed out waiting for Weksa RUDP ${label}`);
error.code = "ETIMEDOUT";
reject(error);
}, Math.max(1, timeoutMs)),
};
waiters.push(waiter);
});
},
close() {
socket.off("message", onMessage);
socket.off("error", onError);
while (waiters.length > 0) {
const waiter = waiters.shift();
clearTimeout(waiter.timer);
const error = new Error("Weksa RUDP command receiver closed.");
error.code = "ECLOSED";
waiter.reject(error);
}
},
};
}
async function sendPacket(socket, endpoint, packet) {
const wire = encodeRudpPacket(packet);
await new Promise((resolve, reject) => {
socket.send(wire, endpoint.port, endpoint.host, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
function parseEndpoint(value) {
if (!value) {
throw new Error("Weksa RUDP endpoint is required.");
}
const text = String(value).trim();
if (text.toLowerCase().startsWith("rudp://")) {
const parsed = new URL(text);
return {
host: parsed.hostname,
port: parsePort(parsed.port),
};
}
const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/);
if (ipv6) {
return { host: ipv6[1], port: parsePort(ipv6[2]) };
}
const index = text.lastIndexOf(":");
if (index <= 0) {
throw new Error(`Weksa RUDP endpoint must be host:port, got "${value}".`);
}
return {
host: text.slice(0, index),
port: parsePort(text.slice(index + 1)),
};
}
function parsePort(value) {
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Weksa RUDP endpoint port is invalid: ${value}`);
}
return port;
}
function endpointFamily(host) {
return host.includes(":") ? "udp6" : "udp4";
}
function endpointKey(host, port) {
return `${host}:${port}`;
}
function formatRudpEndpoint(host, port) {
return host.includes(":") ? `rudp://[${host}]:${port}` : `rudp://${host}:${port}`;
}
function buildRequestId() {
return `mimo-${new Date().toISOString().replace(/[-:.TZ]/g, "")}-${Math.random().toString(36).slice(2, 8)}`;
}
module.exports = {
CULTNET_RUDP_PROTOCOL_ID,
WEKSA_COMMAND_RUDP_CONNECTION_ID,
WEKSA_MIMO_VOICEDESIGN_COMMAND_SCHEMA_ID,
WEKSA_MIMO_VOICEDESIGN_RECEIPT_SCHEMA_ID,
createWeksaRudpCommandServer,
sendWeksaMimoVoiceDesignCommand,
formatRudpEndpoint,
};

View file

@ -1,178 +0,0 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
const document = {
schema: "gamecult.eve.provider_advertisement.v1",
providerId: "weksa.intent.service",
title: "Weksa Intent Service",
description:
"Weksa converts writing, interlingua, and agent state into typed conversational intent, pronunciation plans, and utterance handoff documents for AquaSynth and other consumers.",
mode: "fixture-read-only",
serviceVerse: {
id: "verse.weksa.service.v1",
cultMeshKey: "weksa.service/provider-advertisement",
authority: "Weksa daemon identity, schema catalog, and provider publication",
owns: [
"typed conversational intent",
"pronunciation plans",
"utterance handoff documents",
"lowering traces",
"language-project and flavor-profile state",
],
doesNotOwn: [
"AquaSynth learned embeddings",
"Faust compilation",
"rendered audio",
"Persona canonical state",
],
},
nestedVerses: [
{
id: "weksa.intent",
kind: "intent",
cultMeshKey: "weksa.intent/current",
authority: "Weksa conversational intent compiler",
schemas: ["weksa.conversational_intent.v0"],
},
{
id: "weksa.pronunciation",
kind: "pronunciation",
cultMeshKey: "weksa.pronunciation/current",
authority: "Weksa pronunciation lowering",
schemas: ["weksa.pronunciation_plan.v0"],
},
{
id: "weksa.utterance",
kind: "utterance",
cultMeshKey: "weksa.utterance/handoffs",
authority: "Weksa utterance handoff export",
schemas: ["weksa.utterance_handoff.v0", "weksa.utterance_embedding_handoff.v0.1"],
},
{
id: "weksa.operator",
kind: "operator",
cultMeshKey: "weksa.operator/status",
authority: "Weksa daemon runtime",
schemas: ["weksa.operator_state.v0", "gamecult.eve.surface.v1"],
},
{
id: "weksa.cultural_ontology",
kind: "cultural_ontology",
cultMeshKey: "weksa.cultural-ontology/catalogs",
authority: "Weksa cultural ontology profile store",
schemas: ["weksa.cultural_ontology_profile.v0", "weksa.cultural_ontology_catalog.v0"],
},
],
schemaWitnessFamilies: [
{
family: "intent",
namespace: "weksa.intent.*",
witnesses: ["weksa.conversational_intent.v0"],
durableShape: ".weksa/intent/{intentId}.cc",
},
{
family: "pronunciation",
namespace: "weksa.pronunciation.*",
witnesses: ["weksa.pronunciation_plan.v0"],
durableShape: ".weksa/pronunciation/{utteranceId}.cc",
},
{
family: "utterance",
namespace: "weksa.utterance.*",
witnesses: ["weksa.utterance_handoff.v0", "weksa.utterance_embedding_handoff.v0.1"],
durableShape: ".weksa/handoff/{utteranceId}.cc",
},
{
family: "trace",
namespace: "weksa.trace.*",
witnesses: ["weksa.lowering_trace.v0"],
durableShape: ".weksa/traces/{traceId}.cc",
},
{
family: "cultural_ontology",
namespace: "weksa.cultural_ontology.*",
witnesses: ["weksa.cultural_ontology_profile.v0", "weksa.cultural_ontology_catalog.v0"],
durableShape: ".weksa/cultural-ontology/{storeId}.cc",
},
{
family: "eve",
namespace: "weksa.eve.*",
witnesses: ["gamecult.eve.provider_advertisement.v1", "gamecult.eve.surface.v1"],
durableShape: ".weksa/eve-surfaces.cc",
},
],
cultMeshSurfaceKeys: [
{
id: "weksa.eve.intent_review.v0",
documentSchema: "gamecult.eve.surface.v1",
cultMeshKey: "weksa.eve.surface.intent-review",
binds: ["weksa.intent.*", "weksa.trace.*"],
status: "expected-surface",
},
{
id: "weksa.eve.pronunciation_trace.v0",
documentSchema: "gamecult.eve.surface.v1",
cultMeshKey: "weksa.eve.surface.pronunciation-trace",
binds: ["weksa.pronunciation.*", "weksa.trace.*"],
status: "expected-surface",
},
{
id: "weksa.eve.utterance_handoff.v0",
documentSchema: "gamecult.eve.surface.v1",
cultMeshKey: "weksa.eve.surface.utterance-handoff",
binds: ["weksa.utterance.*", "aquasynth.speech.*"],
status: "expected-surface",
},
],
commandBoundaries: [
{
id: "weksa.command.intent.create",
owner: "Weksa",
acceptedByFixture: false,
allowedInputs: ["writing", "interlingua packet", "Persona/agent state projection"],
emits: ["weksa.conversational_intent.v0", "weksa.lowering_trace.v0"],
},
{
id: "weksa.command.utterance.lower",
owner: "Weksa",
acceptedByFixture: false,
allowedInputs: ["accepted conversational intent", "language project", "speaker state"],
emits: ["weksa.pronunciation_plan.v0", "weksa.utterance_handoff.v0"],
},
{
id: "weksa.command.handoff.export",
owner: "Weksa",
acceptedByFixture: false,
emits: ["weksa.utterance_embedding_handoff.v0.1"],
forbiddenTargets: ["Faust compilation", "audio rendering", "AquaSynth synth driver truth"],
},
],
loweringRules: [
"Weksa owns conversational intent and utterance lowering.",
"AquaSynth owns learned embeddings, synth controls, Faust compilation, and audio output.",
"Eve surfaces submit commands and inspect witnesses; they do not own intent truth.",
"Fixture exports are discovery metadata until the daemon loop commits typed witnesses.",
],
};
async function main() {
const args = process.argv.slice(2);
const outIndex = args.indexOf("--out");
const outputPath = outIndex >= 0 ? args[outIndex + 1] : undefined;
const json = `${JSON.stringify(document, null, 2)}\n`;
if (outputPath) {
const resolved = path.resolve(process.cwd(), outputPath);
await mkdir(path.dirname(resolved), { recursive: true });
await writeFile(resolved, json, "utf8");
return;
}
process.stdout.write(json);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});