Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 157892a95a | |||
|
|
c1b2778b15 | ||
|
|
22eba3a106 | ||
|
|
3d851bf0e1 | ||
| 8d9c0270d1 | |||
| d5ae292ebc | |||
| 4fb817b18f | |||
| ad02feeeb6 | |||
| 283678a58b | |||
| 86371d2ce4 | |||
| f1c9815b26 | |||
| 869c82b686 | |||
| 20865cac59 | |||
| 29d671e338 | |||
| 28d4d37802 | |||
| 3064667ce7 | |||
| 01afcfbdb4 | |||
| 6ba10a3fdc | |||
| c47e8e415d | |||
| 491536cb30 | |||
| 28284cc8db | |||
| b7aa687d13 | |||
| 548ddac8bd | |||
| 044971a375 | |||
| 9466b8342f | |||
| 35fa97913b | |||
| 3327eddca3 | |||
| 2583fb91c3 | |||
| 735708d33e | |||
| d76b96789d | |||
| 2cea2b8a6d | |||
| adb3615298 | |||
|
|
55c75e3bd0 |
94 changed files with 12799 additions and 7759 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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.
12
AGENTS.md
12
AGENTS.md
|
|
@ -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>`.
|
||||
|
|
|
|||
|
|
@ -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
1009
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/weksa"]
|
||||
56
README.md
56
README.md
|
|
@ -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
28
crates/weksa/Cargo.toml
Normal 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
142
crates/weksa/src/corpus.rs
Normal 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)
|
||||
}
|
||||
53
crates/weksa/src/entity.rs
Normal file
53
crates/weksa/src/entity.rs
Normal 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
199
crates/weksa/src/form.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
510
crates/weksa/src/inventory.rs
Normal file
510
crates/weksa/src/inventory.rs
Normal 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
35
crates/weksa/src/lib.rs
Normal 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
205
crates/weksa/src/lower.rs
Normal 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
553
crates/weksa/src/main.rs
Normal 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
432
crates/weksa/src/packet.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
25
crates/weksa/src/schema.rs
Normal file
25
crates/weksa/src/schema.rs
Normal 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>(),
|
||||
]
|
||||
}
|
||||
60
crates/weksa/src/score/chrf.rs
Normal file
60
crates/weksa/src/score/chrf.rs
Normal 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)
|
||||
}
|
||||
169
crates/weksa/src/score/diagnostics.rs
Normal file
169
crates/weksa/src/score/diagnostics.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
113
crates/weksa/src/score/embed.rs
Normal file
113
crates/weksa/src/score/embed.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
144
crates/weksa/src/score/mod.rs
Normal file
144
crates/weksa/src/score/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
44
crates/weksa/src/score/words.rs
Normal file
44
crates/weksa/src/score/words.rs
Normal 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
54
crates/weksa/src/split.rs
Normal 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
722
crates/weksa/src/store.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
525
crates/weksa/src/validate.rs
Normal file
525
crates/weksa/src/validate.rs
Normal 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
869
crates/weksa/tests/cli.rs
Normal 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(©).contains("S6TextHeld"),
|
||||
"{}",
|
||||
out(©)
|
||||
);
|
||||
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(¬_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"));
|
||||
}
|
||||
331
crates/weksa/tests/common/mod.rs
Normal file
331
crates/weksa/tests/common/mod.rs
Normal 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)),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
429
crates/weksa/tests/entities.rs
Normal file
429
crates/weksa/tests/entities.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
638
crates/weksa/tests/entity_door.rs
Normal file
638
crates/weksa/tests/entity_door.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
171
crates/weksa/tests/format_refused.txt
Normal file
171
crates/weksa/tests/format_refused.txt
Normal 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
152
crates/weksa/tests/forms.rs
Normal 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
212
crates/weksa/tests/lower.rs
Normal 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
692
crates/weksa/tests/score.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
38
crates/weksa/tests/seeds.rs
Normal file
38
crates/weksa/tests/seeds.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
153
crates/weksa/tests/split_schema.rs
Normal file
153
crates/weksa/tests/split_schema.rs
Normal 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
1294
crates/weksa/tests/stores.rs
Normal file
File diff suppressed because it is too large
Load diff
685
crates/weksa/tests/validate.rs
Normal file
685
crates/weksa/tests/validate.rs
Normal 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
1
data/corpus/corpus.cc
Normal 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-USentity: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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
@ -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: []
|
||||
|
|
@ -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: []
|
||||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
1
data/entity/entity.cc
Normal 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
|
||||
BIN
data/interlingua/inventory.cc
Normal file
BIN
data/interlingua/inventory.cc
Normal file
Binary file not shown.
|
|
@ -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.
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
88
docs/research/2026-10-29-golf-loop-prior-art.md
Normal file
88
docs/research/2026-10-29-golf-loop-prior-art.md
Normal 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.
|
||||
90
docs/research/2026-10-29-interlingua-lowering-prior-art.md
Normal file
90
docs/research/2026-10-29-interlingua-lowering-prior-art.md
Normal 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.
|
||||
71
docs/research/2026-10-29-lowering-strategy-prior-art.md
Normal file
71
docs/research/2026-10-29-lowering-strategy-prior-art.md
Normal 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.
|
||||
69
docs/research/2026-10-29-weksa-substrate-facts.md
Normal file
69
docs/research/2026-10-29-weksa-substrate-facts.md
Normal 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/`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
406
docs/targets/interlingua-lowering-map.md
Normal file
406
docs/targets/interlingua-lowering-map.md
Normal 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;
|
||||
"ab" 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.
|
||||
94
docs/targets/interlingua-lowering.md
Normal file
94
docs/targets/interlingua-lowering.md
Normal 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`.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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>"
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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: []
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
251
schemas/cultnet/weksa.corpus_item.schema.json
Normal file
251
schemas/cultnet/weksa.corpus_item.schema.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
123
schemas/cultnet/weksa.entity.schema.json
Normal file
123
schemas/cultnet/weksa.entity.schema.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
491
schemas/cultnet/weksa.interlingua_packet.schema.json
Normal file
491
schemas/cultnet/weksa.interlingua_packet.schema.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
105
schemas/cultnet/weksa.inventory_entry.schema.json
Normal file
105
schemas/cultnet/weksa.inventory_entry.schema.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
69
schemas/cultnet/weksa.inventory_revision.schema.json
Normal file
69
schemas/cultnet/weksa.inventory_revision.schema.json
Normal 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]*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\weksa\scripts\restart-weksa-daemon.ps1
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
});
|
||||
Loading…
Reference in a new issue