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 |
90 changed files with 12310 additions and 7542 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 typed CultMesh state that makes those documents inspectable. Eve is deprecated and Thing 2 is its successor; the operator Eve surface is quarantined (see the contract).
|
||||
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,7 +1,8 @@
|
|||
# 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 authority map.
|
||||
|
|
@ -9,15 +10,15 @@ 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,166 +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, 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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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.",
|
||||
"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.
|
||||
|
|
@ -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 through CultMesh for Odin discovery; the
|
||||
operator view returns on Thing 2 (Eve is deprecated).
|
||||
- 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, and daemon jobs 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
|
||||
|
|
|
|||
|
|
@ -12,14 +12,15 @@ 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, never free text, so a packet carries
|
||||
no target-language wording (`no-surface-leak`). The golf loop improves the spec
|
||||
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 is
|
||||
`weksa-lowering:ruling:spec-is-schema-plus-inventory`.
|
||||
(`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
|
||||
|
||||
|
|
@ -28,15 +29,19 @@ means no store holds it and nothing writes it.
|
|||
|
||||
| Kind | What names it | Lifecycle | Who decides |
|
||||
|---|---|---|---|
|
||||
| Packet | Key `packet:<item-id>:r<inventory-rev>:<encoder-digest>`; CultCache type `weksa.interlingua_packet`, carrying `schema_version` and `inventory_rev` | 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 the validator | The encoder writes; the validator refuses malformed packets at the door (`typed-packet`); nothing else writes packets |
|
||||
| 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>` (families: concept, frame, role, relation, speech_act, feature); type `weksa.inventory_entry`, with gloss, value kind, `introduced_rev`, optional `retired_rev` | 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; Self on merge to main |
|
||||
| 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 | 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 (`profile-owns-register`); never the lowerer's defaults |
|
||||
| Corpus item | Key `item:<source-slug>:<n>`; type `weksa.corpus_item` with text, language, speaker, split group, prior turns, source | Admitted once; the text is immutable (a correction is a new item that `supersedes` the old); 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. Model-written items never land in test | The hash function. Nobody assigns by hand, so there is no writer to forbid |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
@ -58,12 +63,103 @@ means no store holds it and nothing writes it.
|
|||
- 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, beta 2; a
|
||||
20-line implementation matched sacrebleu 2.4.3 exactly on four probe pairs)
|
||||
and meaning cosine (sentence embeddings from qwen3-embedding:0.6b, already
|
||||
served by Ollama on Yggdrasil). Probe on one reference: hedged paraphrase
|
||||
- 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.
|
||||
|
|
@ -74,10 +170,138 @@ means no store holds it and nothing writes it.
|
|||
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.
|
||||
- Leak check: an inventory entry minted in a round is refused if its gloss
|
||||
shares a word 3-gram with the corpus item it was minted for. The packet's
|
||||
only free text is proper names and the trace, so the gloss is the channel to
|
||||
guard.
|
||||
- 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`)
|
||||
|
||||
|
|
@ -104,18 +328,79 @@ 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; only the first two have specs.
|
||||
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. Waits on nothing. (spec)
|
||||
2. `packet-store`: Rust crate with packet, inventory and corpus types, the validator, the derived split, the stores and the `weksa` CLI; the standard reconciled to the types. Waits on nothing; sibling of 1. (spec)
|
||||
3. `scorer`: chrF, meaning cosine behind an `Embedder` port (Ollama on Yggdrasil), the leak check wired, `weksa score`. Waits on 2.
|
||||
4. `pd-importer`: public-domain play and fiction dialogue into the corpus with speaker, scene group and edition provenance. Waits on 2 and follow-up `gutenberg-terms`.
|
||||
5. `golf-loop-en`: the loop driver, `ModelPort` with the `claude -p` adapter, encoder and lowerer prompts, round records, the accept rule, the detached unit on Yggdrasil. Waits on 3, 4 and follow-up `claude-on-yggdrasil`.
|
||||
6. `style-axis`: a style-embedding score behind the scorer port. Waits on 3.
|
||||
7. `ja-panel`: ja-JP into the loop's panel. Waits on 5.
|
||||
8. `conlang-lexicon`: the first conlang's lexicon placed at concept embeddings, its morphology data and the gloss reader. Waits on 3 and question `first-conlang`.
|
||||
9. `convergence-freeze`: the convergence test, the freeze, the re-encode into training pairs. Waits on 5, 7 and 8.
|
||||
10. `distil`: fine-tune a lowerer on the frozen training set. Waits on 9 and a host question asked then.
|
||||
11. `live-lowering`: agents emit packets and Weksa lowers them for the reader. Waits on 9.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -65,11 +65,21 @@ reader, and gives Weksa's Persona a working body when her mind
|
|||
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.
|
||||
- `profile-owns-register`: address, politeness and forbidden flattenings come
|
||||
from the target profile, never from the lowerer's defaults.
|
||||
- `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`: a packet carries no target-language surface wording, so a
|
||||
lowering cannot score by copying text the encoder saw.
|
||||
- `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,139 +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`
|
||||
|
||||
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`,
|
||||
`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/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, and transport profile from the
|
||||
typed provider store and RUDP command boundary; no HTTP endpoint owns provider
|
||||
truth.
|
||||
|
||||
## Operator Dashboards
|
||||
|
||||
Eve is deprecated, and Thing 2 is its successor (`thing2:ruling:eve-dashboards-quarantined`, 2026-10-22). Weksa's operator Eve surface publisher is quarantined: removed from main and the build, parked at the `parked/weksa-eve-surface` tag, with follow-up `thing2:follow_up:weksa-operator-view-on-thing2` to rebuild it on Thing 2. Until Thing 2's surface contract is ready, Weksa publishes typed CultMesh state (operator state, command boundary, transport profile, provider advertisement) and no dashboard composition.
|
||||
|
||||
## 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.
|
||||
- Dashboard renderers do not own Weksa state.
|
||||
- Fixture emitters cannot become an alternate runtime truth.
|
||||
- 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,148 +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"],
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
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.",
|
||||
"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