Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e06f00336 | |||
| e7775541fe | |||
| 16b9ef32ac | |||
| 69e1b1f327 | |||
| 1858023248 | |||
| 611460bec3 | |||
| 30afdae55b | |||
| 212bdbaf61 | |||
| d9187aeec1 | |||
| b7501a758d | |||
| 2bb9870338 | |||
| b66f0857d5 | |||
| bf571926c2 | |||
| 36297f8d97 | |||
| 0761f4ae7c | |||
| d77a83e637 | |||
| 448f21e6b2 | |||
| d8afc4ab85 | |||
| ba42639c3b | |||
| 76fa6856b2 | |||
| 44b66f94dc | |||
| 43ec24877f | |||
| 2dcc90796f | |||
| 516eb4b885 | |||
| 98ab798ca5 | |||
| 353dd2138f | |||
| 97aa026498 | |||
| ee3e4a2e38 | |||
| 8325d7e0b2 | |||
| a0aafaff19 | |||
| 736b6a4acb | |||
| 887e79ef44 | |||
| 754322252b | |||
| af0a56d45f | |||
| 349fe8e239 | |||
| 0a2d00a25f | |||
| ccef7a761f | |||
| 981ad0ea1f | |||
| 40469430b9 | |||
| 07c2755b95 | |||
| f1f508e159 | |||
| 7babf8aadc | |||
| 002d3186c3 | |||
|
|
139fa94b29 | ||
|
|
71b9d0e055 | ||
|
|
31a0c2406e | ||
|
|
966a3678a7 | ||
|
|
f7f4e58d78 | ||
| 3cc06693fb | |||
|
|
e8ba0f2eae | ||
|
|
dc5e649851 | ||
|
|
79fcc313ed | ||
|
|
fc99652f13 | ||
|
|
6f14b00090 | ||
| 0911c0198c | |||
|
|
e46f933268 | ||
|
|
602f7c2cd5 | ||
|
|
cd435e2a48 | ||
|
|
0ea07d21fa | ||
|
|
598cbf62d6 | ||
| d9ebbcf889 | |||
|
|
20863615b3 | ||
|
|
e30ee3886a | ||
|
|
4dbaef9437 | ||
| 9f203a5604 | |||
|
|
a1c1d9d599 | ||
|
|
f05906b1f7 | ||
|
|
edb7fff339 | ||
|
|
379c69eb06 | ||
|
|
14e94d4cb8 | ||
|
|
21eaa078d8 | ||
|
|
9e4486b789 | ||
|
|
3e4b035be6 | ||
|
|
167ce83cfc | ||
|
|
b83929d4d2 | ||
| e9cbd9bbfd | |||
|
|
ff3261a50f | ||
| 4298edf984 | |||
|
|
707e0204bf | ||
| 4cbd46d12c | |||
|
|
e8a9bbc5ab | ||
| 2d65239cbc | |||
|
|
00feb1d310 | ||
|
|
633bf831dc | ||
|
|
e0e3c93d97 | ||
|
|
e7aef81309 | ||
| da6bc7aa14 | |||
| b273be1a22 | |||
|
|
32368a998e | ||
|
|
d4c72c54aa | ||
|
|
073b022c8a | ||
|
|
9f8ae13a12 | ||
|
|
76984ecd71 | ||
|
|
829559bbc3 | ||
| d2cab1fd93 | |||
| 2f6a9673e6 | |||
|
|
7ee9d73c72 | ||
|
|
1a7be12374 | ||
|
|
99cf0cb39f | ||
|
|
99286d3892 | ||
|
|
ff60a34cbd | ||
|
|
1a20c977e0 | ||
| 0d5dfe53cf | |||
|
|
2759c0f12d | ||
|
|
2259b9f8c3 | ||
| aa6385edc3 | |||
|
|
5ebdb3cff4 | ||
|
|
3d3ca915ea | ||
|
|
1c08b2abac | ||
| 31375b703a | |||
|
|
0ead162d8e | ||
|
|
d954cfb9e9 | ||
| cbcfed6ede | |||
|
|
d502cb396a | ||
|
|
c527d0eb08 | ||
| 3df878f4fa | |||
|
|
00250088a8 | ||
| 23bfb02593 | |||
|
|
b569028f48 | ||
|
|
74ebafbc65 | ||
|
|
3b7a6803bc | ||
| 3bdfb514ae | |||
| 69e1f0dda3 | |||
|
|
7a803f5cb5 | ||
|
|
de46ced9ea | ||
|
|
da407e23af | ||
| e93862a57f | |||
| 5a125fef4e | |||
|
|
af35f72004 | ||
|
|
c1c19cc848 | ||
| 069820b323 | |||
|
|
0b2f6ce43b | ||
|
|
caaa3b0650 | ||
|
|
60e9cfca87 | ||
| f14d9d1592 | |||
| 377adae7e9 | |||
|
|
5f97aad6a2 | ||
|
|
592dbd7e67 | ||
|
|
c61ef8b7ea | ||
|
|
cd4295632a | ||
|
|
52e7c44990 | ||
|
|
c28f1fbb13 | ||
|
|
59fdd7fec7 | ||
|
|
f1fc31e420 | ||
|
|
b0321fe487 | ||
|
|
467ce98ea0 | ||
|
|
d02029c8b8 | ||
|
|
20ca3aaae5 | ||
|
|
afe1a1b434 | ||
|
|
1c1fd3cf10 | ||
|
|
ae384ee928 | ||
|
|
ae8deb1e03 | ||
|
|
a8918c6ff1 | ||
|
|
6d17f45ca2 | ||
|
|
fc52f3d3a4 | ||
|
|
a14bcaf9f8 | ||
| 7b407fdd13 | |||
|
|
2425030fe3 | ||
|
|
a014fc3f87 | ||
|
|
3e1e1a07da | ||
|
|
fd0c3b0edb | ||
|
|
8ac92f5af3 | ||
|
|
db543840a6 | ||
|
|
49879000f7 | ||
|
|
77d8b13952 | ||
|
|
e7b4d9a68b | ||
| 2be0a35ae7 | |||
|
|
eff94fb4af | ||
|
|
bb152d02ce | ||
|
|
e96acf97a5 | ||
| 7a8ffbea1e | |||
|
|
62059337a3 | ||
|
|
7a33a8d70f | ||
|
|
43f2e118f2 | ||
|
|
71e6058b64 | ||
|
|
3d83ba5fd2 | ||
|
|
d5b11088ad | ||
|
|
469e46cbc5 | ||
|
|
6ef1b7e034 | ||
|
|
0b62d2f3b4 | ||
| ee3a3c3404 | |||
|
|
109276cac4 | ||
| 6f68c18f01 | |||
|
|
35b32b9639 | ||
|
|
c1d530ccea | ||
|
|
b0b5728f07 | ||
|
|
3bd4a37645 | ||
|
|
a6dd52774a | ||
|
|
ce0208f0d0 | ||
|
|
45071fe346 | ||
|
|
ba52b01069 | ||
| 5e10d7cd51 | |||
|
|
a59d0a5e3c | ||
|
|
85e11bf52a | ||
| 42e72d66f9 | |||
|
|
169647ba9c | ||
| 1c9bf3df3a | |||
|
|
d933a3942a | ||
|
|
461f4f8126 | ||
|
|
8c94838c30 | ||
|
|
a300a1ce9e | ||
| 8fd1f5b3be | |||
|
|
3a56d3180f | ||
|
|
2ce6325b70 | ||
|
|
3cf499dbd4 | ||
|
|
453b3d6c2f | ||
| 860c714a1f | |||
|
|
34ce2dc413 | ||
|
|
8a78761a7a | ||
| fd18056b5b | |||
|
|
9ab3274af1 | ||
|
|
0ca9b5ebfe | ||
| 2308b91fea | |||
|
|
53e54148e0 | ||
|
|
0bf9499ac0 | ||
|
|
ec38d01f2f | ||
| 2a46b067d9 | |||
| 96a6630fac | |||
|
|
5e2a4a0ebb | ||
|
|
a7bc2478e5 | ||
|
|
5508000d21 | ||
|
|
a5c6392b4e | ||
|
|
17f44144a5 | ||
|
|
81572b1a54 | ||
| 73ce2de07f | |||
|
|
e7a3406b89 | ||
| 26c64dbeb5 | |||
|
|
5eb58c3100 |
121 changed files with 27547 additions and 2318 deletions
|
|
@ -1,27 +0,0 @@
|
||||||
# Cultural Ontology CultCache Seeds
|
|
||||||
|
|
||||||
This directory stores Weksa cultural ontology as CultCache `.cc` witnesses.
|
|
||||||
|
|
||||||
## Starter Database
|
|
||||||
|
|
||||||
- `starter-targets.cc`
|
|
||||||
|
|
||||||
This store contains one catalog entry and three draft guarded profile entries:
|
|
||||||
|
|
||||||
- `weksa.cultural_ontology.pt-BR.vanilla_brazilian.v0`
|
|
||||||
- `weksa.cultural_ontology.en.transatlantic.v0`
|
|
||||||
- `weksa.cultural_ontology.ja-JP.kanto.contemporary.v0`
|
|
||||||
|
|
||||||
The catalog entry is:
|
|
||||||
|
|
||||||
- `weksa.cultural_ontology.catalog.seed_targets.v0`
|
|
||||||
|
|
||||||
The `.cc` file is the typed state witness. This README is only an inspection
|
|
||||||
guide; it does not own cultural ontology truth.
|
|
||||||
|
|
||||||
The profiles follow `schemas/cultural-ontology-profile.md`: salience axes and
|
|
||||||
linguistic affordances are nested typed state with feature IDs, activation
|
|
||||||
gates, constraints, indexicality, evidence, uncertainty, and trace labels.
|
|
||||||
|
|
||||||
Qdrant retrieval may later propose evidence bundles for these profiles. It does
|
|
||||||
not own accepted profile state.
|
|
||||||
Binary file not shown.
|
|
@ -60,4 +60,4 @@ language-project truth.
|
||||||
`CARGO_HOME=/var/lib/mind-weksa-self/cargo-home cargo test --workspace`.
|
`CARGO_HOME=/var/lib/mind-weksa-self/cargo-home cargo test --workspace`.
|
||||||
- `weksa packet proof <store>` admits the nibu proof packet under the key
|
- `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
|
`packet:6e6962752d303031` (hex of `nibu-001`), not `nibu-proof-001`. Use that key
|
||||||
with `weksa lower <store> <key>`.
|
with `weksa pronounce <store> <key>`.
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -5,7 +5,8 @@ culture, and constraint, and the planned lowerer that turns an agent's
|
||||||
meaning packet into the reader's display language.
|
meaning packet into the reader's display language.
|
||||||
|
|
||||||
**What is valuable here.** The interlingua standard and the lowering design: a
|
**What is valuable here.** The interlingua standard and the lowering design: a
|
||||||
meaning packet owns what is meant, a target-language profile owns register,
|
meaning packet owns what is meant, the scene state (live: `weksa scene put|dump`, labelled by the rule in
|
||||||
|
`docs/scene-annotation.md`; speaker documents are planned) owns the speaker's relation to the addressee, which selects register,
|
||||||
address and politeness, and a lowerer owns surface text and leaves a trace.
|
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
|
**The problem it solves.** An agent's outward speech today is one English
|
||||||
|
|
@ -24,16 +25,21 @@ measured.
|
||||||
published schemas in `schemas/cultnet/`.
|
published schemas in `schemas/cultnet/`.
|
||||||
- `data/corpus/corpus.cc`, `data/entity/entity.cc` and `data/interlingua/inventory.cc`: the English
|
- `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`,
|
corpus, its entities and the inventory, read with `weksa corpus dump`,
|
||||||
`weksa entity dump` and `weksa inventory dump`.
|
`weksa entity dump` and `weksa inventory dump`. `data/original/inventory.cc`
|
||||||
|
carries its own Validation-informed lexicon revisions (5 and 6) for lowering
|
||||||
|
measurements; scored runs use `data/interlingua`.
|
||||||
- [Lowering target](./docs/targets/interlingua-lowering.md) and
|
- [Lowering target](./docs/targets/interlingua-lowering.md) and
|
||||||
[its map](./docs/targets/interlingua-lowering-map.md): the design, scoring
|
[its map](./docs/targets/interlingua-lowering-map.md): the design, scoring
|
||||||
and cut sequence.
|
and cut sequence.
|
||||||
- `data/`: the cultural ontology and target-language profile data.
|
- `data/`: the committed stores (corpora, entities, inventory, language ontology) and research data; there are no target-language profiles.
|
||||||
- `settings/`: setting-specific proving grounds for language projects.
|
- `settings/`: setting-specific proving grounds for language projects.
|
||||||
|
|
||||||
**Live versus planned.** Live: docs, fixtures, examples, ontology data and the
|
**Live versus planned.** Live: docs, fixtures, examples, ontology data and the
|
||||||
packet crate `crates/weksa`, including the scoring library. Planned: lowering,
|
packet crate `crates/weksa`, including `weksa pronounce` (the ruled IPA and phones
|
||||||
the `weksa score` subcommand, and the golf loop that scores lowered English against an expressive corpus. No
|
of the entities a packet cites), `weksa score`, and the golf loop's round and edit-accept
|
||||||
|
cycle (`weksa loop open|round|cycle`), which encodes and lowers through the
|
||||||
|
model (a prompted Claude subagent, per staged-distill).
|
||||||
|
Planned: Score and lowering documents and the other panel languages. No
|
||||||
daemon runs; the earlier Node daemon is parked at tag `parked/weksa-node-body`.
|
daemon runs; the earlier Node daemon is parked at tag `parked/weksa-node-body`.
|
||||||
|
|
||||||
## Ownership Model
|
## Ownership Model
|
||||||
|
|
@ -79,9 +85,17 @@ English, Brazilian Portuguese, Japanese, and later target-language dialogue.
|
||||||
[Flavored English Lowering](./docs/flavored-english-lowering.md) remains the
|
[Flavored English Lowering](./docs/flavored-english-lowering.md) remains the
|
||||||
target-specific English receipt.
|
target-specific English receipt.
|
||||||
|
|
||||||
See [Target-Language Cultural Ontology Profiles](./data/target-language-ontology/README.md)
|
The language ontology is `data/ontology/ontology.cc` (read it with
|
||||||
for the reviewable address, register, politeness, idiom, metaphor, and script
|
`weksa ontology dump`): per language, the lexicon, grammar and discourse choices a lowerer
|
||||||
distinctions that target lowerers must inspect before producing output.
|
reads for a stance, a discourse and a scene relation. Only an entry with a Scene condition names an
|
||||||
|
address or politeness level; the relation, from `data/original/speaker-state.cc`, picks it. Its
|
||||||
|
en-US and ja-JP entries and the keeper scenes are model-drafted or model-annotated, never Test evidence. Nothing yet grows them from accepted
|
||||||
|
rounds; until that mint exists they change only by a new `weksa ontology admit`. ja-JP holds Scene entries `onto:ja-JP:10` to `:13`. It holds none for an above-status out-group
|
||||||
|
addressee, and none for an above-status in-group addressee below intimacy 700 (`:12` still selects
|
||||||
|
an in-group addressee at intimacy 700 or more, whatever the status). Two entries for the above-status
|
||||||
|
cases were withdrawn: a paired measure found they made the lowerer match the reference register less
|
||||||
|
often, and they stay out until a native reader attests them.
|
||||||
|
ja-JP `:5` (hedging), `:7` (affection) and `:8` (restrained sadness) carry certainty and restraint conditions, through `AtMostOrUnset` bounds (an unrated certainty or restraint does not defeat them), so the hedging, certainty, affection, restraint and sadness entries never co-select on the pairs `:5`/`:6`, `:4`/`:7`, `:6`/`:7` and `:6`/`:8`; ja-JP `:3` (anger) carries `AtMostOrUnset` hedge and affection bounds, as en-US `:3`, so `:3`/`:5` and `:3`/`:7` never co-select; the store has no retire, so that was a rebuild of `data/ontology/ontology.cc` with the ja-JP text unchanged. en-US `:3` (blunt anger) carries `AtMostOrUnset` hedge and affection bounds, and `:5` (hedging) and `:8` (affection) each an `AtMostOrUnset` certainty bound (as ja-JP `:7`), so `:3`/`:5`, `:3`/`:8`, `:5`/`:6` and `:6`/`:8` never co-select; `:5`'s certainty clause is that condition and no longer prose; `:6` and the Scene entry `:10` still co-select and are kept apart by scoping in the text. ja-JP `:9` (surprise) states its question ending as available, not prescribed, so `:6`/`:9`, `:5`/`:9` and `:3`/`:9` stay co-selectable and are reconciled by wording, not by condition.
|
||||||
|
|
||||||
For the first AquaSynth speech-training handoff, see
|
For the first AquaSynth speech-training handoff, see
|
||||||
[Tiny PanPhon v0.1 Speech Curriculum](./examples/speech-training/tiny-panphon-v0.1/README.md).
|
[Tiny PanPhon v0.1 Speech Curriculum](./examples/speech-training/tiny-panphon-v0.1/README.md).
|
||||||
|
|
|
||||||
1028
crates/weksa/src/context.rs
Normal file
1028
crates/weksa/src/context.rs
Normal file
File diff suppressed because it is too large
Load diff
14
crates/weksa/src/context/mutants.rs
Normal file
14
crates/weksa/src/context/mutants.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
//! Pins for private reach of `context.rs` that no run can produce.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A corpus with no live Train item of the panel's languages holds an empty
|
||||||
|
/// train. (A run refuses a panel without Train items at open, so only the
|
||||||
|
/// builder shows it.)
|
||||||
|
#[test]
|
||||||
|
fn no_train_item_of_the_panel_is_an_empty_train() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
|
||||||
|
let train = TrainItems::from_corpus(&corpus, &["en-US".to_string()]).unwrap();
|
||||||
|
assert_eq!((train.len(), train.is_empty()), (0, true));
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::form::{FormError, is_slug, string_form};
|
use crate::form::{FormError, is_slug, string_form};
|
||||||
use crate::packet::EntityId;
|
use crate::packet::EntityId;
|
||||||
|
use crate::speaker::Annotator;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct Turn {
|
pub struct Turn {
|
||||||
|
|
@ -29,12 +30,28 @@ pub enum Source {
|
||||||
OperatorApproved {
|
OperatorApproved {
|
||||||
note: String,
|
note: String,
|
||||||
},
|
},
|
||||||
|
/// A public-domain work, with everything a reader needs to check the claim:
|
||||||
|
/// the edition's licence and the jurisdiction the public-domain status is
|
||||||
|
/// asserted in, who translated it (the translation is the text's copyright,
|
||||||
|
/// not the original's) and when the work or translation first appeared. A work missing any
|
||||||
|
/// is refused at admission.
|
||||||
PublicDomain {
|
PublicDomain {
|
||||||
work: String,
|
work: String,
|
||||||
author: String,
|
author: String,
|
||||||
edition_url: String,
|
edition_url: String,
|
||||||
|
licence: String,
|
||||||
|
jurisdiction: String,
|
||||||
|
translator: String,
|
||||||
|
/// The year the work, or for a translation this translation, first
|
||||||
|
/// appeared. It is not the year of the `edition_url` file's source
|
||||||
|
/// print: Project Gutenberg's text may be a later or modernised
|
||||||
|
/// edition, and the claim rests on the date of first appearance.
|
||||||
|
edition_year: u16,
|
||||||
},
|
},
|
||||||
/// Labelled model output; never in the test split, never judged by its own family.
|
/// Labelled model output; never in the test split. The rule that it is
|
||||||
|
/// never judged by its own family binds model judges and the split; a
|
||||||
|
/// deterministic scorer refuses nothing, so the loop labels (the round
|
||||||
|
/// counts items of the answerer's family) and does not forbid.
|
||||||
/// `family` is `Unrecorded` when nothing records one. `generator` is the
|
/// `family` is `Unrecorded` when nothing records one. `generator` is the
|
||||||
/// string the fixture or run wrote, kept verbatim.
|
/// string the fixture or run wrote, kept verbatim.
|
||||||
ModelWritten {
|
ModelWritten {
|
||||||
|
|
@ -45,7 +62,7 @@ pub enum Source {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The family of the model that wrote a line.
|
/// The family of the model that wrote a line.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub enum ModelFamily {
|
pub enum ModelFamily {
|
||||||
Anthropic,
|
Anthropic,
|
||||||
OpenAi,
|
OpenAi,
|
||||||
|
|
@ -54,6 +71,7 @@ pub enum ModelFamily {
|
||||||
Meta,
|
Meta,
|
||||||
Mistral,
|
Mistral,
|
||||||
/// Nothing records which family wrote the line.
|
/// Nothing records which family wrote the line.
|
||||||
|
#[default]
|
||||||
Unrecorded,
|
Unrecorded,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +104,25 @@ pub struct CorpusItem {
|
||||||
/// Which entities the text names, and where. Fixed with the text at intake.
|
/// Which entities the text names, and where. Fixed with the text at intake.
|
||||||
#[cultcache(key = 10)]
|
#[cultcache(key = 10)]
|
||||||
pub names: Vec<NameSpan>,
|
pub names: Vec<NameSpan>,
|
||||||
|
/// Who placed `names`: the annotator of the intake that wrote them. `None`
|
||||||
|
/// when nothing records one (the cast seeds, `weksa corpus add`, the Dao De
|
||||||
|
/// Jing bed); it labels the spans, never the line or its speaker.
|
||||||
|
#[cultcache(key = 11, default)]
|
||||||
|
pub names_by: Option<Annotator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CorpusItem {
|
||||||
|
/// The entities a packet for this item may cite (leak-owned-by-flow-and-scorer-3
|
||||||
|
/// (3)): the entities its text names, then its speaker, then its prior-turn
|
||||||
|
/// speakers (which may repeat). `PacketStore::admit` and `EncoderOffer` read
|
||||||
|
/// only this.
|
||||||
|
pub fn cited_entities(&self) -> impl Iterator<Item = &EntityId> {
|
||||||
|
self.names
|
||||||
|
.iter()
|
||||||
|
.map(|n| &n.entity)
|
||||||
|
.chain(std::iter::once(&self.speaker))
|
||||||
|
.chain(self.prior_turns.iter().map(|t| &t.speaker))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A byte range `[start, end)` of an item's text that names an entity. The
|
/// A byte range `[start, end)` of an item's text that names an entity. The
|
||||||
|
|
|
||||||
328
crates/weksa/src/daodejing.rs
Normal file
328
crates/weksa/src/daodejing.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
//! The Dao De Jing test bed: inventory revision 3 of the `data/daodejing`
|
||||||
|
//! stores and the packets for the slice of James Legge's chapters in its corpus.
|
||||||
|
//! It follows `proof_packet`'s precedent: a hand-authored packet the stores
|
||||||
|
//! admit through the same doors as any other.
|
||||||
|
//!
|
||||||
|
//! The ids carry the leak labels the golf loop needs. A frame minted while
|
||||||
|
//! reading a Train chapter is generic evidence; one minted for a Validation or
|
||||||
|
//! Test chapter was written with that chapter's reference in view, so a score
|
||||||
|
//! on it measures the lowerer table's reach and not the interlingua's.
|
||||||
|
|
||||||
|
use std::num::NonZeroU32;
|
||||||
|
|
||||||
|
use crate::corpus::CorpusItemId;
|
||||||
|
use crate::inventory::{Change, Family, InvId, InventoryEntry, InventoryRevision};
|
||||||
|
use crate::packet::{
|
||||||
|
Constraints, Context, Discourse, EncoderId, FeatValue, FeatureValue, Grade, NodeId, Packet,
|
||||||
|
PacketId, PacketKind, Predication, Provenance, ProvenanceSource, Referent, RoleFill,
|
||||||
|
SCHEMA_VERSION, Stance, Trace,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONCEPTS: &[&str] = &[
|
||||||
|
"sage",
|
||||||
|
"mind",
|
||||||
|
"belly",
|
||||||
|
"will",
|
||||||
|
"bone",
|
||||||
|
"empty",
|
||||||
|
"fill",
|
||||||
|
"weaken",
|
||||||
|
"strengthen",
|
||||||
|
"heaven",
|
||||||
|
"earth",
|
||||||
|
"is_long_enduring",
|
||||||
|
"continues_long",
|
||||||
|
"highest_excellence",
|
||||||
|
"water",
|
||||||
|
"true_words",
|
||||||
|
"seem_paradoxical",
|
||||||
|
"space_between_heaven_and_earth",
|
||||||
|
"bellows",
|
||||||
|
"compare",
|
||||||
|
"resemble",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Frame glosses, from `frame:4`. 4 is minted from a Train chapter, 5 and 6
|
||||||
|
/// from Validation chapters, 7 and 8 from Test chapters.
|
||||||
|
const FRAMES: &[&str] = &[
|
||||||
|
"act",
|
||||||
|
"intransitive",
|
||||||
|
"likeness",
|
||||||
|
"seems_paradoxical",
|
||||||
|
"may_be_compared",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Revision 3 of the Dao De Jing stores, after the seed and the proof revision.
|
||||||
|
pub fn revision() -> (InventoryRevision, Vec<InventoryEntry>) {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for (family, glosses) in [(Family::Concept, CONCEPTS), (Family::Frame, FRAMES)] {
|
||||||
|
let first = if family == Family::Concept { 7 } else { 4 };
|
||||||
|
for (i, gloss) in glosses.iter().enumerate() {
|
||||||
|
entries.push(InventoryEntry {
|
||||||
|
id: family.id(NonZeroU32::new(first + i as u32).expect("nonzero")),
|
||||||
|
gloss: (*gloss).to_string(),
|
||||||
|
value_kind: None,
|
||||||
|
applies_to: vec![],
|
||||||
|
introduced_rev: 3,
|
||||||
|
retired_rev: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
|
||||||
|
(
|
||||||
|
InventoryRevision {
|
||||||
|
n: 3,
|
||||||
|
parent: Some(2),
|
||||||
|
changes,
|
||||||
|
frozen: false,
|
||||||
|
},
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn concept(gloss: &str) -> &'static str {
|
||||||
|
let at = CONCEPTS
|
||||||
|
.iter()
|
||||||
|
.position(|c| *c == gloss)
|
||||||
|
.expect("known concept");
|
||||||
|
Box::leak(format!("concept:{}", 7 + at).into_boxed_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(gloss: &str) -> &'static str {
|
||||||
|
let at = FRAMES
|
||||||
|
.iter()
|
||||||
|
.position(|c| *c == gloss)
|
||||||
|
.expect("known frame");
|
||||||
|
Box::leak(format!("frame:{}", 4 + at).into_boxed_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex8(ascii: &str) -> String {
|
||||||
|
assert_eq!(ascii.len(), 8);
|
||||||
|
ascii.bytes().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(node, concept gloss, frame gloss, [(role id, filler node)])`.
|
||||||
|
type PredicationSpec = (
|
||||||
|
&'static str,
|
||||||
|
&'static str,
|
||||||
|
&'static str,
|
||||||
|
&'static [(&'static str, &'static str)],
|
||||||
|
);
|
||||||
|
|
||||||
|
struct Spec {
|
||||||
|
/// Eight ASCII characters; the packet id is their hex.
|
||||||
|
id: &'static str,
|
||||||
|
item: &'static str,
|
||||||
|
speech_act: &'static str,
|
||||||
|
/// `(node, referent kind id, concept gloss)`.
|
||||||
|
referents: &'static [(&'static str, &'static str, &'static str)],
|
||||||
|
predications: &'static [PredicationSpec],
|
||||||
|
/// The author's reading of the speaker's felt state: `(feature gloss, permille)`.
|
||||||
|
stance: &'static [(&'static str, u16)],
|
||||||
|
note: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT: &str = "role:1";
|
||||||
|
const PATIENT: &str = "role:2";
|
||||||
|
const EXPERIENCER: &str = "role:3";
|
||||||
|
const GOAL: &str = "role:6";
|
||||||
|
const PERSON: &str = "referent_kind:1";
|
||||||
|
const ORGANISM: &str = "referent_kind:3";
|
||||||
|
const PLACE: &str = "referent_kind:4";
|
||||||
|
const ABSTRACT: &str = "referent_kind:11";
|
||||||
|
|
||||||
|
/// Feature ids of the seed: restraint is the tenth stance feature.
|
||||||
|
fn stance_feature(gloss: &str) -> InvId {
|
||||||
|
let n = [
|
||||||
|
"anger",
|
||||||
|
"fear",
|
||||||
|
"sadness",
|
||||||
|
"joy",
|
||||||
|
"disgust",
|
||||||
|
"surprise",
|
||||||
|
"contempt",
|
||||||
|
"affection",
|
||||||
|
"shame",
|
||||||
|
"restraint",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.position(|g| *g == gloss)
|
||||||
|
.expect("stance feature");
|
||||||
|
// Features before the stance block: polarity, tense, aspect, modality,
|
||||||
|
// evidentiality, hedge, certainty, irony.
|
||||||
|
InvId::parse(&format!("feature:{}", 9 + n)).expect("feature id")
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPECS: &[Spec] = &[
|
||||||
|
// Chapter 3 section 2. Train.
|
||||||
|
Spec {
|
||||||
|
id: "dao-0302",
|
||||||
|
item: "item:daodejing:1",
|
||||||
|
speech_act: "speech_act:1",
|
||||||
|
referents: &[
|
||||||
|
("n1", PERSON, "sage"),
|
||||||
|
("n2", ABSTRACT, "mind"),
|
||||||
|
("n3", ORGANISM, "belly"),
|
||||||
|
("n4", ABSTRACT, "will"),
|
||||||
|
("n5", ORGANISM, "bone"),
|
||||||
|
],
|
||||||
|
predications: &[
|
||||||
|
("n6", "empty", "act", &[(AGENT, "n1"), (PATIENT, "n2")]),
|
||||||
|
("n7", "fill", "act", &[(AGENT, "n1"), (PATIENT, "n3")]),
|
||||||
|
("n8", "weaken", "act", &[(AGENT, "n1"), (PATIENT, "n4")]),
|
||||||
|
("n9", "strengthen", "act", &[(AGENT, "n1"), (PATIENT, "n5")]),
|
||||||
|
],
|
||||||
|
stance: &[("restraint", 800)],
|
||||||
|
note: "Four acts of one agent; 'in the exercise of his government' and 'their' are not carried.",
|
||||||
|
},
|
||||||
|
// Chapter 7 section 1, first sentence. Validation.
|
||||||
|
Spec {
|
||||||
|
id: "dao-0701",
|
||||||
|
item: "item:daodejing:2",
|
||||||
|
speech_act: "speech_act:1",
|
||||||
|
referents: &[("n1", PLACE, "heaven"), ("n2", PLACE, "earth")],
|
||||||
|
predications: &[
|
||||||
|
(
|
||||||
|
"n3",
|
||||||
|
"is_long_enduring",
|
||||||
|
"intransitive",
|
||||||
|
&[(EXPERIENCER, "n1")],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"n4",
|
||||||
|
"continues_long",
|
||||||
|
"intransitive",
|
||||||
|
&[(EXPERIENCER, "n2")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stance: &[("restraint", 800)],
|
||||||
|
note: "Heaven and earth as places; whether they are entities is a ruling the ontology owes.",
|
||||||
|
},
|
||||||
|
// Chapter 8 section 1, first sentence. Validation.
|
||||||
|
Spec {
|
||||||
|
id: "dao-0801",
|
||||||
|
item: "item:daodejing:3",
|
||||||
|
speech_act: "speech_act:1",
|
||||||
|
referents: &[
|
||||||
|
("n1", ABSTRACT, "highest_excellence"),
|
||||||
|
("n2", ABSTRACT, "water"),
|
||||||
|
],
|
||||||
|
predications: &[(
|
||||||
|
"n3",
|
||||||
|
"resemble",
|
||||||
|
"likeness",
|
||||||
|
&[(EXPERIENCER, "n1"), (GOAL, "n2")],
|
||||||
|
)],
|
||||||
|
stance: &[("restraint", 800), ("affection", 300)],
|
||||||
|
note: "The metaphor is a plain likeness predication; the tenor and vehicle are not marked.",
|
||||||
|
},
|
||||||
|
// Chapter 78 section 4. Test.
|
||||||
|
Spec {
|
||||||
|
id: "dao-7804",
|
||||||
|
item: "item:daodejing:4",
|
||||||
|
speech_act: "speech_act:1",
|
||||||
|
referents: &[("n1", ABSTRACT, "true_words")],
|
||||||
|
predications: &[(
|
||||||
|
"n2",
|
||||||
|
"seem_paradoxical",
|
||||||
|
"seems_paradoxical",
|
||||||
|
&[(EXPERIENCER, "n1")],
|
||||||
|
)],
|
||||||
|
stance: &[("restraint", 800)],
|
||||||
|
note: "The whole paradox is in the referent's concept and the frame; the packet holds no more than the surface does.",
|
||||||
|
},
|
||||||
|
// Chapter 5 section 2, first sentence. Test.
|
||||||
|
Spec {
|
||||||
|
id: "dao-0502",
|
||||||
|
item: "item:daodejing:5",
|
||||||
|
speech_act: "speech_act:2",
|
||||||
|
referents: &[
|
||||||
|
("n1", PLACE, "space_between_heaven_and_earth"),
|
||||||
|
("n2", ARTIFACT, "bellows"),
|
||||||
|
],
|
||||||
|
predications: &[(
|
||||||
|
"n3",
|
||||||
|
"compare",
|
||||||
|
"may_be_compared",
|
||||||
|
&[(EXPERIENCER, "n1"), (GOAL, "n2")],
|
||||||
|
)],
|
||||||
|
stance: &[("restraint", 800)],
|
||||||
|
note: "A rhetorical question; the speech act is ask and the negated 'may not' is dropped.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ARTIFACT: &str = "referent_kind:5";
|
||||||
|
|
||||||
|
/// The packets of the slice, each minted for its corpus item.
|
||||||
|
pub fn packets() -> Vec<Packet> {
|
||||||
|
SPECS.iter().map(build).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(spec: &Spec) -> Packet {
|
||||||
|
let id = |s: &str| InvId::parse(s).expect("ids are well formed");
|
||||||
|
let node = |s: &str| NodeId::parse(s).expect("nodes are well formed");
|
||||||
|
Packet {
|
||||||
|
id: PacketId::parse(&format!("packet:{}", hex8(spec.id))).expect("packet id"),
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
inventory_rev: 3,
|
||||||
|
kind: PacketKind::CorpusExample,
|
||||||
|
provenance: Provenance {
|
||||||
|
source: ProvenanceSource::Authored,
|
||||||
|
corpus_item: CorpusItemId::parse(spec.item).expect("item key"),
|
||||||
|
encoder: EncoderId::parse(&format!("encoder:{}", hex8("claude-s"))).expect("encoder"),
|
||||||
|
confidence: Grade::new(600).expect("grade"),
|
||||||
|
},
|
||||||
|
context: Context::default(),
|
||||||
|
discourse: Discourse {
|
||||||
|
speech_act: Some(id(spec.speech_act)),
|
||||||
|
addressee: None,
|
||||||
|
addressee_consent: None,
|
||||||
|
features: vec![],
|
||||||
|
},
|
||||||
|
referents: spec
|
||||||
|
.referents
|
||||||
|
.iter()
|
||||||
|
.map(|(n, kind, gloss)| Referent {
|
||||||
|
id: node(n),
|
||||||
|
kind: id(kind),
|
||||||
|
concept: id(concept(gloss)),
|
||||||
|
entity: None,
|
||||||
|
features: vec![],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
predications: spec
|
||||||
|
.predications
|
||||||
|
.iter()
|
||||||
|
.map(|(n, c, f, roles)| Predication {
|
||||||
|
id: node(n),
|
||||||
|
concept: id(concept(c)),
|
||||||
|
frame: id(frame(f)),
|
||||||
|
roles: roles
|
||||||
|
.iter()
|
||||||
|
.map(|(role, filler)| RoleFill {
|
||||||
|
role: id(role),
|
||||||
|
filler: node(filler),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
features: vec![],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
relations: vec![],
|
||||||
|
constraints: Constraints::default(),
|
||||||
|
extensions: vec![],
|
||||||
|
trace: Trace {
|
||||||
|
notes: vec![spec.note.to_string()],
|
||||||
|
rejected_readings: vec![],
|
||||||
|
},
|
||||||
|
stance: Stance {
|
||||||
|
features: spec
|
||||||
|
.stance
|
||||||
|
.iter()
|
||||||
|
.map(|(g, v)| FeatureValue {
|
||||||
|
feature: stance_feature(g),
|
||||||
|
value: FeatValue::Grade(Grade::new(*v).expect("grade")),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
1089
crates/weksa/src/encode.rs
Normal file
1089
crates/weksa/src/encode.rs
Normal file
File diff suppressed because it is too large
Load diff
13
crates/weksa/src/encode/mutants.rs
Normal file
13
crates/weksa/src/encode/mutants.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//! Pins for `unmark` that the import door only reaches through a whole work.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A form that opens another markup is malformed, though its entity id is
|
||||||
|
/// sound: the model wrote a nested or unclosed mark, not a name.
|
||||||
|
#[test]
|
||||||
|
fn a_form_that_opens_a_mark_is_malformed() {
|
||||||
|
assert_eq!(unmark("[[entity:orsa|Or[[sa]] came"), None);
|
||||||
|
// The same line without the nested opener unmarks.
|
||||||
|
let (plain, names) = unmark("[[entity:orsa|Orsa]] came").unwrap();
|
||||||
|
assert_eq!((plain.as_str(), names.len()), ("Orsa came", 1));
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,29 @@ pub struct LanguageForms {
|
||||||
/// 1 to 64 bytes each, no control or invisible format character, no edge
|
/// 1 to 64 bytes each, no control or invisible format character, no edge
|
||||||
/// whitespace, none repeated.
|
/// whitespace, none repeated.
|
||||||
pub forms: Vec<String>,
|
pub forms: Vec<String>,
|
||||||
|
/// How the citation form (`forms[0]`) is said, when someone with the
|
||||||
|
/// authority to rule on it has. Absent means unruled, never "spelled as
|
||||||
|
/// read".
|
||||||
|
#[serde(default)]
|
||||||
|
pub pronunciation: Option<Pronunciation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ruled pronunciation of an entity's citation form in one language.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct Pronunciation {
|
||||||
|
/// Broad IPA with stress marks, bare: no enclosing `/` or `[ ]`. 1 to 64
|
||||||
|
/// bytes, NFC.
|
||||||
|
pub ipa: String,
|
||||||
|
/// Who ruled: the entity itself, a writer, an operator.
|
||||||
|
pub ruled_by: String,
|
||||||
|
/// `YYYY-MM-DD`.
|
||||||
|
pub date: String,
|
||||||
|
/// Where the ruling was made: a room, a document, a run.
|
||||||
|
pub source: String,
|
||||||
|
/// Pronunciations that were considered and refused, same shape as `ipa`.
|
||||||
|
/// Kept so nobody re-proposes one; never emitted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rejected: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where an entity was minted.
|
/// Where an entity was minted.
|
||||||
|
|
@ -50,4 +73,12 @@ impl Entity {
|
||||||
.find(|f| f.language == language)
|
.find(|f| f.language == language)
|
||||||
.map(|f| f.forms.as_slice())
|
.map(|f| f.forms.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ruled pronunciation of the citation form in `language`, if any.
|
||||||
|
pub fn pronunciation_in(&self, language: &str) -> Option<&Pronunciation> {
|
||||||
|
self.forms
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.language == language)
|
||||||
|
.and_then(|f| f.pronunciation.as_ref())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ impl FormError {
|
||||||
|
|
||||||
impl fmt::Display for FormError {
|
impl fmt::Display for FormError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{:?} is not a {}", self.got, self.form)
|
// `got` stays a field for callers; the text does not echo it, because
|
||||||
|
// the value is often an identifier the caller supplied.
|
||||||
|
write!(f, "not a {}", self.form)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,20 +63,101 @@ macro_rules! string_form {
|
||||||
}
|
}
|
||||||
pub(crate) use string_form;
|
pub(crate) use string_form;
|
||||||
|
|
||||||
/// A BCP 47-form language tag: 1 to 35 bytes of ASCII alphanumerics in
|
/// A real calendar date written `YYYY-MM-DD`: four ASCII digits, a month 1 to
|
||||||
/// hyphen-separated subtags of 1 to 8 bytes; the first is 2 to 8 letters, or the
|
/// 12 and a day that month has in that year (leap years by the Gregorian rule).
|
||||||
/// single letter `x` of a private-use tag. Form only: no registry is consulted.
|
pub fn is_calendar_date(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let digits = |r: std::ops::Range<usize>| {
|
||||||
|
b[r.clone()]
|
||||||
|
.iter()
|
||||||
|
.all(u8::is_ascii_digit)
|
||||||
|
.then(|| s[r].parse::<u32>().ok())
|
||||||
|
.flatten()
|
||||||
|
};
|
||||||
|
let (Some(year), Some(month), Some(day)) = (digits(0..4), digits(5..7), digits(8..10)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||||
|
let days = match month {
|
||||||
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
2 if leap => 29,
|
||||||
|
2 => 28,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
(1..=days).contains(&day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A BCP 47 language tag in its canonical case (RFC 5646 section 2.1.1): 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. The
|
||||||
|
/// language subtag is lowercase, a four-letter script subtag is Titlecase, a
|
||||||
|
/// two-letter region subtag is UPPERCASE, and every other subtag, and every
|
||||||
|
/// subtag after a singleton such as `x`, is lowercase. Form only: no registry is
|
||||||
|
/// consulted. Selection and comparison are by exact tag, so a case variant would
|
||||||
|
/// be a different, dead language; this is the one tag check of corpus items,
|
||||||
|
/// entities, ontology entries and the run panel.
|
||||||
pub(crate) fn is_language_tag(s: &str) -> bool {
|
pub(crate) fn is_language_tag(s: &str) -> bool {
|
||||||
if s.len() > 35 {
|
if s.len() > 35 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mut subtags = s.split('-');
|
let mut subtags = s.split('-');
|
||||||
let first = subtags.next().unwrap_or("");
|
let first = subtags.next().unwrap_or("");
|
||||||
let first_ok = first.bytes().all(|b| b.is_ascii_alphabetic())
|
let first_ok = first.bytes().all(|b| b.is_ascii_lowercase())
|
||||||
&& ((2..=8).contains(&first.len()) || first == "x");
|
&& ((2..=8).contains(&first.len()) || first == "x");
|
||||||
first_ok
|
if !first_ok {
|
||||||
&& subtags
|
return false;
|
||||||
.all(|t| (1..=8).contains(&t.len()) && t.bytes().all(|b| b.is_ascii_alphanumeric()))
|
}
|
||||||
|
// `private`: inside the `x` private-use section (subtags 1 to 8, lowercase).
|
||||||
|
// `ext`: inside any singleton section, where a one-byte subtag starts the next.
|
||||||
|
// `pending`: the last subtag was a singleton and nothing has followed it yet.
|
||||||
|
let mut private = first == "x";
|
||||||
|
let mut ext = private;
|
||||||
|
let mut pending = private;
|
||||||
|
for t in subtags {
|
||||||
|
if !(1..=8).contains(&t.len()) || !t.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let lower = t.bytes().all(|b| !b.is_ascii_uppercase());
|
||||||
|
if private {
|
||||||
|
if !lower {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pending = false;
|
||||||
|
} else if t.len() == 1 {
|
||||||
|
// A singleton: lowercase, and not directly after another singleton.
|
||||||
|
if !lower || pending {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ext = true;
|
||||||
|
pending = true;
|
||||||
|
private = t == "x";
|
||||||
|
} else if ext {
|
||||||
|
// Extension subtags are 2 to 8 lowercase.
|
||||||
|
if !lower {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pending = false;
|
||||||
|
} else {
|
||||||
|
let alpha = t.bytes().all(|b| b.is_ascii_alphabetic());
|
||||||
|
let canonical = if t.len() == 2 {
|
||||||
|
// A region: two letters. Two digits are not a subtag at all.
|
||||||
|
alpha && t.bytes().all(|b| b.is_ascii_uppercase())
|
||||||
|
} else if alpha && t.len() == 4 {
|
||||||
|
t.as_bytes()[0].is_ascii_uppercase()
|
||||||
|
&& t.bytes().skip(1).all(|b| b.is_ascii_lowercase())
|
||||||
|
} else {
|
||||||
|
lower
|
||||||
|
};
|
||||||
|
if !canonical {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!pending
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A slug: `[a-z0-9]` and inner `-`, 1 to 48 bytes, no edge `-`.
|
/// A slug: `[a-z0-9]` and inner `-`, 1 to 48 bytes, no edge `-`.
|
||||||
|
|
@ -184,6 +267,25 @@ mod tests {
|
||||||
assert!(!is_format('\u{200c}') && !is_format('\u{200d}'));
|
assert!(!is_format('\u{200c}') && !is_format('\u{200d}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_language_tag_is_in_canonical_case() {
|
||||||
|
for good in [
|
||||||
|
"en", "en-US", "ja-JP", "zh-Hant-TW", "sr-Latn", "es-419", "de-1996", "en-CA-x-ca",
|
||||||
|
"x-private", "x-ab", "x-a", "x-a-b", "en-x-a-b", "en-u-nu-x-a-b", "en-u-nu-latn", "ja-JP-x-a1",
|
||||||
|
] {
|
||||||
|
assert!(is_language_tag(good), "{good}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"", "e", "EN", "En", "en-us", "en-Us", "en-uS", "ja-jp", "JA-JP", "zh-hant-TW",
|
||||||
|
"zh-HANT-TW", "zh-hAnt-TW", "zh-Hant-tw", "en-CA-x-CA", "en-CA-x-Ca", "de-1996-X",
|
||||||
|
"es-419-LATN", "en--US", "en-", "-en", "en_US", "abcdefghi", "en-abcdefghi",
|
||||||
|
"xx-\u{e9}", "x", "en-x", "en-u", "en-US-x", "en-0", "en-12", "en-u-x", "en-u-0",
|
||||||
|
"x-AB", "x-Ab", "en-x-AB", "en-a-b-ccc", "en-u-NU", "en-U-nu", "en-a-b",
|
||||||
|
] {
|
||||||
|
assert!(!is_language_tag(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_attestation_is_bounded_readable_text() {
|
fn an_attestation_is_bounded_readable_text() {
|
||||||
assert!(is_attestation("a"));
|
assert!(is_attestation("a"));
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,11 @@ impl InvId {
|
||||||
pub fn family(self) -> Family {
|
pub fn family(self) -> Family {
|
||||||
self.family
|
self.family
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `n` of `<family prefix>:<n>`.
|
||||||
|
pub fn n(self) -> NonZeroU32 {
|
||||||
|
self.n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InvId {
|
impl fmt::Display for InvId {
|
||||||
|
|
@ -189,9 +194,11 @@ pub enum Slot {
|
||||||
Predication,
|
Predication,
|
||||||
Relation,
|
Relation,
|
||||||
Stance,
|
Stance,
|
||||||
/// The relation of the speaker to the addressee in the scene; see
|
/// The speaker's relation to one party of the scene, held by `weksa.scene`
|
||||||
/// `LoweringInput::with_scene`.
|
/// documents; see `LoweringInput::with_scene`.
|
||||||
Scene,
|
Scene,
|
||||||
|
/// The speaker's stable axes, held by `weksa.speaker` documents.
|
||||||
|
Speaker,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
|
@ -228,10 +235,21 @@ impl InventoryEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the entry's shape matches its family: features carry a value kind
|
/// 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.
|
/// and slots, extensions a value kind and no slots, nothing else either. A
|
||||||
|
/// feature of slot `Stance`, `Speaker` or `Scene` sits in that slot alone: each
|
||||||
|
/// speaker-state layer has one writer and one lifetime, so its features sit
|
||||||
|
/// nowhere else.
|
||||||
pub fn well_formed(&self) -> bool {
|
pub fn well_formed(&self) -> bool {
|
||||||
match self.family() {
|
match self.family() {
|
||||||
Family::Feature => self.value_kind.is_some() && !self.applies_to.is_empty(),
|
Family::Feature => {
|
||||||
|
self.value_kind.is_some()
|
||||||
|
&& !self.applies_to.is_empty()
|
||||||
|
&& (self.applies_to.len() == 1
|
||||||
|
|| !self
|
||||||
|
.applies_to
|
||||||
|
.iter()
|
||||||
|
.any(|s| matches!(s, Slot::Stance | Slot::Speaker | Slot::Scene)))
|
||||||
|
}
|
||||||
Family::Extension => 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(),
|
_ => self.value_kind.is_none() && self.applies_to.is_empty(),
|
||||||
}
|
}
|
||||||
|
|
@ -485,9 +503,20 @@ pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
|
||||||
for (family, glosses) in [
|
for (family, glosses) in [
|
||||||
(
|
(
|
||||||
Family::Concept,
|
Family::Concept,
|
||||||
["endanger", "prevent", "player", "control", "ship_mind"].as_slice(),
|
[
|
||||||
|
"endanger",
|
||||||
|
"prevent",
|
||||||
|
"player",
|
||||||
|
"control",
|
||||||
|
"ship_mind",
|
||||||
|
"request",
|
||||||
|
]
|
||||||
|
.as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Family::Frame,
|
||||||
|
["hazard_warning", "intervention", "request_intervention"].as_slice(),
|
||||||
),
|
),
|
||||||
(Family::Frame, ["hazard_warning", "intervention"].as_slice()),
|
|
||||||
] {
|
] {
|
||||||
for (i, gloss) in glosses.iter().enumerate() {
|
for (i, gloss) in glosses.iter().enumerate() {
|
||||||
entries.push(InventoryEntry {
|
entries.push(InventoryEntry {
|
||||||
|
|
@ -512,28 +541,49 @@ pub fn proof_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Revision 3: the speaker's relation to the addressee, features of slot
|
/// Revision `n`: the speaker-state vocabulary. First the Scene features, the
|
||||||
/// `Scene`. Hand-seeded like [`proof_revision`] until the loop's minting round
|
/// speaker's relation to one party of a scene (slot `Scene`): `relative_status`
|
||||||
/// and cut `speaker-scene` own the Scene vocabulary. `relative_status` is the
|
/// is the speaker's standing against the addressee's, not a register word, what
|
||||||
/// speaker's standing against the addressee's, not a register word:
|
/// it selects is each language's ontology's business. Then the Speaker features
|
||||||
/// what it selects is each language's ontology's business. It mints the three
|
/// (slot `Speaker`): `socioeconomic_standing` is standing in the speaker's own
|
||||||
/// Scene features after the seed's features.
|
/// society, 0 lowest; `education` is formal schooling against that society;
|
||||||
pub fn scene_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
|
/// `age_cohort` is a life stage. Origin and community are not features; they
|
||||||
|
/// arrive as community ids with cut `language-ontology`. Hand-seeded like
|
||||||
|
/// [`proof_revision`] until the loop's minting round owns the vocabulary; the
|
||||||
|
/// features take the ids after the seed's.
|
||||||
|
pub fn speaker_scene_revision(n: u32) -> (InventoryRevision, Vec<InventoryEntry>) {
|
||||||
let taken = seed()
|
let taken = seed()
|
||||||
.1
|
.1
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| e.family() == Family::Feature)
|
.filter(|e| e.family() == Family::Feature)
|
||||||
.count() as u32;
|
.count() as u32;
|
||||||
let labels = ["below", "level", "above"]
|
let options = |labels: &[&str]| {
|
||||||
.map(|l| OptionLabel::parse(l).expect("seed labels are well formed"));
|
ValueKind::Options(
|
||||||
let relative_status = ValueKind::Options(
|
OptionSet::new(
|
||||||
OptionSet::new(labels.to_vec()).expect("seed option sets are well formed"),
|
labels
|
||||||
);
|
.iter()
|
||||||
|
.map(|l| OptionLabel::parse(l).expect("seed labels are well formed"))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.expect("seed option sets are well formed"),
|
||||||
|
)
|
||||||
|
};
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for (i, (gloss, kind)) in [
|
for (i, (gloss, kind, slot)) in [
|
||||||
("relative_status", relative_status),
|
(
|
||||||
("intimacy", ValueKind::Grade),
|
"relative_status",
|
||||||
("in_group", ValueKind::Flag),
|
options(&["below", "level", "above"]),
|
||||||
|
Slot::Scene,
|
||||||
|
),
|
||||||
|
("intimacy", ValueKind::Grade, Slot::Scene),
|
||||||
|
("in_group", ValueKind::Flag, Slot::Scene),
|
||||||
|
("socioeconomic_standing", ValueKind::Grade, Slot::Speaker),
|
||||||
|
("education", ValueKind::Grade, Slot::Speaker),
|
||||||
|
(
|
||||||
|
"age_cohort",
|
||||||
|
options(&["child", "adolescent", "young_adult", "adult", "elder"]),
|
||||||
|
Slot::Speaker,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
|
@ -542,16 +592,16 @@ pub fn scene_revision() -> (InventoryRevision, Vec<InventoryEntry>) {
|
||||||
id: Family::Feature.id(NonZeroU32::MIN.saturating_add(taken + i as u32)),
|
id: Family::Feature.id(NonZeroU32::MIN.saturating_add(taken + i as u32)),
|
||||||
gloss: gloss.to_string(),
|
gloss: gloss.to_string(),
|
||||||
value_kind: Some(kind),
|
value_kind: Some(kind),
|
||||||
applies_to: vec![Slot::Scene],
|
applies_to: vec![slot],
|
||||||
introduced_rev: 3,
|
introduced_rev: n,
|
||||||
retired_rev: None,
|
retired_rev: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
|
let changes = entries.iter().map(|e| Change::Add(e.id)).collect();
|
||||||
(
|
(
|
||||||
InventoryRevision {
|
InventoryRevision {
|
||||||
n: 3,
|
n,
|
||||||
parent: Some(2),
|
parent: Some(n - 1),
|
||||||
changes,
|
changes,
|
||||||
frozen: false,
|
frozen: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,47 @@
|
||||||
//! train/validation/test split, the stores that admit them, and the scorer
|
//! train/validation/test split, the stores that admit them, and the scorer
|
||||||
//! (chrF, meaning cosine, leak diagnostics).
|
//! (chrF, meaning cosine, leak diagnostics).
|
||||||
|
|
||||||
|
pub mod context;
|
||||||
pub mod corpus;
|
pub mod corpus;
|
||||||
|
pub mod daodejing;
|
||||||
|
pub mod encode;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod lower;
|
pub mod lower;
|
||||||
|
pub mod model;
|
||||||
|
pub mod ontology;
|
||||||
pub mod packet;
|
pub mod packet;
|
||||||
|
pub mod phones;
|
||||||
|
pub mod run;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod score;
|
pub mod score;
|
||||||
|
pub mod speaker;
|
||||||
pub mod split;
|
pub mod split;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod validate;
|
pub mod validate;
|
||||||
|
|
||||||
pub use corpus::{CorpusItem, CorpusItemId, NameSpan, Source, Turn};
|
pub use corpus::{CorpusItem, CorpusItemId, NameSpan, Source, Turn};
|
||||||
pub use entity::{Entity, EntityOrigin, LanguageForms};
|
pub use encode::{EditDraft, LoweringDraft, PacketDraft};
|
||||||
|
pub use entity::{Entity, EntityOrigin, LanguageForms, Pronunciation};
|
||||||
pub use form::FormError;
|
pub use form::FormError;
|
||||||
pub use inventory::{
|
pub use inventory::{
|
||||||
Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet,
|
Change, Family, InvId, Inventory, InventoryEntry, InventoryRevision, OptionLabel, OptionSet,
|
||||||
Slot, ValueKind,
|
Slot, ValueKind,
|
||||||
};
|
};
|
||||||
pub use lower::{EntityForms, LowerError, TARGET_LANGUAGE, lower};
|
pub use lower::{EntityForms, LowerError, PhoneLine, TARGET_LANGUAGE, phones, pronounce};
|
||||||
|
pub use model::{AnswerFiles, ModelPort};
|
||||||
|
pub use ontology::{Condition, Layer, Ontology, OntologyEntry, Test, select};
|
||||||
pub use packet::{
|
pub use packet::{
|
||||||
EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance,
|
EncoderId, EntityId, Grade, NodeId, Packet, PacketId, PacketKind, SCHEMA_VERSION, Stance,
|
||||||
};
|
};
|
||||||
|
pub use phones::{Phone, PhoneInventory, SegmentError, Stress};
|
||||||
|
pub use run::{Decision, RoundRecord, RunStores, cycle, round};
|
||||||
pub use score::{Embedder, ItemScore, OllamaEmbedder, Scorer, chrf};
|
pub use score::{Embedder, ItemScore, OllamaEmbedder, Scorer, chrf};
|
||||||
|
pub use speaker::{Annotator, PairRelation, Party, Scene, Speaker};
|
||||||
pub use split::{Split, SplitScheme};
|
pub use split::{Split, SplitScheme};
|
||||||
pub use store::{
|
pub use store::{
|
||||||
AddOutcome, CorpusStore, EntityBound, EntityStore, InventoryStore, PacketStore, StoreError,
|
AddOutcome, CorpusStore, EntityBound, EntityStore, InventoryStore, OntologyStore, PacketStore,
|
||||||
StoreRefusal,
|
SpeakerStateStore, StoreError, StoreRefusal,
|
||||||
};
|
};
|
||||||
pub use validate::{LoweringInput, Refusal, validate};
|
pub use validate::{LoweringInput, Refusal, SceneRead, validate};
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,27 @@
|
||||||
//! The English lowerer: the validated lowering view of a packet to text, by a
|
//! The entity port and the ruled pronunciation of a packet's entities.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! A referent that cites an entity renders as that entity's own form, read
|
//! Wording of a packet in a target language is the `ModelPort`'s answer to
|
||||||
//! through the [`EntityForms`] port: the first (citation) form stored for
|
//! `Prompt::lower` (`context.rs`); nothing here produces lowered text. This
|
||||||
//! [`TARGET_LANGUAGE`], verbatim. The choice is a table lookup on the language
|
//! module holds what the entity store owns and the lowering path reads: the
|
||||||
//! tag and the form's position, not a reading of the form. Forms after the
|
//! narrow [`EntityForms`] port, the ruled IPA of every entity a packet cites
|
||||||
//! first are inflected or particle-bound shapes; no English frame asks for one,
|
//! ([`pronounce`]), and its segmentation into phones ([`phones`]). A referent
|
||||||
//! so none is selected. An entity the port does not hold, or one with no form in
|
//! that cites an entity is read through the port; an entity the port does not
|
||||||
//! the target language, is refused: the concept word would say a different
|
//! hold, one with no form or no ruled pronunciation in [`TARGET_LANGUAGE`], is
|
||||||
//! thing than the name the packet cites.
|
//! refused, because spelling is not a pronunciation and there is no
|
||||||
//!
|
//! grapheme-to-phoneme guess to offer in its place.
|
||||||
//! 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 std::fmt;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::entity::Entity;
|
use crate::entity::Entity;
|
||||||
use crate::inventory::{Family, InvId, Inventory};
|
use crate::packet::EntityId;
|
||||||
use crate::packet::{EntityId, NodeId, Predication};
|
use crate::phones::{Phone, PhoneInventory, SegmentError};
|
||||||
use crate::store::EntityStore;
|
use crate::store::EntityStore;
|
||||||
use crate::validate::LoweringInput;
|
use crate::validate::LoweringInput;
|
||||||
|
|
||||||
/// The language tag this lowerer's entity forms are read under, spelled as an
|
/// The language tag of [`pronounce`] and [`phones`] only. The golf loop lowers
|
||||||
|
/// each item in its own language and does not read this. Spelled as an
|
||||||
/// entity's `LanguageForms::language` and a corpus item's language are.
|
/// entity's `LanguageForms::language` and a corpus item's language are.
|
||||||
pub const TARGET_LANGUAGE: &str = "en-US";
|
pub const TARGET_LANGUAGE: &str = "en-US";
|
||||||
|
|
||||||
|
|
@ -46,224 +38,141 @@ impl EntityForms for EntityStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `(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 stop {patient}"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum LowerError {
|
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 },
|
|
||||||
/// A referent cites an entity the entity port does not hold.
|
/// A referent cites an entity the entity port does not hold.
|
||||||
UnknownEntity { entity: EntityId },
|
UnknownEntity { entity: EntityId },
|
||||||
/// The entity has no form in [`TARGET_LANGUAGE`].
|
/// The entity has no form in [`TARGET_LANGUAGE`].
|
||||||
NoForm { entity: EntityId },
|
NoForm { entity: EntityId },
|
||||||
|
/// IPA was requested for an entity whose [`TARGET_LANGUAGE`] citation form
|
||||||
|
/// has no ruled pronunciation. Spelling is not a pronunciation.
|
||||||
|
NoPronunciation { entity: EntityId },
|
||||||
|
/// IPA was requested for a packet that cites no entity.
|
||||||
|
NoEntityReferent,
|
||||||
|
/// Phones were requested for an entity whose ruled IPA holds a symbol the
|
||||||
|
/// language's phone inventory does not list (or the language has no
|
||||||
|
/// inventory). No phone is guessed.
|
||||||
|
UnknownPhone {
|
||||||
|
entity: EntityId,
|
||||||
|
at: usize,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// Phones were requested for an entity whose ruled IPA has a stress mark
|
||||||
|
/// with no vowel after it.
|
||||||
|
DanglingStress { entity: EntityId, at: usize },
|
||||||
/// The entity port failed to read.
|
/// The entity port failed to read.
|
||||||
EntityRead { entity: EntityId, why: String },
|
EntityRead { entity: EntityId, why: String },
|
||||||
|
/// A lowering prompt was asked of a view whose scene was never read.
|
||||||
|
SceneUnread,
|
||||||
|
/// A referent cites the entity whose id is the lowering prompt's speaker
|
||||||
|
/// token ([`crate::context::SPEAKER_TOKEN`]), which would read as the speaker.
|
||||||
|
SpeakerTokenTaken,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for LowerError {
|
impl fmt::Display for LowerError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{self:?}")
|
f.write_str(match self {
|
||||||
|
LowerError::UnknownEntity { .. } => "referent cites an entity the port does not hold",
|
||||||
|
LowerError::NoForm { .. } => "entity has no form in the target language",
|
||||||
|
LowerError::NoPronunciation { .. } => "entity has no ruled pronunciation",
|
||||||
|
LowerError::NoEntityReferent => "packet cites no entity",
|
||||||
|
LowerError::UnknownPhone { .. } => {
|
||||||
|
"ruled pronunciation holds a symbol with no listed phone"
|
||||||
|
}
|
||||||
|
LowerError::DanglingStress { .. } => "stress mark with no vowel after it",
|
||||||
|
LowerError::EntityRead { .. } => "entity port failed to read",
|
||||||
|
LowerError::SceneUnread => "the view's scene was never read",
|
||||||
|
LowerError::SpeakerTokenTaken => {
|
||||||
|
"a referent cites the entity id the speaker token uses"
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for LowerError {}
|
impl std::error::Error for LowerError {}
|
||||||
|
|
||||||
fn row(
|
/// The ruled IPA of every entity a packet cites, one per entity referent in
|
||||||
table: &'static [Row],
|
/// the packet's referent order, as stored: bare, stress-marked, nothing
|
||||||
id: InvId,
|
/// derived. A rejected pronunciation is never read. An entity the port does not
|
||||||
inv: &Inventory,
|
/// hold, or one with no ruled pronunciation in [`TARGET_LANGUAGE`], is refused:
|
||||||
rev: u32,
|
/// the lowerer has no grapheme-to-phoneme guess to offer in its place.
|
||||||
) -> Result<&'static str, LowerError> {
|
pub fn pronounce(
|
||||||
let (_, gloss, surface) = table
|
input: &LoweringInput,
|
||||||
.iter()
|
entities: &dyn EntityForms,
|
||||||
.find(|(key, _, _)| *key == id.to_string())
|
) -> Result<Vec<String>, LowerError> {
|
||||||
.ok_or(LowerError::NoRow { id })?;
|
Ok(ruled(input, entities)?
|
||||||
match inv.resolve(&id, rev) {
|
.into_iter()
|
||||||
Some(e) if e.gloss == *gloss => Ok(surface),
|
.map(|(_, ipa)| ipa)
|
||||||
_ => Err(LowerError::GlossMismatch { id, row: gloss }),
|
.collect())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn lower(
|
/// One entity's ruled pronunciation as the phones line a sound consumer reads:
|
||||||
|
/// the language tag, the stored IPA it came from, and the segmented phones.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct PhoneLine {
|
||||||
|
pub language: &'static str,
|
||||||
|
pub source: String,
|
||||||
|
pub phones: Vec<Phone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The phones line of every entity a packet cites, in [`pronounce`] order, each
|
||||||
|
/// segmented by [`TARGET_LANGUAGE`]'s phone inventory. A symbol the inventory
|
||||||
|
/// does not list refuses the whole call.
|
||||||
|
pub fn phones(
|
||||||
input: &LoweringInput,
|
input: &LoweringInput,
|
||||||
inv: &Inventory,
|
|
||||||
entities: &dyn EntityForms,
|
entities: &dyn EntityForms,
|
||||||
) -> Result<String, LowerError> {
|
) -> Result<Vec<PhoneLine>, LowerError> {
|
||||||
let rev = input.inventory_rev();
|
let inventory = PhoneInventory::for_language(TARGET_LANGUAGE);
|
||||||
let unrendered = |what| Err(LowerError::Unrendered { what });
|
ruled(input, entities)?
|
||||||
if input.predications().is_empty() {
|
.into_iter()
|
||||||
return unrendered("no predication");
|
.map(|(entity, source)| {
|
||||||
|
let unknown = |at, text| LowerError::UnknownPhone {
|
||||||
|
entity: entity.clone(),
|
||||||
|
at,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
let inventory = inventory.ok_or_else(|| unknown(0, source.clone()))?;
|
||||||
|
let phones = inventory.segment(&source).map_err(|e| match e {
|
||||||
|
SegmentError::UnknownPhone { at, text } => unknown(at, text),
|
||||||
|
SegmentError::DanglingStress { at } => LowerError::DanglingStress {
|
||||||
|
entity: entity.clone(),
|
||||||
|
at,
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
Ok(PhoneLine {
|
||||||
|
language: TARGET_LANGUAGE,
|
||||||
|
source,
|
||||||
|
phones,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(entity, ruled IPA)` for each entity referent, in referent order.
|
||||||
|
fn ruled(
|
||||||
|
input: &LoweringInput,
|
||||||
|
entities: &dyn EntityForms,
|
||||||
|
) -> Result<Vec<(EntityId, String)>, LowerError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for id in input.referents().iter().filter_map(|r| r.entity.as_ref()) {
|
||||||
|
let entity = entity(entities, id)?;
|
||||||
|
let p = entity
|
||||||
|
.pronunciation_in(TARGET_LANGUAGE)
|
||||||
|
.ok_or_else(|| LowerError::NoPronunciation { entity: id.clone() })?;
|
||||||
|
out.push((id.clone(), p.ipa.clone()));
|
||||||
}
|
}
|
||||||
if !input.discourse().features.is_empty()
|
if out.is_empty() {
|
||||||
|| input.referents().iter().any(|r| !r.features.is_empty())
|
return Err(LowerError::NoEntityReferent);
|
||||||
|| 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, entities, 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, name_first)) 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() {
|
|
||||||
// A name opens its clause as written; only template words are cased.
|
|
||||||
if (i == 0 && !opener.is_empty()) || *name_first {
|
|
||||||
out.push(first);
|
|
||||||
} else {
|
|
||||||
out.extend(first.to_uppercase());
|
|
||||||
}
|
|
||||||
out.push_str(chars.as_str());
|
|
||||||
}
|
|
||||||
out.push('.');
|
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clause(
|
pub(crate) fn entity(entities: &dyn EntityForms, id: &EntityId) -> Result<Entity, LowerError> {
|
||||||
input: &LoweringInput,
|
entities
|
||||||
inv: &Inventory,
|
|
||||||
entities: &dyn EntityForms,
|
|
||||||
p: &Predication,
|
|
||||||
) -> Result<(String, bool), 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 name_first = false;
|
|
||||||
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(),
|
|
||||||
})?;
|
|
||||||
let (phrase, is_name) = filler(input, inv, entities, &fill.filler, &said)?;
|
|
||||||
name_first |= is_name && out.is_empty();
|
|
||||||
out.push_str(&phrase);
|
|
||||||
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, name_first))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A role filler as a phrase: the addressee is "you", a predication is "that",
|
|
||||||
/// another referent is its entity's citation form when it cites one, else "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,
|
|
||||||
entities: &dyn EntityForms,
|
|
||||||
node: &NodeId,
|
|
||||||
said: &[&NodeId],
|
|
||||||
) -> Result<(String, bool), 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(), false));
|
|
||||||
}
|
|
||||||
if let Some(id) = &r.entity {
|
|
||||||
return Ok((name(entities, id)?, true));
|
|
||||||
}
|
|
||||||
let word = row(CONCEPTS, r.concept, inv, input.inventory_rev())?;
|
|
||||||
return Ok((format!("the {word}"), false));
|
|
||||||
}
|
|
||||||
Ok(("that".to_string(), false))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The entity's citation form in [`TARGET_LANGUAGE`].
|
|
||||||
fn name(entities: &dyn EntityForms, id: &EntityId) -> Result<String, LowerError> {
|
|
||||||
let entity = entities
|
|
||||||
.entity(id)
|
.entity(id)
|
||||||
.map_err(|e| LowerError::EntityRead {
|
.map_err(|e| LowerError::EntityRead {
|
||||||
entity: id.clone(),
|
entity: id.clone(),
|
||||||
why: e.to_string(),
|
why: e.to_string(),
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| LowerError::UnknownEntity { entity: id.clone() })?;
|
.ok_or_else(|| LowerError::UnknownEntity { entity: id.clone() })
|
||||||
entity
|
|
||||||
.forms_in(TARGET_LANGUAGE)
|
|
||||||
.and_then(<[String]>::first)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| LowerError::NoForm { entity: id.clone() })
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
130
crates/weksa/src/model.rs
Normal file
130
crates/weksa/src/model.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
//! The loop's only door to a model. A model is the Claude session: it reads a
|
||||||
|
//! prompt file and writes an answer file, and a rerun picks the answer up. No
|
||||||
|
//! subprocess, network or binary lives here (golf-loop-in-claude).
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::context::Prompt;
|
||||||
|
use crate::store::normalized;
|
||||||
|
|
||||||
|
/// A model answering [`Prompt`]s and nothing else.
|
||||||
|
pub trait ModelPort {
|
||||||
|
/// The id of the answering model, pinned by the run.
|
||||||
|
fn model(&self) -> &str;
|
||||||
|
fn answer(&mut self, prompt: &Prompt) -> Result<String, PortError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a prompt got no answer. Display names the kind only.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PortError {
|
||||||
|
/// Nobody has answered yet.
|
||||||
|
Unanswered,
|
||||||
|
/// The prompt holds the text of an item in a Test group. It is neither written nor answered.
|
||||||
|
Withheld,
|
||||||
|
Io(ErrorKind),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PortError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
PortError::Unanswered => f.write_str("the prompt is unanswered"),
|
||||||
|
PortError::Withheld => f.write_str("the prompt holds Test item text"),
|
||||||
|
PortError::Io(kind) => write!(f, "io error {kind:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PortError {}
|
||||||
|
|
||||||
|
/// Answers from files in one directory: `<digest>.answer` is the reply to the
|
||||||
|
/// prompt whose digest is `<digest>`; a prompt with no answer is written to
|
||||||
|
/// `<digest>.prompt` for whoever answers.
|
||||||
|
pub struct AnswerFiles {
|
||||||
|
dir: PathBuf,
|
||||||
|
model: String,
|
||||||
|
withheld: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnswerFiles {
|
||||||
|
/// Refuses (`PortError::Withheld`) any prompt that holds the text of one of
|
||||||
|
/// `withheld`, before it reads or writes a file: the one door a prompt
|
||||||
|
/// leaves the loop through, so a Test sentence cannot reach a model by it.
|
||||||
|
pub fn new(dir: PathBuf, model: String, withheld: Vec<String>) -> Self {
|
||||||
|
Self { dir, model, withheld }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalised length from which a Test text is matched. Every live Test text is
|
||||||
|
/// longer; a shorter one ("Stop.") would match any prompt that says "Stop. Then
|
||||||
|
/// ...", so a Test item that short is not caught here.
|
||||||
|
const LONG: usize = 12;
|
||||||
|
|
||||||
|
/// Whether `prompt` holds `text`, by the corpus door's own sameness
|
||||||
|
/// ([`normalized`]: NFKC, case fold, spacing and punctuation dropped): a
|
||||||
|
/// re-cased, re-spaced, wrapped or width-changed copy still counts.
|
||||||
|
fn holds(prompt: &str, text: &str) -> bool {
|
||||||
|
let key = normalized(text);
|
||||||
|
key.chars().count() >= LONG && normalized(prompt).contains(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answers read from one directory and nothing else: `<digest>.answer` is the
|
||||||
|
/// reply to the prompt whose digest is `<digest>`. A prompt with no file is
|
||||||
|
/// [`PortError::Unanswered`]; this port never creates or writes anything. The
|
||||||
|
/// one reader of answer files: [`AnswerFiles`] reads through it.
|
||||||
|
pub struct RecordedAnswers {
|
||||||
|
dir: PathBuf,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordedAnswers {
|
||||||
|
pub fn new(dir: PathBuf, model: String) -> Self {
|
||||||
|
Self { dir, model }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelPort for RecordedAnswers {
|
||||||
|
fn model(&self) -> &str {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
fn answer(&mut self, prompt: &Prompt) -> Result<String, PortError> {
|
||||||
|
match std::fs::read(self.dir.join(format!("{}.answer", prompt.digest()))) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
String::from_utf8(bytes).map_err(|_| PortError::Io(ErrorKind::InvalidData))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Err(PortError::Unanswered),
|
||||||
|
Err(e) => Err(PortError::Io(e.kind())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelPort for AnswerFiles {
|
||||||
|
fn model(&self) -> &str {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
fn answer(&mut self, prompt: &Prompt) -> Result<String, PortError> {
|
||||||
|
let io = |e: std::io::Error| PortError::Io(e.kind());
|
||||||
|
if self.withheld.iter().any(|t| holds(prompt.text(), t)) {
|
||||||
|
return Err(PortError::Withheld);
|
||||||
|
}
|
||||||
|
let digest = prompt.digest();
|
||||||
|
match RecordedAnswers::new(self.dir.clone(), self.model.clone()).answer(prompt) {
|
||||||
|
Err(PortError::Unanswered) => {}
|
||||||
|
read => return read,
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(&self.dir).map_err(io)?;
|
||||||
|
let path = self.dir.join(format!("{digest}.prompt"));
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(held) if held == prompt.text().as_bytes() => {}
|
||||||
|
Ok(_) => return Err(PortError::Io(ErrorKind::AlreadyExists)),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => {
|
||||||
|
std::fs::write(&path, prompt.text()).map_err(io)?
|
||||||
|
}
|
||||||
|
Err(e) => return Err(io(e)),
|
||||||
|
}
|
||||||
|
Err(PortError::Unanswered)
|
||||||
|
}
|
||||||
|
}
|
||||||
207
crates/weksa/src/ontology.rs
Normal file
207
crates/weksa/src/ontology.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
//! The language ontology: per target language, the choices its culture makes
|
||||||
|
//! about word, grammar, discourse, sound and script for a speaker in a stance
|
||||||
|
//! (ruling `persona-drives-ontology`). An entry says when it applies (feature
|
||||||
|
//! conditions over the lowering view's stance, discourse and scene relation) and what it
|
||||||
|
//! chooses. `select` is the only reader that decides which entries a lowering
|
||||||
|
//! prompt carries, and it runs only inside `Prompt::lower`;
|
||||||
|
//! `OntologyStore::admit` is the only writer. Entries are immutable.
|
||||||
|
|
||||||
|
use cultcache_rs::DatabaseEntry;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::inventory::{InvId, Slot};
|
||||||
|
use crate::packet::{FeatValue, FeatureValue, Grade};
|
||||||
|
use crate::speaker::Annotator;
|
||||||
|
use crate::validate::LoweringInput;
|
||||||
|
|
||||||
|
pub const MAX_CONDITIONS: usize = 4;
|
||||||
|
pub const MAX_EXAMPLES: usize = 3;
|
||||||
|
pub const MAX_CHOICE_BYTES: usize = 200;
|
||||||
|
pub const MAX_EXAMPLE_BYTES: usize = 120;
|
||||||
|
pub const MAX_ENTRIES_PER_LANGUAGE: usize = 64;
|
||||||
|
|
||||||
|
/// Which part of the language an entry shapes.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub enum Layer {
|
||||||
|
Lexicon,
|
||||||
|
Grammar,
|
||||||
|
Discourse,
|
||||||
|
Phonology,
|
||||||
|
Orthography,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layer {
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Layer::Lexicon => "lexicon",
|
||||||
|
Layer::Grammar => "grammar",
|
||||||
|
Layer::Discourse => "discourse",
|
||||||
|
Layer::Phonology => "phonology",
|
||||||
|
Layer::Orthography => "orthography",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a feature's value must be for a condition to hold.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub enum Test {
|
||||||
|
AtLeast(Grade),
|
||||||
|
AtMost(Grade),
|
||||||
|
Is(FeatValue),
|
||||||
|
/// Holds when the feature is absent or a grade at most `g`: a bound that
|
||||||
|
/// costs nothing where the feature is unrated. `AtMost` still fails on an
|
||||||
|
/// absent feature.
|
||||||
|
AtMostOrUnset(Grade),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One feature of one slot of the view, tested. `Stance`, `Discourse` and
|
||||||
|
/// `Scene` reach a lowering view; `Speaker` does not yet.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct Condition {
|
||||||
|
pub slot: Slot,
|
||||||
|
pub feature: InvId,
|
||||||
|
pub test: Test,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Condition {
|
||||||
|
/// Whether this condition holds over a slot's feature values: the one
|
||||||
|
/// evaluator `select` and the corpus tests share.
|
||||||
|
pub fn holds(&self, features: &[FeatureValue]) -> bool {
|
||||||
|
let Some(found) = features.iter().find(|f| f.feature == self.feature) else {
|
||||||
|
return matches!(self.test, Test::AtMostOrUnset(_));
|
||||||
|
};
|
||||||
|
match (&self.test, &found.value) {
|
||||||
|
(Test::AtLeast(g), FeatValue::Grade(v)) => v >= g,
|
||||||
|
(Test::AtMost(g) | Test::AtMostOrUnset(g), FeatValue::Grade(v)) => v <= g,
|
||||||
|
(Test::Is(want), v) => want == v,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feature value this condition names, as the inventory check reads it.
|
||||||
|
pub(crate) fn as_value(&self) -> FeatureValue {
|
||||||
|
FeatureValue {
|
||||||
|
feature: self.feature,
|
||||||
|
value: match &self.test {
|
||||||
|
Test::AtLeast(g) | Test::AtMost(g) | Test::AtMostOrUnset(g) => FeatValue::Grade(*g),
|
||||||
|
Test::Is(v) => v.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One choice of one language. Key `onto:<language>:<n>`.
|
||||||
|
///
|
||||||
|
/// The store judges the form of an entry's text (bounds, normalization, no
|
||||||
|
/// control or format character) and never its meaning: no door judges text
|
||||||
|
/// (ruling `leak-owned-by-flow-and-scorer-3`). No Train-only ontology mint
|
||||||
|
/// exists yet, so a seed or a drafted entry is checked against held-out text by
|
||||||
|
/// nothing today; follow-up `leak-diagnostic-in-round` owns that diagnostic.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
|
||||||
|
#[cultcache(type = "weksa.ontology_entry")]
|
||||||
|
pub struct OntologyEntry {
|
||||||
|
#[cultcache(key = 0)]
|
||||||
|
pub language: String,
|
||||||
|
/// The entry's number within its language, from 1.
|
||||||
|
#[cultcache(key = 1)]
|
||||||
|
pub n: u32,
|
||||||
|
/// The revision, of the inventory the entry was admitted through, at which the
|
||||||
|
/// store read its conditions: provenance only; a run reads the conditions'
|
||||||
|
/// features at its own head.
|
||||||
|
#[cultcache(key = 2)]
|
||||||
|
pub inventory_rev: u32,
|
||||||
|
#[cultcache(key = 3)]
|
||||||
|
pub layer: Layer,
|
||||||
|
/// Every condition must hold; none means the entry always applies.
|
||||||
|
#[cultcache(key = 4)]
|
||||||
|
pub when: Vec<Condition>,
|
||||||
|
#[cultcache(key = 5)]
|
||||||
|
pub choice: String,
|
||||||
|
#[cultcache(key = 6)]
|
||||||
|
pub examples: Vec<String>,
|
||||||
|
#[cultcache(key = 7)]
|
||||||
|
pub annotator: Annotator,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `onto:<language>:<n>`.
|
||||||
|
pub fn ontology_key(language: &str, n: u32) -> String {
|
||||||
|
format!("onto:{language}:{n}")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OntologyEntry {
|
||||||
|
pub fn key(&self) -> String {
|
||||||
|
ontology_key(&self.language, self.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every entry of the run's ontology store, by language then number.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Ontology {
|
||||||
|
entries: Vec<OntologyEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ontology {
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn of(mut entries: Vec<OntologyEntry>) -> Self {
|
||||||
|
entries.sort_by(|a, b| (&a.language, a.n).cmp(&(&b.language, b.n)));
|
||||||
|
Self { entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries(&self) -> &[OntologyEntry] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entries of exactly `language` whose every condition holds against the
|
||||||
|
/// view's stance, discourse and scene-relation features (the scene's only when
|
||||||
|
/// `with_scene` selected a relation), in `n` order. An absent feature meets no
|
||||||
|
/// condition except `AtMostOrUnset`. Nothing else of the view is read.
|
||||||
|
pub fn select<'a>(
|
||||||
|
o: &'a Ontology,
|
||||||
|
language: &str,
|
||||||
|
input: &LoweringInput,
|
||||||
|
) -> Vec<&'a OntologyEntry> {
|
||||||
|
o.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.language == language)
|
||||||
|
.filter(|e| {
|
||||||
|
e.when.iter().all(|c| match c.slot {
|
||||||
|
Slot::Stance => c.holds(&input.stance().features),
|
||||||
|
Slot::Discourse => c.holds(&input.discourse().features),
|
||||||
|
Slot::Scene => c.holds(input.scene()),
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn cond(test: Test) -> Condition {
|
||||||
|
Condition { slot: Slot::Discourse, feature: InvId::parse("feature:7").unwrap(), test }
|
||||||
|
}
|
||||||
|
fn has(v: FeatValue) -> Vec<FeatureValue> {
|
||||||
|
vec![FeatureValue { feature: InvId::parse("feature:7").unwrap(), value: v }]
|
||||||
|
}
|
||||||
|
fn grade(g: u16) -> Grade {
|
||||||
|
Grade::new(g).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn at_most_or_unset_holds_when_absent_or_at_most_and_nothing_else() {
|
||||||
|
let c = cond(Test::AtMostOrUnset(grade(799)));
|
||||||
|
assert!(c.holds(&[]));
|
||||||
|
assert!(c.holds(&has(FeatValue::Grade(grade(799)))));
|
||||||
|
assert!(c.holds(&has(FeatValue::Grade(grade(0)))));
|
||||||
|
assert!(!c.holds(&has(FeatValue::Grade(grade(800)))));
|
||||||
|
assert!(!c.holds(&has(FeatValue::Flag(true))));
|
||||||
|
// AtMost keeps its meaning: an absent feature fails it.
|
||||||
|
assert!(!cond(Test::AtMost(grade(799))).holds(&[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ pub use crate::inventory::InvId;
|
||||||
use crate::inventory::OptionLabel;
|
use crate::inventory::OptionLabel;
|
||||||
|
|
||||||
/// Bumped by a commit that changes these types; the published schema moves with it.
|
/// Bumped by a commit that changes these types; the published schema moves with it.
|
||||||
pub const SCHEMA_VERSION: u32 = 4;
|
pub const SCHEMA_VERSION: u32 = 5;
|
||||||
|
|
||||||
/// Id of a referent or predication within one packet: `n<k>`, `k` a canonical
|
/// Id of a referent or predication within one packet: `n<k>`, `k` a canonical
|
||||||
/// decimal 1..=9999.
|
/// decimal 1..=9999.
|
||||||
|
|
@ -261,6 +261,13 @@ pub struct Discourse {
|
||||||
/// Speech-act id.
|
/// Speech-act id.
|
||||||
pub speech_act: Option<InvId>,
|
pub speech_act: Option<InvId>,
|
||||||
pub addressee: Option<NodeId>,
|
pub addressee: Option<NodeId>,
|
||||||
|
/// The predication the addressee asked for or agreed to. A fact about what
|
||||||
|
/// the addressee did, never about how the line sounds, so it is no feature:
|
||||||
|
/// only a source that saw the request may set it (V15), it needs an
|
||||||
|
/// addressee (V14) and names a predication (V14), and a lowering that
|
||||||
|
/// speaks for the addressee's request ("you asked her to") reads it here
|
||||||
|
/// and refuses without it.
|
||||||
|
pub addressee_consent: Option<NodeId>,
|
||||||
pub features: Vec<FeatureValue>,
|
pub features: Vec<FeatureValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,6 +392,7 @@ pub fn proof_packet() -> Packet {
|
||||||
discourse: Discourse {
|
discourse: Discourse {
|
||||||
speech_act: Some(id("speech_act:5")),
|
speech_act: Some(id("speech_act:5")),
|
||||||
addressee: Some(node("n1")),
|
addressee: Some(node("n1")),
|
||||||
|
addressee_consent: None,
|
||||||
features: vec![],
|
features: vec![],
|
||||||
},
|
},
|
||||||
referents: vec![
|
referents: vec![
|
||||||
|
|
|
||||||
152
crates/weksa/src/phones.rs
Normal file
152
crates/weksa/src/phones.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
//! The phone inventory and the segmenter: a ruled IPA string to an ordered list of
|
||||||
|
//! phones with stress, for the consumer that turns phones into sound. The
|
||||||
|
//! inventory is data (`data/phonology/<language>.phones.tsv`); this module reads
|
||||||
|
//! it and owns one rule, that every symbol of the string is a phone the inventory
|
||||||
|
//! lists. A symbol it does not list is refused: no nearest phone, no spelling
|
||||||
|
//! rule, no guess. Tense/lax is a column of the inventory, so /i/ and /ɪ/ are two
|
||||||
|
//! phones here, not one phone and a flag on an entity.
|
||||||
|
//!
|
||||||
|
//! Stress marks are not phones. `ˈ` (primary) and `ˌ` (secondary) put their
|
||||||
|
//! stress on the next vowel phone, the syllable's nucleus; every other phone is
|
||||||
|
//! `none`. A mark with no vowel after it is refused. Durations, pitch and
|
||||||
|
//! acoustics are the consumer's, never read or written here.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
const EN_US: &str = include_str!("../../../data/phonology/en-US.phones.tsv");
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Stress {
|
||||||
|
None,
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One phone of a segmented string: its IPA symbol as the inventory spells it
|
||||||
|
/// and its stress.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct Phone {
|
||||||
|
pub ipa: String,
|
||||||
|
pub stress: Stress,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Tenseness {
|
||||||
|
Tense,
|
||||||
|
Lax,
|
||||||
|
Reduced,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Class {
|
||||||
|
Consonant,
|
||||||
|
Vowel(Tenseness),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SegmentError {
|
||||||
|
/// The symbol (or the text from it on) matches no inventory phone.
|
||||||
|
UnknownPhone { at: usize, text: String },
|
||||||
|
/// A stress mark (`at` is its byte offset) that finds no vowel before the
|
||||||
|
/// string ends or another mark comes.
|
||||||
|
DanglingStress { at: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PhoneInventory {
|
||||||
|
phones: BTreeMap<String, Class>,
|
||||||
|
longest: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhoneInventory {
|
||||||
|
/// The inventory for a language tag, if one is committed.
|
||||||
|
pub fn for_language(language: &str) -> Option<&'static PhoneInventory> {
|
||||||
|
static EN_US_INV: OnceLock<PhoneInventory> = OnceLock::new();
|
||||||
|
match language {
|
||||||
|
"en-US" => Some(EN_US_INV.get_or_init(|| {
|
||||||
|
PhoneInventory::parse(EN_US).expect("the committed en-US phone inventory parses")
|
||||||
|
})),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the tab-separated inventory. Malformed lines are errors, as are
|
||||||
|
/// repeated symbols.
|
||||||
|
pub fn parse(text: &str) -> Result<Self, String> {
|
||||||
|
let mut phones = BTreeMap::new();
|
||||||
|
for (n, line) in text.lines().enumerate() {
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cols: Vec<&str> = line.split('\t').collect();
|
||||||
|
let [ipa, class, tenseness, gloss] = cols[..] else {
|
||||||
|
return Err(format!("line {}: want 4 tab-separated columns", n + 1));
|
||||||
|
};
|
||||||
|
let class = match (class, tenseness) {
|
||||||
|
("consonant", "-") => Class::Consonant,
|
||||||
|
("vowel", "tense") => Class::Vowel(Tenseness::Tense),
|
||||||
|
("vowel", "lax") => Class::Vowel(Tenseness::Lax),
|
||||||
|
("vowel", "reduced") => Class::Vowel(Tenseness::Reduced),
|
||||||
|
_ => return Err(format!("line {}: bad class/tenseness {class}/{tenseness}", n + 1)),
|
||||||
|
};
|
||||||
|
if ipa.is_empty() || gloss.is_empty() || ipa.chars().any(|c| matches!(c, 'ˈ' | 'ˌ')) {
|
||||||
|
return Err(format!("line {}: empty or stress-marked entry", n + 1));
|
||||||
|
}
|
||||||
|
if phones.insert(ipa.to_string(), class).is_some() {
|
||||||
|
return Err(format!("line {}: {ipa} repeats", n + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let longest = phones.keys().map(|k| k.chars().count()).max().unwrap_or(0);
|
||||||
|
Ok(Self { phones, longest })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn class(&self, ipa: &str) -> Option<&Class> {
|
||||||
|
self.phones.get(ipa)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segment `ipa` into phones: longest inventory match at each position.
|
||||||
|
pub fn segment(&self, ipa: &str) -> Result<Vec<Phone>, SegmentError> {
|
||||||
|
let chars: Vec<(usize, char)> = ipa.char_indices().collect();
|
||||||
|
let mut out: Vec<Phone> = Vec::new();
|
||||||
|
let mut pending: Option<(Stress, usize)> = None;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let at = chars[i].0;
|
||||||
|
let mark = match chars[i].1 {
|
||||||
|
'ˈ' => Some(Stress::Primary),
|
||||||
|
'ˌ' => Some(Stress::Secondary),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(m) = mark {
|
||||||
|
if let Some((_, was)) = pending {
|
||||||
|
return Err(SegmentError::DanglingStress { at: was });
|
||||||
|
}
|
||||||
|
pending = Some((m, at));
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let hit = (1..=self.longest.min(chars.len() - i)).rev().find_map(|len| {
|
||||||
|
let end = chars.get(i + len).map_or(ipa.len(), |c| c.0);
|
||||||
|
let s = &ipa[at..end];
|
||||||
|
self.phones.get(s).map(|c| (len, s, c))
|
||||||
|
});
|
||||||
|
let Some((len, s, class)) = hit else {
|
||||||
|
return Err(SegmentError::UnknownPhone { at, text: ipa[at..].to_string() });
|
||||||
|
};
|
||||||
|
let stress = if matches!(class, Class::Vowel(_)) {
|
||||||
|
pending.take().map_or(Stress::None, |(m, _)| m)
|
||||||
|
} else {
|
||||||
|
Stress::None
|
||||||
|
};
|
||||||
|
out.push(Phone { ipa: s.to_string(), stress });
|
||||||
|
i += len;
|
||||||
|
}
|
||||||
|
if let Some((_, at)) = pending {
|
||||||
|
return Err(SegmentError::DanglingStress { at });
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
4112
crates/weksa/src/run.rs
Normal file
4112
crates/weksa/src/run.rs
Normal file
File diff suppressed because it is too large
Load diff
21
crates/weksa/src/run/mutants.rs
Normal file
21
crates/weksa/src/run/mutants.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
//! Pins for private functions of `run.rs` that the doors reach only through a
|
||||||
|
//! whole run.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A line is probed only when it has at least four words, and then it is
|
||||||
|
/// split in two near its byte middle, the pieces trimmed.
|
||||||
|
#[test]
|
||||||
|
fn a_recall_line_splits_from_four_words_and_not_before() {
|
||||||
|
assert_eq!(recall_split("one two three"), None);
|
||||||
|
assert_eq!(recall_split(""), None);
|
||||||
|
assert_eq!(
|
||||||
|
recall_split("one two three four"),
|
||||||
|
Some(("one two".to_string(), "three four".to_string()))
|
||||||
|
);
|
||||||
|
// No spaces: each ideograph is a word, the split falls at the middle.
|
||||||
|
assert_eq!(
|
||||||
|
recall_split("天地不仁以万物为刍狗"),
|
||||||
|
Some(("天地不仁以".to_string(), "万物为刍狗".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
use crate::corpus::CorpusItem;
|
use crate::corpus::CorpusItem;
|
||||||
use crate::entity::Entity;
|
use crate::entity::Entity;
|
||||||
use crate::inventory::{InventoryEntry, InventoryRevision};
|
use crate::inventory::{InventoryEntry, InventoryRevision};
|
||||||
|
use crate::ontology::OntologyEntry;
|
||||||
use crate::packet::Packet;
|
use crate::packet::Packet;
|
||||||
|
use crate::run::{Decision, RoundRecord, RunRecord};
|
||||||
|
use crate::speaker::{Scene, Speaker};
|
||||||
use cultcache_rs::DatabaseEntry;
|
use cultcache_rs::DatabaseEntry;
|
||||||
use schemars::{JsonSchema, schema_for};
|
use schemars::{JsonSchema, schema_for};
|
||||||
|
|
||||||
|
|
@ -21,5 +24,11 @@ pub fn published() -> Vec<(String, String)> {
|
||||||
published_for::<InventoryRevision>(),
|
published_for::<InventoryRevision>(),
|
||||||
published_for::<CorpusItem>(),
|
published_for::<CorpusItem>(),
|
||||||
published_for::<Entity>(),
|
published_for::<Entity>(),
|
||||||
|
published_for::<Speaker>(),
|
||||||
|
published_for::<Scene>(),
|
||||||
|
published_for::<OntologyEntry>(),
|
||||||
|
published_for::<RunRecord>(),
|
||||||
|
published_for::<RoundRecord>(),
|
||||||
|
published_for::<Decision>(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,6 @@ pub struct SplitLeak {
|
||||||
pub worst: Option<WorstItem>,
|
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
|
/// The distinct word n-grams of a text for each order 1..=ORDERS, over its
|
||||||
/// normalised word segments.
|
/// normalised word segments.
|
||||||
fn grams(text: &str) -> Vec<HashSet<String>> {
|
fn grams(text: &str) -> Vec<HashSet<String>> {
|
||||||
|
|
@ -145,25 +139,29 @@ fn split_leak(
|
||||||
leak
|
leak
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads minted text (inventory glosses, option labels, later ontology text)
|
/// The held-out split `want` read against minted text: Train items are the
|
||||||
/// against the held-out items. Per held-out item and for n in 1..=3, its word
|
/// live Train items; the readings are over the live items of `want`.
|
||||||
/// n-grams that occur in no Train item are its novel n-grams; coverage at each
|
pub(super) fn held_out(minted: &[&str], items: &[CorpusItem], want: Split) -> SplitLeak {
|
||||||
/// 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> {
|
let of = |want: Split| -> Vec<&CorpusItem> {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|i| SplitScheme::V1.split(i) == Some(want))
|
.filter(|i| SplitScheme::V1.live(i) == Some(want))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let train = union(of(Split::Train).iter().map(|i| i.text.as_str()));
|
let train = union(of(Split::Train).iter().map(|i| i.text.as_str()));
|
||||||
let minted_grams = union(minted.iter().copied());
|
let minted_grams = union(minted.iter().copied());
|
||||||
LeakDiagnostic {
|
split_leak(minted, &train, &minted_grams, &of(want))
|
||||||
validation: split_leak(minted, &train, &minted_grams, &of(Split::Validation)),
|
}
|
||||||
test: split_leak(minted, &train, &minted_grams, &of(Split::Test)),
|
|
||||||
}
|
/// Reads minted text (inventory glosses, option labels, later ontology text)
|
||||||
|
/// against the Validation items. Per Validation 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; withdrawn
|
||||||
|
/// items and items in no split are dropped. Test is read only through
|
||||||
|
/// `TestReadings`.
|
||||||
|
pub fn leak_diagnostic(minted: &[&str], items: &[CorpusItem]) -> SplitLeak {
|
||||||
|
held_out(minted, items, Split::Validation)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
//! output against the whole reference. Nothing is deleted, hidden or replaced
|
//! 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
|
//! 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
|
//! text the output carries can raise a score except by resembling the
|
||||||
//! reference. No door judges text; the diagnostics report.
|
//! reference. No door judges text; the diagnostics report. The round path
|
||||||
|
//! (`aggregate`, `leak_diagnostic`) reads Train and Validation; Test is read
|
||||||
|
//! only through `TestReadings::at_checkpoint` (convergence-test).
|
||||||
|
|
||||||
mod chrf;
|
mod chrf;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
|
|
@ -10,15 +12,15 @@ mod embed;
|
||||||
mod words;
|
mod words;
|
||||||
|
|
||||||
use anyhow::{Result, ensure};
|
use anyhow::{Result, ensure};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub use chrf::{CHRF_VERSION, chrf};
|
pub use chrf::{CHRF_VERSION, chrf};
|
||||||
pub use diagnostics::{
|
pub use diagnostics::{OrderCoverage, PairChrf, SplitLeak, WorstItem, leak_diagnostic};
|
||||||
LeakDiagnostic, OrderCoverage, PairChrf, SplitLeak, WorstItem, leak_diagnostic,
|
|
||||||
};
|
|
||||||
pub use embed::{Embedder, OllamaEmbedder, cosine};
|
pub use embed::{Embedder, OllamaEmbedder, cosine};
|
||||||
|
|
||||||
use crate::corpus::CorpusItem;
|
use crate::corpus::CorpusItem;
|
||||||
use crate::split::{Split, SplitScheme};
|
use crate::split::{Checkpoint, Split, SplitScheme};
|
||||||
|
|
||||||
/// One reference/output pair; both are scored whole.
|
/// One reference/output pair; both are scored whole.
|
||||||
pub struct ScoreInput<'a> {
|
pub struct ScoreInput<'a> {
|
||||||
|
|
@ -26,7 +28,7 @@ pub struct ScoreInput<'a> {
|
||||||
pub output: &'a str,
|
pub output: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct ItemScore {
|
pub struct ItemScore {
|
||||||
/// chrF of the output against the reference.
|
/// chrF of the output against the reference.
|
||||||
pub chrf: f64,
|
pub chrf: f64,
|
||||||
|
|
@ -102,7 +104,7 @@ impl<E: Embedder> Scorer<E> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mean scores of one derived split.
|
/// Mean scores of one derived split.
|
||||||
#[derive(Clone, Debug, PartialEq, Default)]
|
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct SplitMean {
|
pub struct SplitMean {
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
/// `None` when the split has no items.
|
/// `None` when the split has no items.
|
||||||
|
|
@ -111,34 +113,251 @@ pub struct SplitMean {
|
||||||
pub meaning: Option<f64>,
|
pub meaning: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The round readings a round record may hold. The scorer decides when Test
|
||||||
|
/// can be read, by type: `Aggregate` has no Test field.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// let agg = weksa::score::aggregate(&[]);
|
||||||
|
/// let _ = agg.validation;
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// let agg = weksa::score::aggregate(&[]);
|
||||||
|
/// let _ = agg.test;
|
||||||
|
/// ```
|
||||||
#[derive(Clone, Debug, PartialEq, Default)]
|
#[derive(Clone, Debug, PartialEq, Default)]
|
||||||
pub struct Aggregate {
|
pub struct Aggregate {
|
||||||
pub train: SplitMean,
|
pub train: SplitMean,
|
||||||
pub validation: SplitMean,
|
pub validation: SplitMean,
|
||||||
pub test: SplitMean,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Groups scores by the item's derived split. Items in no split are counted
|
/// The scores of the items read in split `want`, in list order.
|
||||||
/// nowhere. The scorer only groups: the loop decides when Test is read.
|
fn scores_in<'a>(scored: &'a [(&CorpusItem, ItemScore)], want: Split) -> Vec<&'a ItemScore> {
|
||||||
pub fn aggregate(scored: &[(&CorpusItem, ItemScore)]) -> Aggregate {
|
scored
|
||||||
let mean = |want: Split| {
|
.iter()
|
||||||
let of: Vec<&ItemScore> = scored
|
.filter(|(item, _)| SplitScheme::V1.live(item) == Some(want))
|
||||||
.iter()
|
.map(|(_, score)| score)
|
||||||
.filter(|(item, _)| SplitScheme::V1.split(item) == Some(want))
|
.collect()
|
||||||
.map(|(_, score)| score)
|
}
|
||||||
.collect();
|
|
||||||
let average = |values: Vec<f64>| {
|
/// The mean of the scores of the items read in split `want`.
|
||||||
(!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
|
fn split_mean(scored: &[(&CorpusItem, ItemScore)], want: Split) -> SplitMean {
|
||||||
};
|
let of = scores_in(scored, want);
|
||||||
SplitMean {
|
let average = |values: Vec<f64>| {
|
||||||
count: of.len(),
|
(!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
|
||||||
chrf: average(of.iter().map(|s| s.chrf).collect()),
|
|
||||||
meaning: average(of.iter().filter_map(|s| s.meaning).collect()),
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
Aggregate {
|
SplitMean {
|
||||||
train: mean(Split::Train),
|
count: of.len(),
|
||||||
validation: mean(Split::Validation),
|
chrf: average(of.iter().map(|s| s.chrf).collect()),
|
||||||
test: mean(Split::Test),
|
meaning: average(of.iter().filter_map(|s| s.meaning).collect()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Groups scores by the split each item is read in. Items read in no split
|
||||||
|
/// (withdrawn, or model-written in a Test group) are counted nowhere.
|
||||||
|
pub fn aggregate(scored: &[(&CorpusItem, ItemScore)]) -> Aggregate {
|
||||||
|
Aggregate {
|
||||||
|
train: split_mean(scored, Split::Train),
|
||||||
|
validation: split_mean(scored, Split::Validation),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Validation items' scores in list order, numbers only: no key, no text.
|
||||||
|
/// A refused item arrives scored 0 and keeps its position.
|
||||||
|
pub fn validation_items(scored: &[(&CorpusItem, ItemScore)]) -> Vec<ItemScore> {
|
||||||
|
scores_in(scored, Split::Validation).into_iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Test split's readings, scalars only: no item key, no minted string, no
|
||||||
|
/// pair. The checkpoint decision (convergence-test) needs numbers; a key beside
|
||||||
|
/// a minted string is the handle an edit step would act on. The only producer
|
||||||
|
/// is `at_checkpoint`, which consumes a `Checkpoint`; only the lib can make one.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use weksa::score::TestReadings;
|
||||||
|
/// fn f(t: TestReadings) -> usize { t.count() }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// use weksa::score::TestReadings;
|
||||||
|
/// fn f(t: TestReadings) { let _u = t.clone(); }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// use weksa::score::TestReadings;
|
||||||
|
/// fn f(t: TestReadings) { let _ = format!("{t:?}"); }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// use weksa::score::TestReadings;
|
||||||
|
/// fn s<T: serde::Serialize>(_: &T) {}
|
||||||
|
/// fn f(t: TestReadings) { s(&t); }
|
||||||
|
/// ```
|
||||||
|
pub struct TestReadings {
|
||||||
|
mean: SplitMean,
|
||||||
|
leak: SplitLeak,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestReadings {
|
||||||
|
/// Reads the live Test items against the scores and the minted strings,
|
||||||
|
/// by the same `split_leak` method the Validation reading uses. The
|
||||||
|
/// checkpoint is spent.
|
||||||
|
pub fn at_checkpoint(
|
||||||
|
checkpoint: Checkpoint,
|
||||||
|
scored: &[(&CorpusItem, ItemScore)],
|
||||||
|
minted: &[&str],
|
||||||
|
items: &[CorpusItem],
|
||||||
|
) -> TestReadings {
|
||||||
|
let _spent = checkpoint;
|
||||||
|
TestReadings {
|
||||||
|
mean: split_mean(scored, Split::Test),
|
||||||
|
leak: diagnostics::held_out(minted, items, Split::Test),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scored Test items.
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.mean.count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chrf(&self) -> Option<f64> {
|
||||||
|
self.mean.chrf
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn meaning(&self) -> Option<f64> {
|
||||||
|
self.mean.meaning
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test items in the leak reading.
|
||||||
|
pub fn items(&self) -> usize {
|
||||||
|
self.leak.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highest chrF of any minted string against any Test item; the score
|
||||||
|
/// only, never the pair.
|
||||||
|
pub fn max_chrf(&self) -> Option<f64> {
|
||||||
|
self.leak.max_chrf.as_ref().map(|p| p.chrf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coverage_by_n(&self) -> &[OrderCoverage] {
|
||||||
|
&self.leak.coverage_by_n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::split::fixture::{group_in, item};
|
||||||
|
|
||||||
|
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 score(chrf: f64, meaning: Option<f64>) -> ItemScore {
|
||||||
|
ItemScore { chrf, meaning }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_split(key: &str, text: &str, split: Split, model: bool) -> CorpusItem {
|
||||||
|
item(key, text, &group_in(split), model)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_items_keeps_validation_only_in_order() {
|
||||||
|
let train = in_split("item:t:1", UNRELATED, Split::Train, false);
|
||||||
|
let first = in_split("item:v:1", "first words", Split::Validation, false);
|
||||||
|
let refused = in_split("item:v:2", "second words", Split::Validation, false);
|
||||||
|
let last = in_split("item:v:3", "third words", Split::Validation, false);
|
||||||
|
let mut gone = in_split("item:v:4", "withdrawn words", Split::Validation, false);
|
||||||
|
gone.withdrawn = true;
|
||||||
|
let test = in_split("item:c:1", HELD_OUT, Split::Test, false);
|
||||||
|
let scored = [
|
||||||
|
(&train, score(10.0, Some(0.1))),
|
||||||
|
(&first, score(40.0, Some(0.4))),
|
||||||
|
(&test, score(60.0, Some(0.6))),
|
||||||
|
(&refused, score(0.0, Some(0.0))),
|
||||||
|
(&gone, score(90.0, Some(0.9))),
|
||||||
|
(&last, score(80.0, None)),
|
||||||
|
];
|
||||||
|
let got = validation_items(&scored);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
[score(40.0, Some(0.4)), score(0.0, Some(0.0)), score(80.0, None)]
|
||||||
|
);
|
||||||
|
assert_eq!(got.len(), aggregate(&scored).validation.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_readings_at_checkpoint() {
|
||||||
|
let train_item = in_split("item:t:1", UNRELATED, Split::Train, false);
|
||||||
|
let test = in_split("item:c:1", HELD_OUT, Split::Test, false);
|
||||||
|
let test_model = in_split("item:c:2", "model words here", Split::Test, true);
|
||||||
|
let mut gone = in_split("item:c:3", "withdrawn words", Split::Test, false);
|
||||||
|
gone.withdrawn = true;
|
||||||
|
let val = in_split(
|
||||||
|
"item:v:1",
|
||||||
|
"gardening is quiet work",
|
||||||
|
Split::Validation,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let items = [
|
||||||
|
train_item.clone(),
|
||||||
|
test.clone(),
|
||||||
|
test_model.clone(),
|
||||||
|
gone.clone(),
|
||||||
|
val.clone(),
|
||||||
|
];
|
||||||
|
let scored = [
|
||||||
|
(&train_item, score(10.0, Some(0.5))),
|
||||||
|
(&val, score(40.0, Some(0.8))),
|
||||||
|
(&test, score(60.0, Some(0.6))),
|
||||||
|
(&test_model, score(100.0, Some(1.0))),
|
||||||
|
(&gone, score(90.0, Some(0.9))),
|
||||||
|
];
|
||||||
|
let t = TestReadings::at_checkpoint(Checkpoint::for_test(2), &scored, &[HELD_OUT], &items);
|
||||||
|
assert_eq!(
|
||||||
|
(t.count(), t.chrf(), t.meaning()),
|
||||||
|
(1, Some(60.0), Some(0.6))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
t.items(),
|
||||||
|
1,
|
||||||
|
"model-written and withdrawn Test items are excluded"
|
||||||
|
);
|
||||||
|
assert!(t.max_chrf().unwrap() > 99.0);
|
||||||
|
assert_eq!(t.coverage_by_n()[2].share, Some(1.0));
|
||||||
|
|
||||||
|
// A minted string that is not the Test text reads low.
|
||||||
|
let t = TestReadings::at_checkpoint(Checkpoint::for_test(2), &scored, &["zzz"], &items);
|
||||||
|
assert!(t.max_chrf().unwrap() < 20.0);
|
||||||
|
assert_eq!(t.coverage_by_n()[2].share, Some(0.0));
|
||||||
|
|
||||||
|
let empty = TestReadings::at_checkpoint(Checkpoint::for_test(2), &[], &[HELD_OUT], &[]);
|
||||||
|
assert_eq!(
|
||||||
|
(empty.count(), empty.chrf(), empty.meaning()),
|
||||||
|
(0, None, None)
|
||||||
|
);
|
||||||
|
assert_eq!((empty.items(), empty.max_chrf()), (0, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_and_test_leak_agree_in_method() {
|
||||||
|
// The same text and minted strings, once in Validation and once in
|
||||||
|
// Test, read the same numbers.
|
||||||
|
let words: Vec<&str> = HELD_OUT.split(' ').collect();
|
||||||
|
let chunks: Vec<String> = words.chunks(3).map(|c| c.join(" ")).collect();
|
||||||
|
let minted: Vec<&str> = chunks.iter().map(String::as_str).collect();
|
||||||
|
let train_item = in_split("item:t:1", UNRELATED, Split::Train, false);
|
||||||
|
let v = leak_diagnostic(
|
||||||
|
&minted,
|
||||||
|
&[
|
||||||
|
in_split("item:v:1", HELD_OUT, Split::Validation, false),
|
||||||
|
train_item.clone(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let test = in_split("item:c:1", HELD_OUT, Split::Test, false);
|
||||||
|
let t =
|
||||||
|
TestReadings::at_checkpoint(Checkpoint::for_test(1), &[], &minted, &[test, train_item]);
|
||||||
|
assert_eq!(t.items(), v.items);
|
||||||
|
assert_eq!(t.max_chrf(), v.max_chrf.as_ref().map(|p| p.chrf));
|
||||||
|
assert_eq!(t.coverage_by_n(), v.coverage_by_n.as_slice());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,96 @@
|
||||||
//! The one way the scorer reads words: UAX #29 word segmentation, then NFC and
|
//! The one way the scorer reads words, then NFC and full Unicode case folding.
|
||||||
//! full Unicode case folding. The novel-coverage diagnostic reads text through
|
//! The novel-coverage diagnostic reads text through here, so every script is
|
||||||
//! here, so every script is treated by one rule.
|
//! treated by one rule.
|
||||||
|
//!
|
||||||
|
//! A word is a run of word characters: alphanumerics, combining marks and the
|
||||||
|
//! joiners ZWNJ and ZWJ. Any other character ends it, so `kick:the:bucket`,
|
||||||
|
//! `kick·the·bucket`, `kick_the_bucket` and `kick'the'bucket` read as
|
||||||
|
//! `kick the bucket`, as does `kickTheBucket` (a lowercase-to-uppercase
|
||||||
|
//! transition also ends a word).
|
||||||
|
//!
|
||||||
|
//! Han, kana, Thai, Lao, Khmer and Burmese write no word boundaries, and the
|
||||||
|
//! scorer holds no dictionary to find them. A run of those characters is one
|
||||||
|
//! word, never one word per character: per-character words would make every
|
||||||
|
//! honest single-word gloss share bigrams with a held-out sentence, which is
|
||||||
|
//! the leak signature. A change between such a run and any other script also
|
||||||
|
//! ends a word. The n >= 2 coverage signature is therefore blind on these
|
||||||
|
//! scripts: a leak there shows at n = 1 and in max pairwise chrF, which is
|
||||||
|
//! character n-grams and script-blind. A dictionary segmenter is follow-up
|
||||||
|
//! `ja-word-segmenter`.
|
||||||
|
|
||||||
use caseless::default_case_fold_str;
|
use caseless::default_case_fold_str;
|
||||||
use unicode_normalization::UnicodeNormalization;
|
use unicode_normalization::UnicodeNormalization;
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_normalization::char::is_combining_mark;
|
||||||
|
|
||||||
/// NFC, then full default case folding.
|
/// NFC, then full default case folding.
|
||||||
pub(super) fn normalise(s: &str) -> String {
|
pub(super) fn normalise(s: &str) -> String {
|
||||||
default_case_fold_str(&s.nfc().collect::<String>())
|
default_case_fold_str(&s.nfc().collect::<String>())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pieces of one segment: split at `_` and `.` (which are dropped) and
|
fn is_joiner(c: char) -> bool {
|
||||||
/// at every lowercase-to-uppercase transition, so `kick_the_bucket`,
|
matches!(c, '\u{200c}' | '\u{200d}')
|
||||||
/// `kick.the.bucket` and `kickTheBucket` read as `kick the bucket`.
|
}
|
||||||
fn pieces(segment: &str) -> Vec<&str> {
|
|
||||||
|
/// A character that belongs inside a word. Marks must stay: Thai vowels and
|
||||||
|
/// tone marks, Hindi vowel signs and Hangul jamo are not alphanumeric.
|
||||||
|
fn is_word_char(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || is_combining_mark(c) || is_joiner(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Han, kana, Thai, Lao, Khmer and Burmese: scripts written without spaces.
|
||||||
|
fn is_unspaced(c: char) -> bool {
|
||||||
|
matches!(
|
||||||
|
c,
|
||||||
|
'\u{3005}'
|
||||||
|
| '\u{3007}'
|
||||||
|
| '\u{3021}'..='\u{3029}'
|
||||||
|
| '\u{3038}'..='\u{303b}'
|
||||||
|
| '\u{3041}'..='\u{30ff}'
|
||||||
|
| '\u{3400}'..='\u{4dbf}'
|
||||||
|
| '\u{4e00}'..='\u{9fff}'
|
||||||
|
| '\u{f900}'..='\u{fad9}'
|
||||||
|
| '\u{ff66}'..='\u{ff9f}'
|
||||||
|
| '\u{31f0}'..='\u{31ff}'
|
||||||
|
| '\u{20000}'..='\u{323af}'
|
||||||
|
| '\u{0e01}'..='\u{0e5b}'
|
||||||
|
| '\u{0e81}'..='\u{0edf}'
|
||||||
|
| '\u{1000}'..='\u{109f}'
|
||||||
|
| '\u{1780}'..='\u{17ff}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The normalised words of `text` in order; runs with no alphanumeric char
|
||||||
|
/// (marks alone, say) are dropped.
|
||||||
|
pub(super) fn words(text: &str) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut start = 0;
|
let mut start = 0;
|
||||||
let mut previous: Option<char> = None;
|
let mut previous: Option<char> = None;
|
||||||
for (at, c) in segment.char_indices() {
|
let end = |from: usize, to: usize, out: &mut Vec<String>| {
|
||||||
let separator = c == '_' || c == '.';
|
let word = &text[from..to];
|
||||||
let camel = previous.is_some_and(char::is_lowercase) && c.is_uppercase();
|
if word.chars().any(char::is_alphanumeric) {
|
||||||
if separator || camel {
|
out.push(normalise(word));
|
||||||
out.push(&segment[start..at]);
|
}
|
||||||
start = if separator { at + c.len_utf8() } else { at };
|
};
|
||||||
|
for (at, c) in text.char_indices() {
|
||||||
|
if !is_word_char(c) {
|
||||||
|
end(start, at, &mut out);
|
||||||
|
start = at + c.len_utf8();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// a mark or joiner belongs to the character before it
|
||||||
|
let attached = is_combining_mark(c) || is_joiner(c);
|
||||||
|
let breaks = !attached
|
||||||
|
&& previous.is_some_and(|p| {
|
||||||
|
(p.is_lowercase() && c.is_uppercase()) || is_unspaced(p) != is_unspaced(c)
|
||||||
|
});
|
||||||
|
if breaks {
|
||||||
|
end(start, at, &mut out);
|
||||||
|
start = at;
|
||||||
|
}
|
||||||
|
if !attached {
|
||||||
|
previous = Some(c);
|
||||||
}
|
}
|
||||||
previous = Some(c);
|
|
||||||
}
|
}
|
||||||
out.push(&segment[start..]);
|
end(start, text.len(), &mut out);
|
||||||
out
|
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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
134
crates/weksa/src/speaker.rs
Normal file
134
crates/weksa/src/speaker.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
//! The speaker-state documents: a speaker's stable axes and a scene's relations.
|
||||||
|
//! Both are append-only revisions under one key per revision and hold typed
|
||||||
|
//! feature values only, never text (ruling `leak-owned-by-flow-and-scorer-3`).
|
||||||
|
//! `SpeakerStateStore` is their only writer; `LoweringInput::with_scene` is the
|
||||||
|
//! only reader that reaches a lowering view.
|
||||||
|
|
||||||
|
use cultcache_rs::DatabaseEntry;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::corpus::ModelFamily;
|
||||||
|
use crate::packet::{EntityId, FeatureValue};
|
||||||
|
|
||||||
|
/// The most participants one scene holds.
|
||||||
|
pub const MAX_PARTICIPANTS: usize = 64;
|
||||||
|
|
||||||
|
/// Who annotated a document. A model's generator is an attestation string, held
|
||||||
|
/// to the same form as every provenance string.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub enum Annotator {
|
||||||
|
Operator,
|
||||||
|
Model {
|
||||||
|
family: ModelFamily,
|
||||||
|
generator: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The speaker's stable axes. Key `speaker:<entity slug>@<n>`; a revision
|
||||||
|
/// appends and never edits.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
|
||||||
|
#[cultcache(type = "weksa.speaker")]
|
||||||
|
pub struct Speaker {
|
||||||
|
#[cultcache(key = 0)]
|
||||||
|
pub entity: EntityId,
|
||||||
|
/// The revision, from 1.
|
||||||
|
#[cultcache(key = 1)]
|
||||||
|
pub n: u32,
|
||||||
|
#[cultcache(key = 2)]
|
||||||
|
pub inventory_rev: u32,
|
||||||
|
/// Feature values of slot `Speaker`.
|
||||||
|
#[cultcache(key = 3)]
|
||||||
|
pub features: Vec<FeatureValue>,
|
||||||
|
#[cultcache(key = 4)]
|
||||||
|
pub annotator: Annotator,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other party of a relation. `Unnamed` is an addressee the scene does not
|
||||||
|
/// name, such as the player or a crowd.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub enum Party {
|
||||||
|
Named(EntityId),
|
||||||
|
Unnamed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The speaker's relation to one party: feature values of slot `Scene`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct PairRelation {
|
||||||
|
pub from: EntityId,
|
||||||
|
pub to: Party,
|
||||||
|
pub features: Vec<FeatureValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scene: its participants and the relations between them. Key
|
||||||
|
/// `scene:<group>@<n>`; a revision appends and never edits.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema, DatabaseEntry)]
|
||||||
|
#[cultcache(type = "weksa.scene")]
|
||||||
|
pub struct Scene {
|
||||||
|
/// The corpus split group of the scene, a slug.
|
||||||
|
#[cultcache(key = 0)]
|
||||||
|
pub group: String,
|
||||||
|
/// The revision, from 1.
|
||||||
|
#[cultcache(key = 1)]
|
||||||
|
pub n: u32,
|
||||||
|
/// The revision, of the inventory the scene was admitted through, at which
|
||||||
|
/// `put_scene` read its features: provenance only; a run never reads it.
|
||||||
|
#[cultcache(key = 2)]
|
||||||
|
pub inventory_rev: u32,
|
||||||
|
#[cultcache(key = 3)]
|
||||||
|
pub participants: Vec<EntityId>,
|
||||||
|
#[cultcache(key = 4)]
|
||||||
|
pub relations: Vec<PairRelation>,
|
||||||
|
#[cultcache(key = 5)]
|
||||||
|
pub annotator: Annotator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scene {
|
||||||
|
/// The first of `speakers` the scene does not list as a participant: the
|
||||||
|
/// one rule of scene coverage, applied where a scene is put and where it is
|
||||||
|
/// read.
|
||||||
|
pub fn uncovered<'a>(
|
||||||
|
&self,
|
||||||
|
speakers: impl IntoIterator<Item = &'a EntityId>,
|
||||||
|
) -> Option<&'a EntityId> {
|
||||||
|
speakers
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| !self.participants.contains(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The features of the relation from `speaker` to the named `addressee`, or
|
||||||
|
/// to `Unnamed` when there is none; `None` when the scene holds no such
|
||||||
|
/// relation (a relation with no features is `Some(&[])`). A named
|
||||||
|
/// addressee the scene lacks never falls back to `Unnamed`.
|
||||||
|
pub fn relation(
|
||||||
|
&self,
|
||||||
|
speaker: &EntityId,
|
||||||
|
addressee: Option<&EntityId>,
|
||||||
|
) -> Option<&[FeatureValue]> {
|
||||||
|
self.relations
|
||||||
|
.iter()
|
||||||
|
.find(|r| {
|
||||||
|
&r.from == speaker
|
||||||
|
&& match (&r.to, addressee) {
|
||||||
|
(Party::Named(a), Some(b)) => a == b,
|
||||||
|
(Party::Unnamed, None) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|r| r.features.as_slice())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `speaker:<entity slug>@<n>`.
|
||||||
|
pub fn speaker_key(entity: &EntityId, n: u32) -> String {
|
||||||
|
let slug = entity
|
||||||
|
.as_str()
|
||||||
|
.strip_prefix("entity:")
|
||||||
|
.unwrap_or(entity.as_str());
|
||||||
|
format!("speaker:{slug}@{n}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `scene:<group>@<n>`.
|
||||||
|
pub fn scene_key(group: &str, n: u32) -> String {
|
||||||
|
format!("scene:{group}@{n}")
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
//! The train/validation/test split is derived from an item's group and nothing
|
//! 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.
|
//! else. No stored assignment exists, so no writer can reassign one. An item is
|
||||||
|
//! read in its live split. The round path (round records, prompts, `aggregate`,
|
||||||
|
//! `leak_diagnostic`) carries no Test reading; a typed `TestReadings` and a Test
|
||||||
|
//! `EncoderOffer` need a Checkpoint, and only the lib can make one
|
||||||
|
//! (convergence-freeze adds the door). Operator tools (`weksa corpus dump`,
|
||||||
|
//! `weksa score`, `CorpusStore`) read corpus text of any split; that is not
|
||||||
|
//! gated here.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use crate::corpus::{CorpusItem, Source};
|
use crate::corpus::{CorpusItem, Source};
|
||||||
|
|
||||||
|
|
@ -16,15 +24,38 @@ pub enum SplitScheme {
|
||||||
V1,
|
V1,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fnv1a64(bytes: &[u8]) -> u64 {
|
pub const fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||||
|
fnv1a64_fold(0xcbf2_9ce4_8422_2325, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a 64 of the parts, each preceded by its length as eight little-endian
|
||||||
|
/// bytes, so no two different splits of the same bytes share a digest.
|
||||||
|
pub const fn fnv1a64_of(parts: &[&[u8]]) -> u64 {
|
||||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
for b in bytes {
|
let mut p = 0;
|
||||||
h ^= u64::from(*b);
|
while p < parts.len() {
|
||||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
h = fnv1a64_fold(h, &(parts[p].len() as u64).to_le_bytes());
|
||||||
|
h = fnv1a64_fold(h, parts[p]);
|
||||||
|
p += 1;
|
||||||
}
|
}
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn fnv1a64_fold(mut h: u64, bytes: &[u8]) -> u64 {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
h ^= bytes[i] as u64;
|
||||||
|
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16 lowercase hex digits, zero-padded: how a digest is written everywhere.
|
||||||
|
pub(crate) fn hex16(n: u64) -> String {
|
||||||
|
format!("{n:016x}")
|
||||||
|
}
|
||||||
|
|
||||||
impl SplitScheme {
|
impl SplitScheme {
|
||||||
/// 0..100, FNV-1a 64 of `weksa-split-v1:` + group, mod 100.
|
/// 0..100, FNV-1a 64 of `weksa-split-v1:` + group, mod 100.
|
||||||
pub fn bucket(self, group: &str) -> u64 {
|
pub fn bucket(self, group: &str) -> u64 {
|
||||||
|
|
@ -51,4 +82,142 @@ impl SplitScheme {
|
||||||
split => Some(split),
|
split => Some(split),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The split an item is read in; a withdrawn item is read in none.
|
||||||
|
pub fn live(self, item: &CorpusItem) -> Option<Split> {
|
||||||
|
if item.withdrawn {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
self.split(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission to read Test once, as typed `TestReadings` or as Test
|
||||||
|
/// `EncoderOffer`s. Only the weksa lib can make one: there is no public
|
||||||
|
/// constructor, so no code outside it can obtain those. This is not a seal on
|
||||||
|
/// corpus text: operator tools and `CorpusStore` can read any item, and the
|
||||||
|
/// guarded invariant is that the round path holds no Test reading. The one door
|
||||||
|
/// is added by
|
||||||
|
/// convergence-freeze, which builds a `Checkpoint` from the convergence window
|
||||||
|
/// over the run's persisted round records (convergence-test).
|
||||||
|
/// `TestReadings::at_checkpoint` consumes it, so one `Checkpoint` buys one Test
|
||||||
|
/// reading. One `Checkpoint` also offers each item at most once
|
||||||
|
/// (`EncoderOffer::for_checkpoint`), and an offer borrows its Checkpoint, so it
|
||||||
|
/// cannot outlive the reading that spends it.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// fn f(c: &weksa::split::Checkpoint) -> u32 { c.round() }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// No public constructor:
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// let _ = weksa::split::Checkpoint::declare(1);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The test constructor is not exported (this doctest names it today; the
|
||||||
|
/// line-based tripwire in `tests/checkpoint_door.rs` catches only common
|
||||||
|
/// single-line forms of a new public constructor):
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// let _ = weksa::split::Checkpoint::for_test(1);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// let _ = weksa::split::Checkpoint { round: 1, offered: Default::default() };
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// One reading per checkpoint:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use weksa::score::TestReadings;
|
||||||
|
/// use weksa::split::Checkpoint;
|
||||||
|
/// fn f(c: Checkpoint) { let _ = TestReadings::at_checkpoint(c, &[], &[], &[]); }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// use weksa::score::TestReadings;
|
||||||
|
/// use weksa::split::Checkpoint;
|
||||||
|
/// fn f(c: Checkpoint) {
|
||||||
|
/// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]);
|
||||||
|
/// let _ = TestReadings::at_checkpoint(c, &[], &[], &[]);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// fn f(c: weksa::split::Checkpoint) { let _ = c.clone(); }
|
||||||
|
/// ```
|
||||||
|
pub struct Checkpoint {
|
||||||
|
round: u32,
|
||||||
|
offered: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Checkpoint {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn for_test(round: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
round,
|
||||||
|
offered: BTreeSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records `key` as offered; false when it already was.
|
||||||
|
pub(crate) fn first_offer(&mut self, key: &str) -> bool {
|
||||||
|
self.offered.insert(key.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn round(&self) -> u32 {
|
||||||
|
self.round
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod mutants;
|
||||||
|
|
||||||
|
/// Item builders for the unit tests that live in `src`: the twins of
|
||||||
|
/// `tests/common`'s `group_in_bucket`, `item_in`, `cast` and `model`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod fixture {
|
||||||
|
use super::{Split, SplitScheme};
|
||||||
|
use crate::corpus::{CorpusItem, ModelFamily, Source};
|
||||||
|
use crate::packet::EntityId;
|
||||||
|
|
||||||
|
/// The first group `g<i>` whose V1 bucket is in `split`.
|
||||||
|
pub(crate) fn group_in(split: Split) -> String {
|
||||||
|
(0..100_000)
|
||||||
|
.map(|i| format!("g{i}"))
|
||||||
|
.find(|g| SplitScheme::V1.of_bucket(SplitScheme::V1.bucket(g)) == split)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn item(key: &str, text: &str, group: &str, model_written: bool) -> CorpusItem {
|
||||||
|
let source = if model_written {
|
||||||
|
Source::ModelWritten {
|
||||||
|
family: ModelFamily::Unrecorded,
|
||||||
|
generator: None,
|
||||||
|
prompt_digest: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Source::CastAuthored {
|
||||||
|
persona: "nibu".into(),
|
||||||
|
fixture: "f.yaml".into(),
|
||||||
|
author: "Ada Writer".into(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
CorpusItem {
|
||||||
|
id: key.into(),
|
||||||
|
text: text.into(),
|
||||||
|
language: "en-US".into(),
|
||||||
|
speaker: EntityId::parse("entity:nibu").unwrap(),
|
||||||
|
names: vec![],
|
||||||
|
names_by: None,
|
||||||
|
group: group.into(),
|
||||||
|
prior_turns: vec![],
|
||||||
|
source,
|
||||||
|
admitted_on: "2026-10-29".into(),
|
||||||
|
supersedes: None,
|
||||||
|
withdrawn: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
crates/weksa/src/split/mutants.rs
Normal file
11
crates/weksa/src/split/mutants.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//! Pins of private items of `split` found unpinned by mutation testing.
|
||||||
|
|
||||||
|
use super::Checkpoint;
|
||||||
|
|
||||||
|
/// A checkpoint reports the round it was declared at, whatever the round.
|
||||||
|
#[test]
|
||||||
|
fn a_checkpoint_reports_its_round() {
|
||||||
|
assert_eq!(Checkpoint::for_test(7).round(), 7);
|
||||||
|
assert_eq!(Checkpoint::for_test(0).round(), 0);
|
||||||
|
assert_eq!(Checkpoint::for_test(1).round(), 1);
|
||||||
|
}
|
||||||
|
|
@ -7,16 +7,26 @@ use std::fmt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use caseless::default_case_fold_str;
|
use caseless::default_case_fold_str;
|
||||||
use cultcache_rs::{CultCache, SingleFileMessagePackBackingStore};
|
use cultcache_rs::{CacheBackingStore, CultCache, SingleFileMessagePackBackingStore};
|
||||||
use unicode_normalization::{UnicodeNormalization, char::is_combining_mark, is_nfc};
|
use unicode_normalization::{UnicodeNormalization, char::is_combining_mark, is_nfc};
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
use crate::corpus::{CorpusItem, CorpusItemId, Source, item_key, key_slug};
|
use crate::corpus::{CorpusItem, CorpusItemId, Source, item_key, key_slug};
|
||||||
use crate::entity::{Entity, EntityOrigin};
|
use crate::split::{Split, SplitScheme};
|
||||||
use crate::form::{is_attestation, is_joining_only, is_language_tag, is_refused_format};
|
use crate::entity::{Entity, EntityOrigin, Pronunciation};
|
||||||
use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key};
|
use crate::form::{
|
||||||
use crate::packet::{EntityId, Packet};
|
is_attestation, is_calendar_date, is_joining_only, is_language_tag, is_refused_format, is_slug,
|
||||||
use crate::validate::{LoweringInput, Refusal, admissible};
|
};
|
||||||
|
use crate::inventory::{
|
||||||
|
Change, InvId, Inventory, InventoryEntry, InventoryRevision, Slot, revision_key,
|
||||||
|
};
|
||||||
|
use crate::ontology::{
|
||||||
|
MAX_CONDITIONS, MAX_CHOICE_BYTES, MAX_ENTRIES_PER_LANGUAGE, MAX_EXAMPLE_BYTES, MAX_EXAMPLES,
|
||||||
|
Ontology, OntologyEntry, Test,
|
||||||
|
};
|
||||||
|
use crate::packet::{EntityId, FeatValue, Packet};
|
||||||
|
use crate::speaker::{Annotator, MAX_PARTICIPANTS, Party, Scene, Speaker, scene_key, speaker_key};
|
||||||
|
use crate::validate::{LoweringInput, Refusal, feature_refusals, validate};
|
||||||
|
|
||||||
/// A broken store rule, named.
|
/// A broken store rule, named.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -77,10 +87,61 @@ pub enum StoreRefusal {
|
||||||
/// A new packet names a corpus item already withdrawn. Packets admitted
|
/// A new packet names a corpus item already withdrawn. Packets admitted
|
||||||
/// before the withdrawal stay stored and readable.
|
/// before the withdrawal stay stored and readable.
|
||||||
WithdrawnCorpusItem { key: String },
|
WithdrawnCorpusItem { key: String },
|
||||||
|
/// A speaker or scene revision that is not the entity's or group's latest
|
||||||
|
/// plus one (1 when none).
|
||||||
|
RevisionNotNext {
|
||||||
|
key: String,
|
||||||
|
expected: u32,
|
||||||
|
got: u32,
|
||||||
|
},
|
||||||
|
/// A scene that omits the speaker of a live item of its group.
|
||||||
|
SceneSpeakerMissing { group: String, entity: EntityId },
|
||||||
/// Any other malformed document or change.
|
/// Any other malformed document or change.
|
||||||
Malformed { key: String, why: &'static str },
|
Malformed { key: String, why: &'static str },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the CLI prints: the rule broken and the numbers and reasons that
|
||||||
|
/// explain it. Item keys, entity ids and groups are the caller's identifiers
|
||||||
|
/// and are left to the typed value, never to text.
|
||||||
|
impl fmt::Display for StoreRefusal {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
use StoreRefusal::*;
|
||||||
|
match self {
|
||||||
|
S1ItemImmutable { .. } => f.write_str("S1ItemImmutable"),
|
||||||
|
S2RevisionChain { expected, got } => {
|
||||||
|
write!(f, "S2RevisionChain expected {expected} got {got}")
|
||||||
|
}
|
||||||
|
S3Frozen { frozen } => write!(f, "S3Frozen at {frozen}"),
|
||||||
|
S4IntroducedRev {
|
||||||
|
introduced,
|
||||||
|
revision,
|
||||||
|
..
|
||||||
|
} => write!(f, "S4IntroducedRev {introduced} in revision {revision}"),
|
||||||
|
S5SupersedeGroup { .. } => f.write_str("S5SupersedeGroup"),
|
||||||
|
S6TextHeld { .. } => f.write_str("S6TextHeld"),
|
||||||
|
UnknownCorpusItem { .. } => f.write_str("UnknownCorpusItem"),
|
||||||
|
PacketImmutable { .. } => f.write_str("PacketImmutable"),
|
||||||
|
UnknownEntity { .. } => f.write_str("UnknownEntity"),
|
||||||
|
NameSpanRange { start, end, .. } => write!(f, "NameSpanRange {start}..{end}"),
|
||||||
|
NameSpanSplitsCluster { start, end, .. } => {
|
||||||
|
write!(f, "NameSpanSplitsCluster {start}..{end}")
|
||||||
|
}
|
||||||
|
NotNfc { .. } => f.write_str("NotNfc"),
|
||||||
|
EntityBounds { bound, .. } => write!(f, "EntityBounds {bound:?}"),
|
||||||
|
NameSpanOverlap { .. } => f.write_str("NameSpanOverlap"),
|
||||||
|
NameSpanNotAForm { start, end, .. } => write!(f, "NameSpanNotAForm {start}..{end}"),
|
||||||
|
EntityNotAppendOnly { .. } => f.write_str("EntityNotAppendOnly"),
|
||||||
|
EntityNotOnItem { .. } => f.write_str("EntityNotOnItem"),
|
||||||
|
WithdrawnCorpusItem { .. } => f.write_str("WithdrawnCorpusItem"),
|
||||||
|
RevisionNotNext { expected, got, .. } => {
|
||||||
|
write!(f, "RevisionNotNext expected {expected} got {got}")
|
||||||
|
}
|
||||||
|
SceneSpeakerMissing { .. } => f.write_str("SceneSpeakerMissing"),
|
||||||
|
Malformed { why, .. } => write!(f, "Malformed: {why}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The capacity bound an entity broke.
|
/// The capacity bound an entity broke.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum EntityBound {
|
pub enum EntityBound {
|
||||||
|
|
@ -111,7 +172,7 @@ pub enum StoreError {
|
||||||
impl fmt::Display for StoreError {
|
impl fmt::Display for StoreError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
StoreError::Refused(r) => write!(f, "refused: {r:?}"),
|
StoreError::Refused(r) => write!(f, "refused: {r}"),
|
||||||
StoreError::Invalid(rs) => {
|
StoreError::Invalid(rs) => {
|
||||||
write!(f, "invalid packet:")?;
|
write!(f, "invalid packet:")?;
|
||||||
rs.iter().try_for_each(|r| write!(f, " [{r}]"))
|
rs.iter().try_for_each(|r| write!(f, " [{r}]"))
|
||||||
|
|
@ -143,16 +204,23 @@ fn malformed(key: &str, why: &'static str) -> StoreError {
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_cache(
|
pub(crate) fn open_cache(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
register: impl FnOnce(&mut CultCache) -> anyhow::Result<()>,
|
register: impl FnOnce(&mut CultCache) -> anyhow::Result<()>,
|
||||||
) -> anyhow::Result<CultCache> {
|
) -> anyhow::Result<CultCache> {
|
||||||
if let Some(dir) = path.parent() {
|
if let Some(dir) = path.parent() {
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
|
open_cache_over(SingleFileMessagePackBackingStore::new(path), register)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open_cache_over(
|
||||||
|
store: impl CacheBackingStore + 'static,
|
||||||
|
register: impl FnOnce(&mut CultCache) -> anyhow::Result<()>,
|
||||||
|
) -> anyhow::Result<CultCache> {
|
||||||
let mut cache = CultCache::new();
|
let mut cache = CultCache::new();
|
||||||
register(&mut cache)?;
|
register(&mut cache)?;
|
||||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
|
cache.add_generic_backing_store(store)?;
|
||||||
cache.pull_all_backing_stores()?;
|
cache.pull_all_backing_stores()?;
|
||||||
Ok(cache)
|
Ok(cache)
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +249,17 @@ impl CorpusStore {
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text of every item in a Test group, any language, withdrawn and
|
||||||
|
/// model-written ones included: what no prompt may hold.
|
||||||
|
pub fn test_texts(&self) -> anyhow::Result<Vec<String>> {
|
||||||
|
Ok(self
|
||||||
|
.all()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| SplitScheme::V1.of_bucket(SplitScheme::V1.bucket(&item.group)) == Split::Test)
|
||||||
|
.map(|item| item.text)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, key: &str) -> anyhow::Result<Option<CorpusItem>> {
|
pub fn get(&self, key: &str) -> anyhow::Result<Option<CorpusItem>> {
|
||||||
self.cache.get::<CorpusItem>(key)
|
self.cache.get::<CorpusItem>(key)
|
||||||
}
|
}
|
||||||
|
|
@ -241,12 +320,33 @@ impl CorpusStore {
|
||||||
return Err(StoreRefusal::NotNfc { key: item.id }.into());
|
return Err(StoreRefusal::NotNfc { key: item.id }.into());
|
||||||
}
|
}
|
||||||
let text = normalized(&item.text);
|
let text = normalized(&item.text);
|
||||||
if !text.chars().any(char::is_alphanumeric) {
|
// Read from the text as given: NFKC maps modifier letters (ʰ) to base letters.
|
||||||
return Err(malformed(&item.id, "text has no alphanumeric character"));
|
// A text of marks, joiners and modifiers says so; any other text with
|
||||||
|
// no alphanumeric character (punctuation, space) gets the plainer reason.
|
||||||
|
if !item.text.chars().any(is_base) {
|
||||||
|
let marks_only = item.text.chars().any(|c| {
|
||||||
|
is_combining_mark(c)
|
||||||
|
|| matches!(c, '\u{200c}' | '\u{200d}')
|
||||||
|
|| is_modifier_letter(c)
|
||||||
|
});
|
||||||
|
return Err(malformed(
|
||||||
|
&item.id,
|
||||||
|
if marks_only {
|
||||||
|
"text has no base letter or digit"
|
||||||
|
} else {
|
||||||
|
"text has no alphanumeric character"
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_calendar_date(&item.admitted_on) {
|
||||||
|
return Err(malformed(&item.id, "admitted_on is not a YYYY-MM-DD date"));
|
||||||
}
|
}
|
||||||
if let Some(why) = empty_attestation(&item.source) {
|
if let Some(why) = empty_attestation(&item.source) {
|
||||||
return Err(malformed(&item.id, why));
|
return Err(malformed(&item.id, why));
|
||||||
}
|
}
|
||||||
|
if let Some(by) = &item.names_by {
|
||||||
|
check_annotator(&item.id, by)?;
|
||||||
|
}
|
||||||
check_entities(&item, entities)?;
|
check_entities(&item, entities)?;
|
||||||
for held in self.all()? {
|
for held in self.all()? {
|
||||||
if normalized(&held.text) == text && item.supersedes.as_deref() != Some(&held.id) {
|
if normalized(&held.text) == text && item.supersedes.as_deref() != Some(&held.id) {
|
||||||
|
|
@ -364,16 +464,160 @@ fn check_entities(item: &CorpusItem, entities: &EntityStore) -> Result<(), Store
|
||||||
/// default case fold, NFKC) keeping letters, digits and combining marks
|
/// 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,
|
/// (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
|
/// fullwidth, ligature and case-fold variants (straße, STRASSE) and spacing and
|
||||||
/// punctuation variants compare equal. Homoglyphs across scripts (Cyrillic a for
|
/// punctuation variants compare equal. Three writings of one thing also fold:
|
||||||
/// Latin a) stay apart: no key here reads what a character looks like.
|
/// Turkish dotless i and dotted capital I read as i, katakana reads as the
|
||||||
fn normalized(text: &str) -> String {
|
/// hiragana of the same syllable, and Arabic-Indic and Extended Arabic-Indic
|
||||||
let folded = default_case_fold_str(&text.nfkc().collect::<String>());
|
/// digits read as 0-9. Homoglyphs across scripts (Cyrillic a for Latin a) stay
|
||||||
folded
|
/// apart: no key here reads what a character looks like. A combining dot above
|
||||||
|
/// (U+0307) after i is dropped, so İx and i̇x collide as the old case fold made
|
||||||
|
/// them; so does plain ix, with both. Over-merging a rare sequence errs toward
|
||||||
|
/// held, a lost collision toward leak. U+0307 after any other letter stays.
|
||||||
|
pub(crate) fn normalized(text: &str) -> String {
|
||||||
|
let mapped: String = text.nfkc().flat_map(fold_variant).collect();
|
||||||
|
// The dot goes before the last NFKC so the marks left on the i compose with
|
||||||
|
// it (i, dot, acute reads as the i with an acute).
|
||||||
|
let mut dotless = String::new();
|
||||||
|
for c in default_case_fold_str(&mapped).chars() {
|
||||||
|
if c == '\u{307}' && dotless.ends_with('i') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dotless.push(c);
|
||||||
|
}
|
||||||
|
dotless
|
||||||
.nfkc()
|
.nfkc()
|
||||||
.filter(|c| c.is_alphanumeric() || is_combining_mark(*c))
|
.filter(|&c| c.is_alphanumeric() || is_combining_mark(c))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one-to-one folds `normalized` adds to NFKC and case folding, applied
|
||||||
|
/// before the case fold. (U+0130 needs no arm: it folds to i plus a combining
|
||||||
|
/// dot, and the `dotless` loop in `normalized` drops that dot after an `i`, so
|
||||||
|
/// the key is plain `i`.)
|
||||||
|
fn fold_variant(c: char) -> impl Iterator<Item = char> {
|
||||||
|
let one = |c: char| (c, None);
|
||||||
|
let (first, second) = match c {
|
||||||
|
'\u{131}' => one('i'),
|
||||||
|
// Voiced katakana wa, wi, we, wo: the hiragana (U+308F-3092) plus the
|
||||||
|
// combining voiced mark, which is how NFKC spells the hiragana forms.
|
||||||
|
'\u{30f7}'..='\u{30fa}' => (
|
||||||
|
char::from_u32(c as u32 - 0x68).unwrap_or(c),
|
||||||
|
Some('\u{3099}'),
|
||||||
|
),
|
||||||
|
// Katakana letters and small letters to hiragana; the iteration marks
|
||||||
|
// ヽヾ to ゝゞ. The prolonged sound mark U+30FC stays.
|
||||||
|
'\u{30a1}'..='\u{30f6}' | '\u{30fd}'..='\u{30fe}' => {
|
||||||
|
one(char::from_u32(c as u32 - 0x60).unwrap_or(c))
|
||||||
|
}
|
||||||
|
'\u{660}'..='\u{669}' => one(char::from(b'0' + (c as u32 - 0x660) as u8)),
|
||||||
|
'\u{6f0}'..='\u{6f9}' => one(char::from(b'0' + (c as u32 - 0x6f0) as u8)),
|
||||||
|
_ => one(c),
|
||||||
|
};
|
||||||
|
std::iter::once(first).chain(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A character that can carry a word: alphanumeric and neither a combining
|
||||||
|
/// mark (Mn, Mc, Me) nor a modifier letter (Lm). A text of none is marks and
|
||||||
|
/// modifiers only, like a text of punctuation.
|
||||||
|
fn is_base(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() && !is_combining_mark(c) && !is_modifier_letter(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// General_Category Lm, Unicode 17 (the Rust 1.99 tables), as inclusive ranges.
|
||||||
|
fn is_modifier_letter(c: char) -> bool {
|
||||||
|
const LM: [(u32, u32); 79] = [
|
||||||
|
(0x2b0, 0x2c1),
|
||||||
|
(0x2c6, 0x2d1),
|
||||||
|
(0x2e0, 0x2e4),
|
||||||
|
(0x2ec, 0x2ec),
|
||||||
|
(0x2ee, 0x2ee),
|
||||||
|
(0x374, 0x374),
|
||||||
|
(0x37a, 0x37a),
|
||||||
|
(0x559, 0x559),
|
||||||
|
(0x640, 0x640),
|
||||||
|
(0x6e5, 0x6e6),
|
||||||
|
(0x7f4, 0x7f5),
|
||||||
|
(0x7fa, 0x7fa),
|
||||||
|
(0x81a, 0x81a),
|
||||||
|
(0x824, 0x824),
|
||||||
|
(0x828, 0x828),
|
||||||
|
(0x8c9, 0x8c9),
|
||||||
|
(0x971, 0x971),
|
||||||
|
(0xe46, 0xe46),
|
||||||
|
(0xec6, 0xec6),
|
||||||
|
(0x10fc, 0x10fc),
|
||||||
|
(0x17d7, 0x17d7),
|
||||||
|
(0x1843, 0x1843),
|
||||||
|
(0x1aa7, 0x1aa7),
|
||||||
|
(0x1c78, 0x1c7d),
|
||||||
|
(0x1d2c, 0x1d6a),
|
||||||
|
(0x1d78, 0x1d78),
|
||||||
|
(0x1d9b, 0x1dbf),
|
||||||
|
(0x2071, 0x2071),
|
||||||
|
(0x207f, 0x207f),
|
||||||
|
(0x2090, 0x209c),
|
||||||
|
(0x2c7c, 0x2c7d),
|
||||||
|
(0x2d6f, 0x2d6f),
|
||||||
|
(0x2e2f, 0x2e2f),
|
||||||
|
(0x3005, 0x3005),
|
||||||
|
(0x3031, 0x3035),
|
||||||
|
(0x303b, 0x303b),
|
||||||
|
(0x309d, 0x309e),
|
||||||
|
(0x30fc, 0x30fe),
|
||||||
|
(0xa015, 0xa015),
|
||||||
|
(0xa4f8, 0xa4fd),
|
||||||
|
(0xa60c, 0xa60c),
|
||||||
|
(0xa67f, 0xa67f),
|
||||||
|
(0xa69c, 0xa69d),
|
||||||
|
(0xa717, 0xa71f),
|
||||||
|
(0xa770, 0xa770),
|
||||||
|
(0xa788, 0xa788),
|
||||||
|
(0xa7f1, 0xa7f4),
|
||||||
|
(0xa7f8, 0xa7f9),
|
||||||
|
(0xa9cf, 0xa9cf),
|
||||||
|
(0xa9e6, 0xa9e6),
|
||||||
|
(0xaa70, 0xaa70),
|
||||||
|
(0xaadd, 0xaadd),
|
||||||
|
(0xaaf3, 0xaaf4),
|
||||||
|
(0xab5c, 0xab5f),
|
||||||
|
(0xab69, 0xab69),
|
||||||
|
(0xff70, 0xff70),
|
||||||
|
(0xff9e, 0xff9f),
|
||||||
|
(0x10780, 0x10785),
|
||||||
|
(0x10787, 0x107b0),
|
||||||
|
(0x107b2, 0x107ba),
|
||||||
|
(0x10d4e, 0x10d4e),
|
||||||
|
(0x10d6f, 0x10d6f),
|
||||||
|
(0x10ec5, 0x10ec5),
|
||||||
|
(0x11dd9, 0x11dd9),
|
||||||
|
(0x16b40, 0x16b43),
|
||||||
|
(0x16d40, 0x16d42),
|
||||||
|
(0x16d6b, 0x16d6c),
|
||||||
|
(0x16f93, 0x16f9f),
|
||||||
|
(0x16fe0, 0x16fe1),
|
||||||
|
(0x16fe3, 0x16fe3),
|
||||||
|
(0x16ff2, 0x16ff3),
|
||||||
|
(0x1aff0, 0x1aff3),
|
||||||
|
(0x1aff5, 0x1affb),
|
||||||
|
(0x1affd, 0x1affe),
|
||||||
|
(0x1e030, 0x1e06d),
|
||||||
|
(0x1e137, 0x1e13d),
|
||||||
|
(0x1e4eb, 0x1e4eb),
|
||||||
|
(0x1e6ff, 0x1e6ff),
|
||||||
|
(0x1e94b, 0x1e94b),
|
||||||
|
];
|
||||||
|
let c = c as u32;
|
||||||
|
LM.binary_search_by(|&(lo, hi)| {
|
||||||
|
if hi < c {
|
||||||
|
std::cmp::Ordering::Less
|
||||||
|
} else if lo > c {
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
} else {
|
||||||
|
std::cmp::Ordering::Equal
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// The field that holds an unattested provenance string, if any: a string with
|
/// 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
|
/// no alphanumeric, a control or format character, or over 200 bytes attests
|
||||||
/// nothing.
|
/// nothing.
|
||||||
|
|
@ -402,6 +646,18 @@ fn empty_attestation(source: &Source) -> Option<&'static str> {
|
||||||
Source::PublicDomain { edition_url, .. } if empty(edition_url) => {
|
Source::PublicDomain { edition_url, .. } if empty(edition_url) => {
|
||||||
Some("a public-domain source names its edition url")
|
Some("a public-domain source names its edition url")
|
||||||
}
|
}
|
||||||
|
Source::PublicDomain { licence, .. } if empty(licence) => {
|
||||||
|
Some("a public-domain source names its licence")
|
||||||
|
}
|
||||||
|
Source::PublicDomain { jurisdiction, .. } if empty(jurisdiction) => {
|
||||||
|
Some("a public-domain source names its jurisdiction")
|
||||||
|
}
|
||||||
|
Source::PublicDomain { translator, .. } if empty(translator) => {
|
||||||
|
Some("a public-domain source names its translator")
|
||||||
|
}
|
||||||
|
Source::PublicDomain {
|
||||||
|
edition_year: 0, ..
|
||||||
|
} => Some("a public-domain source names its edition year"),
|
||||||
Source::ModelWritten { generator, .. } if empty_some(generator) => {
|
Source::ModelWritten { generator, .. } if empty_some(generator) => {
|
||||||
Some("a model-written source names a non-empty generator")
|
Some("a model-written source names a non-empty generator")
|
||||||
}
|
}
|
||||||
|
|
@ -453,6 +709,7 @@ impl EntityStore {
|
||||||
o.language == n.language
|
o.language == n.language
|
||||||
&& o.forms.len() <= n.forms.len()
|
&& o.forms.len() <= n.forms.len()
|
||||||
&& n.forms.starts_with(&o.forms)
|
&& n.forms.starts_with(&o.forms)
|
||||||
|
&& (o.pronunciation.is_none() || o.pronunciation == n.pronunciation)
|
||||||
});
|
});
|
||||||
if !grows {
|
if !grows {
|
||||||
return Err(StoreRefusal::EntityNotAppendOnly { id: entity.id }.into());
|
return Err(StoreRefusal::EntityNotAppendOnly { id: entity.id }.into());
|
||||||
|
|
@ -506,6 +763,9 @@ fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> {
|
||||||
if lf.forms.len() > EntityBound::MAX_FORMS {
|
if lf.forms.len() > EntityBound::MAX_FORMS {
|
||||||
return Err(bound(EntityBound::Forms));
|
return Err(bound(EntityBound::Forms));
|
||||||
}
|
}
|
||||||
|
if let Some(p) = &lf.pronunciation {
|
||||||
|
check_pronunciation(entity, p)?;
|
||||||
|
}
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
for f in &lf.forms {
|
for f in &lf.forms {
|
||||||
if f.len() > EntityBound::MAX_FORM_BYTES {
|
if f.len() > EntityBound::MAX_FORM_BYTES {
|
||||||
|
|
@ -536,6 +796,48 @@ fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pronunciation is bare IPA, NFC, with no brackets, control character or
|
||||||
|
/// edge whitespace; every rejected variant is the same, differs from the ruled
|
||||||
|
/// one and is named once; the ruling names its author, a `YYYY-MM-DD` date and
|
||||||
|
/// its source.
|
||||||
|
fn check_pronunciation(entity: &Entity, p: &Pronunciation) -> Result<(), StoreError> {
|
||||||
|
let bad = |why| malformed(entity.id.as_str(), why);
|
||||||
|
let ipa_ok = |s: &str| {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.len() <= EntityBound::MAX_FORM_BYTES
|
||||||
|
&& is_nfc(s)
|
||||||
|
&& s.trim() == s
|
||||||
|
&& !s
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_control() || is_refused_format(c) || "/[]".contains(c))
|
||||||
|
};
|
||||||
|
if !ipa_ok(&p.ipa) {
|
||||||
|
return Err(bad(
|
||||||
|
"a pronunciation is bare NFC IPA with no brackets or slashes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_attestation(&p.ruled_by) || !is_attestation(&p.source) {
|
||||||
|
return Err(bad("a pronunciation names who ruled it and where"));
|
||||||
|
}
|
||||||
|
if !is_calendar_date(&p.date) {
|
||||||
|
return Err(bad("a pronunciation is dated YYYY-MM-DD"));
|
||||||
|
}
|
||||||
|
if p.rejected.len() > EntityBound::MAX_FORMS {
|
||||||
|
return Err(StoreRefusal::EntityBounds {
|
||||||
|
id: entity.id.clone(),
|
||||||
|
bound: EntityBound::Forms,
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let mut seen = BTreeSet::from([&p.ipa]);
|
||||||
|
if !p.rejected.iter().all(|r| ipa_ok(r) && seen.insert(r)) {
|
||||||
|
return Err(bad(
|
||||||
|
"a rejected pronunciation is well-formed, named once and not the ruled one",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub struct InventoryStore {
|
pub struct InventoryStore {
|
||||||
cache: CultCache,
|
cache: CultCache,
|
||||||
}
|
}
|
||||||
|
|
@ -683,7 +985,7 @@ impl PacketStore {
|
||||||
corpus: &CorpusStore,
|
corpus: &CorpusStore,
|
||||||
entities: &EntityStore,
|
entities: &EntityStore,
|
||||||
) -> Result<LoweringInput, StoreError> {
|
) -> Result<LoweringInput, StoreError> {
|
||||||
let view = admissible(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?;
|
validate(&packet, &inventory.inventory()?).map_err(StoreError::Invalid)?;
|
||||||
let item = corpus.resolve(&packet.provenance.corpus_item)?;
|
let item = corpus.resolve(&packet.provenance.corpus_item)?;
|
||||||
if item.withdrawn {
|
if item.withdrawn {
|
||||||
return Err(StoreRefusal::WithdrawnCorpusItem {
|
return Err(StoreRefusal::WithdrawnCorpusItem {
|
||||||
|
|
@ -698,9 +1000,7 @@ impl PacketStore {
|
||||||
}
|
}
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
let on_item = item.names.iter().any(|n| &n.entity == entity)
|
let on_item = item.cited_entities().any(|e| e == entity);
|
||||||
|| &item.speaker == entity
|
|
||||||
|| item.prior_turns.iter().any(|t| &t.speaker == entity);
|
|
||||||
if !on_item {
|
if !on_item {
|
||||||
return Err(StoreRefusal::EntityNotOnItem {
|
return Err(StoreRefusal::EntityNotOnItem {
|
||||||
entity: entity.clone(),
|
entity: entity.clone(),
|
||||||
|
|
@ -709,6 +1009,7 @@ impl PacketStore {
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let view = LoweringInput::of(&packet, &item);
|
||||||
let key = packet.id.to_string();
|
let key = packet.id.to_string();
|
||||||
if let Some(existing) = self.get(&key)? {
|
if let Some(existing) = self.get(&key)? {
|
||||||
if existing == packet {
|
if existing == packet {
|
||||||
|
|
@ -720,3 +1021,342 @@ impl PacketStore {
|
||||||
Ok(view)
|
Ok(view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The speaker and scene documents: append-only revisions, one key each. The
|
||||||
|
/// only writer of `weksa.speaker` and `weksa.scene`.
|
||||||
|
pub struct SpeakerStateStore {
|
||||||
|
cache: CultCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpeakerStateStore {
|
||||||
|
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
cache: open_cache(path, |c| {
|
||||||
|
c.register_entry_type::<Speaker>()?;
|
||||||
|
c.register_entry_type::<Scene>()
|
||||||
|
})?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admits the next revision of a speaker, or an identical replay. The key
|
||||||
|
/// derives from the document. Revision `n` is the entity's latest plus one
|
||||||
|
/// (1 when none). The entity is in the entity store, the features pass
|
||||||
|
/// `feature_refusals` in slot `Speaker` at the document's inventory
|
||||||
|
/// revision, and a model annotator's generator is an attestation.
|
||||||
|
pub fn put_speaker(
|
||||||
|
&mut self,
|
||||||
|
s: Speaker,
|
||||||
|
inventory: &InventoryStore,
|
||||||
|
entities: &EntityStore,
|
||||||
|
) -> Result<AddOutcome, StoreError> {
|
||||||
|
let key = speaker_key(&s.entity, s.n);
|
||||||
|
if self.get_speaker(&key)?.is_some_and(|old| old == s) {
|
||||||
|
return Ok(AddOutcome::Unchanged);
|
||||||
|
}
|
||||||
|
check_annotator(&key, &s.annotator)?;
|
||||||
|
known_entity(entities, &s.entity)?;
|
||||||
|
let out = feature_refusals(
|
||||||
|
&inventory.inventory()?,
|
||||||
|
s.inventory_rev,
|
||||||
|
&s.features,
|
||||||
|
Slot::Speaker,
|
||||||
|
);
|
||||||
|
if !out.is_empty() {
|
||||||
|
return Err(StoreError::Invalid(out));
|
||||||
|
}
|
||||||
|
let latest = self.speaker(&s.entity)?.map_or(0, |l| l.n);
|
||||||
|
check_next(&key, latest, s.n)?;
|
||||||
|
self.cache.put(key, &s)?;
|
||||||
|
Ok(AddOutcome::Added)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admits the next revision of a scene, or an identical replay. The group
|
||||||
|
/// is a slug some corpus item (live or withdrawn) holds; there are 1 to
|
||||||
|
/// `MAX_PARTICIPANTS` participants, each named once and in the entity
|
||||||
|
/// store; a relation's `from` is a participant, a named `to` is another
|
||||||
|
/// participant, and an ordered pair has at most one relation; every speaker
|
||||||
|
/// of a live item of the group is a participant. Features pass
|
||||||
|
/// `feature_refusals` in slot `Scene` for every relation.
|
||||||
|
pub fn put_scene(
|
||||||
|
&mut self,
|
||||||
|
s: Scene,
|
||||||
|
inventory: &InventoryStore,
|
||||||
|
corpus: &CorpusStore,
|
||||||
|
entities: &EntityStore,
|
||||||
|
) -> Result<AddOutcome, StoreError> {
|
||||||
|
let key = scene_key(&s.group, s.n);
|
||||||
|
if self.get_scene(&key)?.is_some_and(|old| old == s) {
|
||||||
|
return Ok(AddOutcome::Unchanged);
|
||||||
|
}
|
||||||
|
let bad = |why| malformed(&key, why);
|
||||||
|
if !is_slug(&s.group) {
|
||||||
|
return Err(bad("a scene group is a slug"));
|
||||||
|
}
|
||||||
|
check_annotator(&key, &s.annotator)?;
|
||||||
|
let items: Vec<CorpusItem> = corpus
|
||||||
|
.all()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|i| i.group == s.group)
|
||||||
|
.collect();
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(bad("no corpus item holds the scene's group"));
|
||||||
|
}
|
||||||
|
if s.participants.is_empty() || s.participants.len() > MAX_PARTICIPANTS {
|
||||||
|
return Err(bad("a scene has 1 to 64 participants"));
|
||||||
|
}
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
for p in &s.participants {
|
||||||
|
if !seen.insert(p) {
|
||||||
|
return Err(bad("a participant is named once"));
|
||||||
|
}
|
||||||
|
known_entity(entities, p)?;
|
||||||
|
}
|
||||||
|
let mut pairs = BTreeSet::new();
|
||||||
|
for r in &s.relations {
|
||||||
|
if !seen.contains(&r.from) {
|
||||||
|
return Err(bad("a relation's from is a participant"));
|
||||||
|
}
|
||||||
|
if let Party::Named(to) = &r.to
|
||||||
|
&& (!seen.contains(to) || to == &r.from)
|
||||||
|
{
|
||||||
|
return Err(bad("a relation's named to is another participant"));
|
||||||
|
}
|
||||||
|
if !pairs.insert((&r.from, &r.to)) {
|
||||||
|
return Err(bad("an ordered pair has one relation"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(entity) = s.uncovered(items.iter().filter(|i| !i.withdrawn).map(|i| &i.speaker))
|
||||||
|
{
|
||||||
|
return Err(StoreRefusal::SceneSpeakerMissing {
|
||||||
|
entity: entity.clone(),
|
||||||
|
group: s.group,
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let inv = inventory.inventory()?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if s.relations.is_empty() {
|
||||||
|
out = feature_refusals(&inv, s.inventory_rev, &[], Slot::Scene);
|
||||||
|
}
|
||||||
|
for r in &s.relations {
|
||||||
|
out.extend(feature_refusals(
|
||||||
|
&inv,
|
||||||
|
s.inventory_rev,
|
||||||
|
&r.features,
|
||||||
|
Slot::Scene,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !out.is_empty() {
|
||||||
|
return Err(StoreError::Invalid(out));
|
||||||
|
}
|
||||||
|
let latest = self.scene(&s.group)?.map_or(0, |l| l.n);
|
||||||
|
check_next(&key, latest, s.n)?;
|
||||||
|
self.cache.put(key, &s)?;
|
||||||
|
Ok(AddOutcome::Added)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest revision (highest `n`) of the entity's speaker document.
|
||||||
|
pub fn speaker(&self, entity: &EntityId) -> anyhow::Result<Option<Speaker>> {
|
||||||
|
Ok(self
|
||||||
|
.all_speakers()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| &s.entity == entity)
|
||||||
|
.max_by_key(|s| s.n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest revision (highest `n`) of the group's scene document.
|
||||||
|
pub fn scene(&self, group: &str) -> anyhow::Result<Option<Scene>> {
|
||||||
|
Ok(self
|
||||||
|
.all_scenes()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.group == group)
|
||||||
|
.max_by_key(|s| s.n))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_speaker(&self, key: &str) -> anyhow::Result<Option<Speaker>> {
|
||||||
|
self.cache.get::<Speaker>(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_scene(&self, key: &str) -> anyhow::Result<Option<Scene>> {
|
||||||
|
self.cache.get::<Scene>(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every speaker revision, by entity then revision number.
|
||||||
|
pub fn all_speakers(&self) -> anyhow::Result<Vec<Speaker>> {
|
||||||
|
let mut all = self.cache.get_all::<Speaker>()?;
|
||||||
|
all.sort_by(|a, b| (&a.entity, a.n).cmp(&(&b.entity, b.n)));
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every scene revision, by group then revision number.
|
||||||
|
pub fn all_scenes(&self) -> anyhow::Result<Vec<Scene>> {
|
||||||
|
let mut all = self.cache.get_all::<Scene>()?;
|
||||||
|
all.sort_by(|a, b| (&a.group, a.n).cmp(&(&b.group, b.n)));
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_entity(entities: &EntityStore, id: &EntityId) -> Result<(), StoreError> {
|
||||||
|
match entities.get(id)? {
|
||||||
|
Some(_) => Ok(()),
|
||||||
|
None => Err(StoreRefusal::UnknownEntity { entity: id.clone() }.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_annotator(key: &str, a: &Annotator) -> Result<(), StoreError> {
|
||||||
|
match a {
|
||||||
|
Annotator::Model { generator, .. } if !is_attestation(generator) => {
|
||||||
|
Err(malformed(key, "a model annotator attests its generator"))
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_next(key: &str, latest: u32, got: u32) -> Result<(), StoreError> {
|
||||||
|
let expected = latest
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| malformed(key, "no revision follows the latest"))?;
|
||||||
|
if got != expected {
|
||||||
|
return Err(StoreRefusal::RevisionNotNext {
|
||||||
|
key: key.to_string(),
|
||||||
|
expected,
|
||||||
|
got,
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language ontology: immutable entries admitted in all-or-nothing
|
||||||
|
/// batches. The only writer of `weksa.ontology_entry`.
|
||||||
|
pub struct OntologyStore {
|
||||||
|
cache: CultCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OntologyStore {
|
||||||
|
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
cache: open_cache(path, |c| c.register_entry_type::<OntologyEntry>())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every entry, by language then number.
|
||||||
|
pub fn all(&self) -> anyhow::Result<Vec<OntologyEntry>> {
|
||||||
|
Ok(self.ontology()?.entries().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ontology(&self) -> anyhow::Result<Ontology> {
|
||||||
|
Ok(Ontology::of(self.cache.get_all::<OntologyEntry>()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admits a batch whole or not at all; an identical replay of stored
|
||||||
|
/// entries is `Unchanged`. Per language the batch's numbers run the
|
||||||
|
/// language's latest plus one, consecutively, to at most
|
||||||
|
/// `MAX_ENTRIES_PER_LANGUAGE` entries in all. Every entry passes
|
||||||
|
/// [`check_ontology_entry`] against the inventory before any is put. No
|
||||||
|
/// message echoes an entry's text.
|
||||||
|
pub fn admit(
|
||||||
|
&mut self,
|
||||||
|
batch: Vec<OntologyEntry>,
|
||||||
|
inventory: &InventoryStore,
|
||||||
|
) -> Result<AddOutcome, StoreError> {
|
||||||
|
let all = self.cache.get_all::<OntologyEntry>()?;
|
||||||
|
if batch
|
||||||
|
.iter()
|
||||||
|
.all(|e| all.iter().any(|old| old == e))
|
||||||
|
{
|
||||||
|
return Ok(AddOutcome::Unchanged);
|
||||||
|
}
|
||||||
|
let inv = inventory.inventory()?;
|
||||||
|
let mut counted: std::collections::BTreeMap<&str, u32> = std::collections::BTreeMap::new();
|
||||||
|
for e in &batch {
|
||||||
|
let key = e.key();
|
||||||
|
if !is_language_tag(&e.language) {
|
||||||
|
return Err(malformed(&key, "an ontology language is a language tag"));
|
||||||
|
}
|
||||||
|
let latest = match counted.get(e.language.as_str()) {
|
||||||
|
Some(n) => *n,
|
||||||
|
None => all
|
||||||
|
.iter()
|
||||||
|
.filter(|o| o.language == e.language)
|
||||||
|
.map(|o| o.n)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0),
|
||||||
|
};
|
||||||
|
check_next(&key, latest, e.n)?;
|
||||||
|
if e.n as usize > MAX_ENTRIES_PER_LANGUAGE {
|
||||||
|
return Err(malformed(&key, "a language holds at most 64 entries"));
|
||||||
|
}
|
||||||
|
counted.insert(e.language.as_str(), e.n);
|
||||||
|
check_ontology_entry(e, &inv, e.inventory_rev)?;
|
||||||
|
}
|
||||||
|
let mut staged = Vec::new();
|
||||||
|
for e in &batch {
|
||||||
|
staged.push(self.cache.prepare_entry_named(e.key(), e)?.0);
|
||||||
|
}
|
||||||
|
self.cache.put_prepared_batch(staged)?;
|
||||||
|
Ok(AddOutcome::Added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rules of one ontology entry against an inventory: the annotator, the
|
||||||
|
/// bounded and normalized text of the choice and examples, the condition count
|
||||||
|
/// and each condition's feature (slot `Stance`, `Discourse` or `Scene`, a grade
|
||||||
|
/// feature for `AtLeast`, `AtMost` and `AtMostOrUnset`, an option or flag value for `Is`) read at
|
||||||
|
/// `rev`. The store passes the entry's own `inventory_rev` on admission; a run
|
||||||
|
/// passes its inventory's head, because beds number revisions independently.
|
||||||
|
pub(crate) fn check_ontology_entry(
|
||||||
|
e: &OntologyEntry,
|
||||||
|
inventory: &Inventory,
|
||||||
|
rev: u32,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let key = e.key();
|
||||||
|
check_annotator(&key, &e.annotator)?;
|
||||||
|
let text_ok = |t: &str, max: usize| {
|
||||||
|
(1..=max).contains(&t.len())
|
||||||
|
&& !t.chars().any(|c| c.is_control() || is_refused_format(c))
|
||||||
|
&& is_nfc(t)
|
||||||
|
};
|
||||||
|
if !text_ok(&e.choice, MAX_CHOICE_BYTES) {
|
||||||
|
return Err(malformed(
|
||||||
|
&key,
|
||||||
|
"a choice is 1 to 200 normalized bytes with no control or format character",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if e.examples.len() > MAX_EXAMPLES
|
||||||
|
|| !e.examples.iter().all(|x| text_ok(x, MAX_EXAMPLE_BYTES))
|
||||||
|
{
|
||||||
|
return Err(malformed(
|
||||||
|
&key,
|
||||||
|
"an entry has at most 3 examples of 1 to 120 normalized bytes with no control or format character",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if e.when.len() > MAX_CONDITIONS {
|
||||||
|
return Err(malformed(&key, "an entry has at most 4 conditions"));
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for c in &e.when {
|
||||||
|
if !matches!(c.slot, Slot::Stance | Slot::Discourse | Slot::Scene) {
|
||||||
|
return Err(malformed(
|
||||||
|
&key,
|
||||||
|
"a condition reads slot Stance, Discourse or Scene",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if matches!(c.test, Test::Is(FeatValue::Grade(_))) {
|
||||||
|
return Err(malformed(&key, "Is names an option or a flag"));
|
||||||
|
}
|
||||||
|
out.extend(feature_refusals(
|
||||||
|
inventory,
|
||||||
|
rev,
|
||||||
|
&[c.as_value()],
|
||||||
|
c.slot,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if e.when.is_empty() && !inventory.has_revision(rev) {
|
||||||
|
out.extend(feature_refusals(inventory, rev, &[], Slot::Stance));
|
||||||
|
}
|
||||||
|
if !out.is_empty() {
|
||||||
|
return Err(StoreError::Invalid(out));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
//! The validator owns the inventory and form rules a packet must meet. Every
|
//! 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
|
//! path that admits a packet applies them, but `validate` only judges: it
|
||||||
//! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a
|
//! returns `Ok(())` or the refusals. [`LoweringInput`], the only thing a
|
||||||
//! lowerer sees, comes from `PacketStore::admit` alone, which builds it through
|
//! lowerer sees, comes from `PacketStore::admit` alone, which builds it from
|
||||||
//! the crate-private `admissible`. The stance is checked like every feature
|
//! the admitted packet and its corpus item through the crate-private
|
||||||
//! list, in slot `Stance`. The rules are V1..V8 and V10..V13: the shape of an id
|
//! `LoweringInput::of`. The stance is checked like every feature
|
||||||
//! or a grade is its type's parse, not a rule, so V9 is retired and not
|
//! list, in slot `Stance`. The rules are V1..V8, V10..V12 and V14..V18: the
|
||||||
//! renumbered. V13 guards the one other way features reach a lowering view:
|
//! shape of an id or a grade is its type's parse, not a rule, so V9 is retired
|
||||||
//! [`LoweringInput::with_scene`].
|
//! and not renumbered; V13 is retired too. [`LoweringInput::with_scene`] reads the
|
||||||
|
//! scene of the view's own item group from the speaker-state store, and
|
||||||
|
//! `feature_refusals` is the one feature check it and that store share.
|
||||||
//!
|
//!
|
||||||
//! `validate` owns form and capacity and makes no leak decision. A packet's one
|
//! `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
|
//! free text is its trace (`notes` and `rejected_readings`): `validate` does not
|
||||||
|
|
@ -26,25 +28,36 @@
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
use crate::corpus::CorpusItem;
|
||||||
use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind};
|
use crate::inventory::{Family, InvId, Inventory, InventoryEntry, Slot, ValueKind};
|
||||||
use crate::packet::{
|
use crate::packet::{
|
||||||
Constraints, Context, Discourse, Extension, FeatValue, FeatureValue, NodeId, Packet,
|
Constraints, Context, Discourse, EntityId, Extension, FeatValue, FeatureValue, NodeId, Packet,
|
||||||
PacketKind, Predication, Referent, Relation, SCHEMA_VERSION, Stance,
|
PacketKind, Predication, ProvenanceSource, Referent, Relation, SCHEMA_VERSION, Stance,
|
||||||
};
|
};
|
||||||
|
use crate::store::{CorpusStore, SpeakerStateStore, StoreError};
|
||||||
|
|
||||||
/// A broken rule, named.
|
/// A broken rule, named.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum Refusal {
|
pub enum Refusal {
|
||||||
/// V1: the packet's schema_version is not the one these types define.
|
/// V1: the packet's schema_version is not the one these types define.
|
||||||
V1SchemaVersion { found: u32 },
|
V1SchemaVersion {
|
||||||
|
found: u32,
|
||||||
|
},
|
||||||
/// V2: no referents, predications or relations.
|
/// V2: no referents, predications or relations.
|
||||||
V2PayloadRequired,
|
V2PayloadRequired,
|
||||||
/// V3: a node id repeats across referents and predications.
|
/// V3: a node id repeats across referents and predications.
|
||||||
V3DuplicateNode { id: NodeId },
|
V3DuplicateNode {
|
||||||
|
id: NodeId,
|
||||||
|
},
|
||||||
/// V4: a reference names no node.
|
/// V4: a reference names no node.
|
||||||
V4DanglingRef { id: NodeId, at: &'static str },
|
V4DanglingRef {
|
||||||
|
id: NodeId,
|
||||||
|
at: &'static str,
|
||||||
|
},
|
||||||
/// V5: an inventory id does not resolve at the packet's revision.
|
/// V5: an inventory id does not resolve at the packet's revision.
|
||||||
V5Unresolved { id: InvId },
|
V5Unresolved {
|
||||||
|
id: InvId,
|
||||||
|
},
|
||||||
/// V6: an inventory id is of the wrong family for its slot.
|
/// V6: an inventory id is of the wrong family for its slot.
|
||||||
V6WrongFamily {
|
V6WrongFamily {
|
||||||
id: InvId,
|
id: InvId,
|
||||||
|
|
@ -52,25 +65,67 @@ pub enum Refusal {
|
||||||
at: &'static str,
|
at: &'static str,
|
||||||
},
|
},
|
||||||
/// V7: a feature or extension value does not fit its value kind.
|
/// V7: a feature or extension value does not fit its value kind.
|
||||||
V7ValueKind { what: String },
|
V7ValueKind {
|
||||||
|
what: String,
|
||||||
|
},
|
||||||
/// V8: a feature is not declared for the slot it sits on.
|
/// V8: a feature is not declared for the slot it sits on.
|
||||||
V8NotApplicable { feature: InvId, slot: Slot },
|
V8NotApplicable {
|
||||||
|
feature: InvId,
|
||||||
|
slot: Slot,
|
||||||
|
},
|
||||||
/// V10: the packet's inventory revision has no record.
|
/// V10: the packet's inventory revision has no record.
|
||||||
V10UnknownRevision { rev: u32 },
|
V10UnknownRevision {
|
||||||
|
rev: u32,
|
||||||
|
},
|
||||||
/// V11: a feature id repeats in one slot's list (`slot` is `None` for an
|
/// V11: a feature id repeats in one slot's list (`slot` is `None` for an
|
||||||
/// extension id repeated in the packet).
|
/// extension id repeated in the packet).
|
||||||
V11DuplicateFeature { feature: InvId, slot: Option<Slot> },
|
V11DuplicateFeature {
|
||||||
|
feature: InvId,
|
||||||
|
slot: Option<Slot>,
|
||||||
|
},
|
||||||
/// V11: a node id repeats in `participants`, `audience` or `preserve`
|
/// V11: a node id repeats in `participants`, `audience` or `preserve`
|
||||||
/// (`at` names the list).
|
/// (`at` names the list).
|
||||||
V11DuplicateNodeRef { id: NodeId, at: &'static str },
|
V11DuplicateNodeRef {
|
||||||
|
id: NodeId,
|
||||||
|
at: &'static str,
|
||||||
|
},
|
||||||
/// V11: a role id repeats in one predication's roles.
|
/// V11: a role id repeats in one predication's roles.
|
||||||
V11DuplicateRole { role: InvId, predication: NodeId },
|
V11DuplicateRole {
|
||||||
|
role: InvId,
|
||||||
|
predication: NodeId,
|
||||||
|
},
|
||||||
/// V12: more referents, predications and relations than `Packet::MAX_NODES`.
|
/// V12: more referents, predications and relations than `Packet::MAX_NODES`.
|
||||||
V12TooManyNodes { count: usize, max: usize },
|
V12TooManyNodes {
|
||||||
/// V13: scene features were given for a packet with no addressee. They
|
count: usize,
|
||||||
/// state the speaker's relation to the addressee, so with none they
|
max: usize,
|
||||||
/// describe nobody.
|
},
|
||||||
V13SceneWithoutAddressee,
|
/// V14: an addressee consent that no addressee can have given, or that
|
||||||
|
/// names a node that is not a predication. It is what the addressee asked
|
||||||
|
/// for or agreed to, an act, so a referent cannot be its object.
|
||||||
|
V14ConsentWithoutAddressee,
|
||||||
|
V14ConsentNotAPredication {
|
||||||
|
id: NodeId,
|
||||||
|
},
|
||||||
|
/// V15: an addressee consent set by a source that only read wording.
|
||||||
|
/// A decomposition of text, or imported corpus text, sees "you asked her
|
||||||
|
/// to" as words; only authoring or game telemetry saw the request.
|
||||||
|
V15ConsentSource {
|
||||||
|
source: ProvenanceSource,
|
||||||
|
},
|
||||||
|
/// V16: `with_scene` found its group's scene not covering the item's speaker
|
||||||
|
/// or the speaker of some live item of the group.
|
||||||
|
V16SpeakerNotInScene,
|
||||||
|
/// V17: an addressee that names a predication. An act is not someone
|
||||||
|
/// spoken to, so the addressee names a referent.
|
||||||
|
V17AddresseeNotAReferent {
|
||||||
|
id: NodeId,
|
||||||
|
},
|
||||||
|
/// V18: a predication whose frame is the inventory's `request_intervention`,
|
||||||
|
/// with no `addressee_consent` naming it. It says what the addressee did, so
|
||||||
|
/// only the packet's own record may license it.
|
||||||
|
V18RequestWithoutConsent {
|
||||||
|
predication: NodeId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Refusal {
|
impl Refusal {
|
||||||
|
|
@ -89,14 +144,47 @@ impl Refusal {
|
||||||
| Refusal::V11DuplicateNodeRef { .. }
|
| Refusal::V11DuplicateNodeRef { .. }
|
||||||
| Refusal::V11DuplicateRole { .. } => "V11",
|
| Refusal::V11DuplicateRole { .. } => "V11",
|
||||||
Refusal::V12TooManyNodes { .. } => "V12",
|
Refusal::V12TooManyNodes { .. } => "V12",
|
||||||
Refusal::V13SceneWithoutAddressee => "V13",
|
Refusal::V14ConsentWithoutAddressee | Refusal::V14ConsentNotAPredication { .. } => {
|
||||||
|
"V14"
|
||||||
|
}
|
||||||
|
Refusal::V15ConsentSource { .. } => "V15",
|
||||||
|
Refusal::V16SpeakerNotInScene => "V16",
|
||||||
|
Refusal::V17AddresseeNotAReferent { .. } => "V17",
|
||||||
|
Refusal::V18RequestWithoutConsent { .. } => "V18",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Refusal {
|
impl fmt::Display for Refusal {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}: {self:?}", self.rule())
|
let what = match self {
|
||||||
|
Refusal::V1SchemaVersion { .. } => "unsupported schema version",
|
||||||
|
Refusal::V2PayloadRequired => "packet has no referents, predications or relations",
|
||||||
|
Refusal::V3DuplicateNode { .. } => "a node id repeats",
|
||||||
|
Refusal::V4DanglingRef { .. } => "a reference names no node",
|
||||||
|
Refusal::V5Unresolved { .. } => {
|
||||||
|
"an inventory id does not resolve at the packet's revision"
|
||||||
|
}
|
||||||
|
Refusal::V6WrongFamily { .. } => "an inventory id is of the wrong family for its slot",
|
||||||
|
Refusal::V7ValueKind { .. } => "a value does not fit its value kind",
|
||||||
|
Refusal::V8NotApplicable { .. } => "a feature is not declared for its slot",
|
||||||
|
Refusal::V10UnknownRevision { .. } => "the inventory revision has no record",
|
||||||
|
Refusal::V11DuplicateFeature { .. } => "a feature id repeats in one list",
|
||||||
|
Refusal::V11DuplicateNodeRef { .. } => "a node id repeats in one list",
|
||||||
|
Refusal::V11DuplicateRole { .. } => "a role id repeats in one predication",
|
||||||
|
Refusal::V12TooManyNodes { .. } => "too many nodes",
|
||||||
|
Refusal::V14ConsentWithoutAddressee => "consent given without an addressee",
|
||||||
|
Refusal::V14ConsentNotAPredication { .. } => "consent names no predication",
|
||||||
|
Refusal::V15ConsentSource { .. } => "consent set by a source that only read wording",
|
||||||
|
Refusal::V16SpeakerNotInScene => {
|
||||||
|
"the scene does not cover the speaker of every live item of its group"
|
||||||
|
}
|
||||||
|
Refusal::V17AddresseeNotAReferent { .. } => "the addressee names no referent",
|
||||||
|
Refusal::V18RequestWithoutConsent { .. } => {
|
||||||
|
"a request frame needs the addressee's consent naming it"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write!(f, "{}: {what}", self.rule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,28 +202,6 @@ impl fmt::Display for Refusal {
|
||||||
/// fn lower(p: &Packet, i: &Inventory) -> Result<LoweringInput, Vec<Refusal>> { weksa::validate(p, i) }
|
/// fn lower(p: &Packet, i: &Inventory) -> Result<LoweringInput, Vec<Refusal>> { weksa::validate(p, i) }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn validate(packet: &Packet, inventory: &Inventory) -> Result<(), Vec<Refusal>> {
|
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();
|
let mut out = Vec::new();
|
||||||
if packet.schema_version != SCHEMA_VERSION {
|
if packet.schema_version != SCHEMA_VERSION {
|
||||||
out.push(Refusal::V1SchemaVersion {
|
out.push(Refusal::V1SchemaVersion {
|
||||||
|
|
@ -155,7 +221,9 @@ pub(crate) fn admissible(
|
||||||
}
|
}
|
||||||
let nodes = check_nodes(packet, &mut out);
|
let nodes = check_nodes(packet, &mut out);
|
||||||
check_refs(packet, &nodes, &mut out);
|
check_refs(packet, &nodes, &mut out);
|
||||||
|
check_addressee(packet, &nodes, &mut out);
|
||||||
check_repeats(packet, &mut out);
|
check_repeats(packet, &mut out);
|
||||||
|
check_consent(packet, &nodes, &mut out);
|
||||||
if inventory.has_revision(packet.inventory_rev) {
|
if inventory.has_revision(packet.inventory_rev) {
|
||||||
Inventoried {
|
Inventoried {
|
||||||
inv: inventory,
|
inv: inventory,
|
||||||
|
|
@ -163,16 +231,13 @@ pub(crate) fn admissible(
|
||||||
out: &mut out,
|
out: &mut out,
|
||||||
}
|
}
|
||||||
.check(packet);
|
.check(packet);
|
||||||
|
check_requests(packet, inventory, &mut out);
|
||||||
} else {
|
} else {
|
||||||
out.push(Refusal::V10UnknownRevision {
|
out.push(Refusal::V10UnknownRevision {
|
||||||
rev: packet.inventory_rev,
|
rev: packet.inventory_rev,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if out.is_empty() {
|
if out.is_empty() { Ok(()) } else { Err(out) }
|
||||||
Ok(LoweringInput::of(packet))
|
|
||||||
} else {
|
|
||||||
Err(out)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_nodes(packet: &Packet, out: &mut Vec<Refusal>) -> BTreeSet<NodeId> {
|
fn check_nodes(packet: &Packet, out: &mut Vec<Refusal>) -> BTreeSet<NodeId> {
|
||||||
|
|
@ -208,6 +273,9 @@ fn check_refs(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>)
|
||||||
if let Some(a) = &packet.discourse.addressee {
|
if let Some(a) = &packet.discourse.addressee {
|
||||||
need(a, "addressee");
|
need(a, "addressee");
|
||||||
}
|
}
|
||||||
|
if let Some(c) = &packet.discourse.addressee_consent {
|
||||||
|
need(c, "addressee consent");
|
||||||
|
}
|
||||||
for (list, at) in node_lists(packet) {
|
for (list, at) in node_lists(packet) {
|
||||||
for id in list {
|
for id in list {
|
||||||
need(id, at);
|
need(id, at);
|
||||||
|
|
@ -215,6 +283,56 @@ fn check_refs(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V17: the addressee, when it names a node, names a referent. A dangling id
|
||||||
|
/// is V4's.
|
||||||
|
fn check_addressee(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>) {
|
||||||
|
if let Some(a) = &packet.discourse.addressee
|
||||||
|
&& nodes.contains(a)
|
||||||
|
&& !packet.referents.iter().any(|r| &r.id == a)
|
||||||
|
{
|
||||||
|
out.push(Refusal::V17AddresseeNotAReferent { id: a.clone() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// V14 and V15: the addressee's consent is a typed fact with one origin. A
|
||||||
|
/// dangling id is V4's, so V14 judges only a node that exists.
|
||||||
|
fn check_consent(packet: &Packet, nodes: &BTreeSet<NodeId>, out: &mut Vec<Refusal>) {
|
||||||
|
let Some(consent) = &packet.discourse.addressee_consent else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if packet.discourse.addressee.is_none() {
|
||||||
|
out.push(Refusal::V14ConsentWithoutAddressee);
|
||||||
|
}
|
||||||
|
if nodes.contains(consent) && !packet.predications.iter().any(|p| &p.id == consent) {
|
||||||
|
out.push(Refusal::V14ConsentNotAPredication {
|
||||||
|
id: consent.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match packet.provenance.source {
|
||||||
|
ProvenanceSource::Authored | ProvenanceSource::GameTelemetry => {}
|
||||||
|
source => out.push(Refusal::V15ConsentSource { source }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gloss of the inventory frame that reports an addressee's request.
|
||||||
|
const REQUEST_FRAME_GLOSS: &str = "request_intervention";
|
||||||
|
|
||||||
|
/// V18: a predication whose frame, resolved at the packet's revision, is the
|
||||||
|
/// request frame is named by `addressee_consent`. An unresolved frame is V5's.
|
||||||
|
fn check_requests(packet: &Packet, inventory: &Inventory, out: &mut Vec<Refusal>) {
|
||||||
|
for p in &packet.predications {
|
||||||
|
if inventory
|
||||||
|
.resolve(&p.frame, packet.inventory_rev)
|
||||||
|
.is_some_and(|e| e.gloss == REQUEST_FRAME_GLOSS)
|
||||||
|
&& packet.discourse.addressee_consent.as_ref() != Some(&p.id)
|
||||||
|
{
|
||||||
|
out.push(Refusal::V18RequestWithoutConsent {
|
||||||
|
predication: p.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn node_lists(packet: &Packet) -> [(&Vec<NodeId>, &'static str); 3] {
|
fn node_lists(packet: &Packet) -> [(&Vec<NodeId>, &'static str); 3] {
|
||||||
[
|
[
|
||||||
(&packet.context.participants, "participant"),
|
(&packet.context.participants, "participant"),
|
||||||
|
|
@ -382,6 +500,29 @@ impl<'a> Inventoried<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one feature check of the speaker-state documents and `with_scene`: V10
|
||||||
|
/// when `rev` has no record, else the rules the packet's own check applies to a
|
||||||
|
/// feature list in `slot` (V5 to V8, V11).
|
||||||
|
pub(crate) fn feature_refusals(
|
||||||
|
inventory: &Inventory,
|
||||||
|
rev: u32,
|
||||||
|
features: &[FeatureValue],
|
||||||
|
slot: Slot,
|
||||||
|
) -> Vec<Refusal> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if !inventory.has_revision(rev) {
|
||||||
|
out.push(Refusal::V10UnknownRevision { rev });
|
||||||
|
} else {
|
||||||
|
Inventoried {
|
||||||
|
inv: inventory,
|
||||||
|
rev,
|
||||||
|
out: &mut out,
|
||||||
|
}
|
||||||
|
.features(features, slot);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
|
fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
|
||||||
match (kind, value) {
|
match (kind, value) {
|
||||||
(Some(ValueKind::Grade), FeatValue::Grade(_)) => true,
|
(Some(ValueKind::Grade), FeatValue::Grade(_)) => true,
|
||||||
|
|
@ -391,6 +532,28 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `with_scene` found for a view: the scene revision `rev` it read, or why
|
||||||
|
/// it selected no relation. Each case is distinct so a missing annotation or a
|
||||||
|
/// store at the wrong path cannot look like a scene that held nothing.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SceneRead {
|
||||||
|
/// `with_scene` has not run.
|
||||||
|
Unread,
|
||||||
|
/// The store holds no scene for the item's group.
|
||||||
|
NoScene,
|
||||||
|
/// The scene was read and the packet names no addressee.
|
||||||
|
NoAddressee { rev: u32 },
|
||||||
|
/// The addressee referent is the speaker themself.
|
||||||
|
SelfAddressed { rev: u32 },
|
||||||
|
/// The scene holds no relation from the speaker to this addressee.
|
||||||
|
NoRelation { rev: u32 },
|
||||||
|
/// The relation was found; its features may be none.
|
||||||
|
Selected {
|
||||||
|
rev: u32,
|
||||||
|
features: Vec<FeatureValue>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// What the lowerer sees: a validated packet's meaning, with no packet id, no
|
/// 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
|
/// 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
|
/// fields are private and the constructor is crate-private, called only by
|
||||||
|
|
@ -447,16 +610,50 @@ fn fits(kind: Option<&ValueKind>, value: &FeatValue) -> bool {
|
||||||
/// fn read(l: weksa::LoweringInput) { let _ = l.scene; }
|
/// fn read(l: weksa::LoweringInput) { let _ = l.scene; }
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
|
/// Scene values come only from the store: the call names neither speaker nor
|
||||||
|
/// scene. The first block is the call with the store; the second hands it a
|
||||||
|
/// scene value and a speaker and fails for that alone.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// fn pick(
|
||||||
|
/// l: weksa::LoweringInput,
|
||||||
|
/// i: &weksa::Inventory,
|
||||||
|
/// s: &weksa::SpeakerStateStore,
|
||||||
|
/// c: &weksa::CorpusStore,
|
||||||
|
/// ) {
|
||||||
|
/// let _ = l.with_scene(i, s, c);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// fn pick(
|
||||||
|
/// l: weksa::LoweringInput,
|
||||||
|
/// i: &weksa::Inventory,
|
||||||
|
/// s: &weksa::speaker::Scene,
|
||||||
|
/// e: &weksa::EntityId,
|
||||||
|
/// ) {
|
||||||
|
/// let _ = l.with_scene(i, s, e);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```compile_fail
|
||||||
|
/// fn pick(l: weksa::LoweringInput, i: &weksa::Inventory) {
|
||||||
|
/// let _ = l.with_scene(i, vec![]);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
/// The constructor is private: a call from outside the crate compiles only if
|
/// 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
|
/// `LoweringInput::of` is `pub`. The first block is the same code without the
|
||||||
/// call, so the second fails for that call and nothing else.
|
/// call, so the second fails for that call and nothing else.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// fn build(p: &weksa::packet::Packet) { let _ = p; }
|
/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) { let _ = (p, item); }
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// ```compile_fail
|
/// ```compile_fail
|
||||||
/// fn build(p: &weksa::packet::Packet) { let _ = weksa::LoweringInput::of(p); }
|
/// fn build(p: &weksa::packet::Packet, item: &weksa::CorpusItem) {
|
||||||
|
/// let _ = weksa::LoweringInput::of(p, item);
|
||||||
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// A lowering view is never read from bytes: `Deserialize` would let a caller
|
/// A lowering view is never read from bytes: `Deserialize` would let a caller
|
||||||
|
|
@ -484,11 +681,16 @@ pub struct LoweringInput {
|
||||||
constraints: Constraints,
|
constraints: Constraints,
|
||||||
extensions: Vec<Extension>,
|
extensions: Vec<Extension>,
|
||||||
stance: Stance,
|
stance: Stance,
|
||||||
scene: Vec<FeatureValue>,
|
/// The item's speaker and group, as the corpus store holds them: the
|
||||||
|
/// only source of whose relation `with_scene` selects, and from which scene.
|
||||||
|
speaker: EntityId,
|
||||||
|
group: String,
|
||||||
|
scene: SceneRead,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoweringInput {
|
impl LoweringInput {
|
||||||
fn of(p: &Packet) -> Self {
|
/// The view of an admitted packet and the corpus item it was minted for.
|
||||||
|
pub(crate) fn of(p: &Packet, item: &CorpusItem) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inventory_rev: p.inventory_rev,
|
inventory_rev: p.inventory_rev,
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
|
|
@ -500,41 +702,73 @@ impl LoweringInput {
|
||||||
constraints: p.constraints.clone(),
|
constraints: p.constraints.clone(),
|
||||||
extensions: p.extensions.clone(),
|
extensions: p.extensions.clone(),
|
||||||
stance: p.stance.clone(),
|
stance: p.stance.clone(),
|
||||||
scene: Vec::new(),
|
speaker: item.speaker.clone(),
|
||||||
|
group: item.group.clone(),
|
||||||
|
scene: SceneRead::Unread,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds the speaker's relation to the addressee: feature values of slot
|
/// Reads the speaker's relation to the addressee, the only path for scene
|
||||||
/// `Scene`, checked like every feature list (V5 to V8, V11) at this view's
|
/// values into a view. The speaker is the item's speaker and the scene is
|
||||||
/// inventory revision. They state a relation, so the packet must name an
|
/// the latest the store holds for the item's group; the caller names
|
||||||
/// addressee (V13). Typed ids and grades only, never wording, so a scene
|
/// neither. A scene that does not list the item's speaker, or the speaker of
|
||||||
/// carries no text to the lowerer. The scene is not part of the packet: a
|
/// any live item of the group (the rule `put_scene` applies), is V16, with
|
||||||
/// scene document supplies it, and replacing it replaces the earlier one.
|
/// or without an addressee. What the read found is recorded on the view as a
|
||||||
|
/// [`SceneRead`], never an empty feature list standing for five cases:
|
||||||
|
/// no scene for the group, no addressee, an addressee who is the speaker,
|
||||||
|
/// no such relation, or the relation from the speaker to the addressee
|
||||||
|
/// referent's entity (to `Unnamed` when the referent has none), whose
|
||||||
|
/// features are checked like every feature list in slot `Scene` at this
|
||||||
|
/// view's inventory revision. A read replaces any earlier one. The English
|
||||||
|
/// lowerer ignores it.
|
||||||
pub fn with_scene(
|
pub fn with_scene(
|
||||||
mut self,
|
mut self,
|
||||||
inventory: &Inventory,
|
inventory: &Inventory,
|
||||||
scene: Vec<FeatureValue>,
|
state: &SpeakerStateStore,
|
||||||
) -> Result<Self, Vec<Refusal>> {
|
corpus: &CorpusStore,
|
||||||
let mut out = Vec::new();
|
) -> Result<Self, StoreError> {
|
||||||
if !inventory.has_revision(self.inventory_rev) {
|
self.scene = SceneRead::Unread;
|
||||||
out.push(Refusal::V10UnknownRevision {
|
let Some(scene) = state.scene(&self.group)? else {
|
||||||
rev: self.inventory_rev,
|
self.scene = SceneRead::NoScene;
|
||||||
});
|
return Ok(self);
|
||||||
} else {
|
};
|
||||||
Inventoried {
|
let rev = scene.n;
|
||||||
inv: inventory,
|
let live = corpus.all()?;
|
||||||
rev: self.inventory_rev,
|
let live = live
|
||||||
out: &mut out,
|
.iter()
|
||||||
}
|
.filter(|i| i.group == self.group && !i.withdrawn)
|
||||||
.features(&scene, Slot::Scene);
|
.map(|i| &i.speaker);
|
||||||
|
if scene
|
||||||
|
.uncovered(live.chain(std::iter::once(&self.speaker)))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(StoreError::Invalid(vec![Refusal::V16SpeakerNotInScene]));
|
||||||
}
|
}
|
||||||
if !scene.is_empty() && self.discourse.addressee.is_none() {
|
let Some(addressee) = self.discourse.addressee.as_ref() else {
|
||||||
out.push(Refusal::V13SceneWithoutAddressee);
|
self.scene = SceneRead::NoAddressee { rev };
|
||||||
|
return Ok(self);
|
||||||
|
};
|
||||||
|
let entity = self
|
||||||
|
.referents
|
||||||
|
.iter()
|
||||||
|
.find(|r| &r.id == addressee)
|
||||||
|
.and_then(|r| r.entity.as_ref());
|
||||||
|
if entity == Some(&self.speaker) {
|
||||||
|
self.scene = SceneRead::SelfAddressed { rev };
|
||||||
|
return Ok(self);
|
||||||
}
|
}
|
||||||
|
let Some(features) = scene.relation(&self.speaker, entity) else {
|
||||||
|
self.scene = SceneRead::NoRelation { rev };
|
||||||
|
return Ok(self);
|
||||||
|
};
|
||||||
|
let out = feature_refusals(inventory, self.inventory_rev, features, Slot::Scene);
|
||||||
if !out.is_empty() {
|
if !out.is_empty() {
|
||||||
return Err(out);
|
return Err(StoreError::Invalid(out));
|
||||||
}
|
}
|
||||||
self.scene = scene;
|
self.scene = SceneRead::Selected {
|
||||||
|
rev,
|
||||||
|
features: features.to_vec(),
|
||||||
|
};
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -568,8 +802,17 @@ impl LoweringInput {
|
||||||
pub fn stance(&self) -> &Stance {
|
pub fn stance(&self) -> &Stance {
|
||||||
&self.stance
|
&self.stance
|
||||||
}
|
}
|
||||||
/// The speaker's relation to the addressee; empty until `with_scene`.
|
/// The features of the scene relation from the speaker to the addressee.
|
||||||
|
/// Empty for every [`SceneRead`] but `Selected` (and for a selected
|
||||||
|
/// relation that has no features): ask [`scene_read`](Self::scene_read) which.
|
||||||
pub fn scene(&self) -> &[FeatureValue] {
|
pub fn scene(&self) -> &[FeatureValue] {
|
||||||
|
match &self.scene {
|
||||||
|
SceneRead::Selected { features, .. } => features,
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// What `with_scene` read; `Unread` until it ran.
|
||||||
|
pub fn scene_read(&self) -> &SceneRead {
|
||||||
&self.scene
|
&self.scene
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
crates/weksa/tests/checkpoint_door.rs
Normal file
107
crates/weksa/tests/checkpoint_door.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
//! A line-based tripwire on the Checkpoint door, not a proof. The real defence
|
||||||
|
//! is that `Checkpoint::for_test` is `cfg(test)`, pinned by the compile_fail
|
||||||
|
//! doctest on its current name in `split.rs`. This scan reads `split.rs` line by
|
||||||
|
//! line and catches the common single-line forms: a `pub fn` in `impl
|
||||||
|
//! Checkpoint` whose signature line says `-> Self` or `-> Checkpoint` without
|
||||||
|
//! `cfg(test)` above it, a trait impl for `Checkpoint`, a derive on it, a `pub`
|
||||||
|
//! field in its braces, and a `-> Checkpoint` line in any other `src` file. It
|
||||||
|
//! misses: signatures wrapped over several lines, `Option`, `Box`, `Result` or
|
||||||
|
//! tuple returns from `split.rs`, tuple structs with `pub` fields, and
|
||||||
|
//! constructors made by macros.
|
||||||
|
|
||||||
|
/// Why `src` lets code outside the lib make a `Checkpoint`, if it does.
|
||||||
|
fn door_leaks(split_rs: &str) -> Vec<String> {
|
||||||
|
let mut leaks = Vec::new();
|
||||||
|
let lines: Vec<&str> = split_rs.lines().collect();
|
||||||
|
let mut in_impl = false;
|
||||||
|
let mut in_struct = false;
|
||||||
|
for (n, line) in lines.iter().enumerate() {
|
||||||
|
let t = line.trim();
|
||||||
|
if t.starts_with("impl Checkpoint") {
|
||||||
|
in_impl = true;
|
||||||
|
} else if t.starts_with("impl") && t.contains("Checkpoint") {
|
||||||
|
leaks.push(format!("{}: trait impl: {t}", n + 1));
|
||||||
|
}
|
||||||
|
if t.starts_with("pub struct Checkpoint") {
|
||||||
|
let derived = lines[..n]
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take_while(|l| l.trim().starts_with("#[") || l.trim().starts_with("///"))
|
||||||
|
.any(|l| l.contains("derive"));
|
||||||
|
if derived {
|
||||||
|
leaks.push(format!("{}: derive on Checkpoint", n + 1));
|
||||||
|
}
|
||||||
|
in_struct = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_struct {
|
||||||
|
if t.starts_with('}') {
|
||||||
|
in_struct = false;
|
||||||
|
} else if t.starts_with("pub ") {
|
||||||
|
leaks.push(format!("{}: public field: {t}", n + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in_impl && t.starts_with("pub ") && t.contains("fn ") {
|
||||||
|
let mints = t.contains("-> Self") || t.contains("-> Checkpoint");
|
||||||
|
let test_only = lines[..n]
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take_while(|l| l.trim().starts_with("#["))
|
||||||
|
.any(|l| l.contains("cfg(test)"));
|
||||||
|
if mints && !test_only {
|
||||||
|
leaks.push(format!("{}: pub constructor: {t}", n + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leaks
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_public_item_mints_a_checkpoint() {
|
||||||
|
let src =
|
||||||
|
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/split.rs")).unwrap();
|
||||||
|
assert_eq!(door_leaks(&src), Vec::<String>::new());
|
||||||
|
// No other module returns one either.
|
||||||
|
for entry in std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src")).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.extension().is_some_and(|e| e == "rs") && !path.ends_with("split.rs") {
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
let minting = text
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.contains("-> Checkpoint") || l.contains("-> Result<Checkpoint"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(minting, 0, "{}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_scan_sees_each_way_to_open_the_door() {
|
||||||
|
let good = "impl Checkpoint {\n #[cfg(test)]\n pub(crate) fn for_test(r: u32) -> Self { x }\n}\n";
|
||||||
|
assert!(door_leaks(good).is_empty());
|
||||||
|
// Test constructor made public, under its own name or another.
|
||||||
|
let public = "impl Checkpoint {\n pub fn for_test(r: u32) -> Self { x }\n}\n";
|
||||||
|
assert_eq!(door_leaks(public).len(), 1);
|
||||||
|
let renamed = "impl Checkpoint {\n #[cfg(test)]\n #[allow(x)]\n pub fn new(r: u32) -> Checkpoint { x }\n}\n";
|
||||||
|
assert!(
|
||||||
|
door_leaks(renamed).is_empty(),
|
||||||
|
"cfg(test) above another attribute counts"
|
||||||
|
);
|
||||||
|
let renamed = "impl Checkpoint {\n pub fn new(r: u32) -> Checkpoint { x }\n}\n";
|
||||||
|
assert_eq!(door_leaks(renamed).len(), 1);
|
||||||
|
// cfg(test) on the previous item does not cover this one.
|
||||||
|
let stray =
|
||||||
|
"impl Checkpoint {\n #[cfg(test)]\n fn a() {}\n pub fn b() -> Self { x }\n}\n";
|
||||||
|
assert_eq!(door_leaks(stray).len(), 1);
|
||||||
|
assert_eq!(door_leaks("impl Default for Checkpoint {}\n").len(), 1);
|
||||||
|
assert_eq!(door_leaks("impl From<u32> for Checkpoint {}\n").len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
door_leaks("#[derive(Clone)]\npub struct Checkpoint {\n").len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
door_leaks("pub struct Checkpoint {\n pub round: u32,\n}\n").len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(door_leaks("pub struct Checkpoint {\n round: u32,\n}\n").is_empty());
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -34,7 +34,8 @@ pub const CONCEPT_LATE: &str = "concept:4";
|
||||||
pub const FRAME: &str = "frame:1";
|
pub const FRAME: &str = "frame:1";
|
||||||
/// An extension entry of value kind Grade, introduced at rev 2.
|
/// An extension entry of value kind Grade, introduced at rev 2.
|
||||||
pub const EXT: &str = "extension:1";
|
pub const EXT: &str = "extension:1";
|
||||||
/// A Grade feature introduced at rev 2 that sits in every slot.
|
/// A Grade feature introduced at rev 2 in every content slot (Discourse,
|
||||||
|
/// Referent, Predication, Relation).
|
||||||
pub const FEAT_ALL: &str = "feature:50";
|
pub const FEAT_ALL: &str = "feature:50";
|
||||||
|
|
||||||
pub const PACKET_ID: &str = "packet:0123456789abcdef";
|
pub const PACKET_ID: &str = "packet:0123456789abcdef";
|
||||||
|
|
@ -93,6 +94,7 @@ pub fn ent(slug: &str, language: &str, forms: &[&str]) -> Entity {
|
||||||
forms: vec![LanguageForms {
|
forms: vec![LanguageForms {
|
||||||
language: language.into(),
|
language: language.into(),
|
||||||
forms: forms.iter().map(|f| f.to_string()).collect(),
|
forms: forms.iter().map(|f| f.to_string()).collect(),
|
||||||
|
pronunciation: None,
|
||||||
}],
|
}],
|
||||||
origin: EntityOrigin::Cast {
|
origin: EntityOrigin::Cast {
|
||||||
persona: slug.into(),
|
persona: slug.into(),
|
||||||
|
|
@ -112,6 +114,90 @@ pub fn entities_in(dir: &std::path::Path) -> EntityStore {
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An empty ontology store at `<dir>/ontology.cc`, made if absent.
|
||||||
|
pub fn ontology_in(dir: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let path = dir.join("ontology.cc");
|
||||||
|
if !path.exists() {
|
||||||
|
std::fs::write(&path, [0x90]).unwrap();
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new inventory store at `path` holding the Dao inventory as it stood at
|
||||||
|
/// rev 3 (seed, proof, the Dao revision): the loop tests' fixed head, whose
|
||||||
|
/// round and candidate revisions they pin. The committed
|
||||||
|
/// `data/daodejing/inventory.cc` is rev 4 and is read by the corpus tests.
|
||||||
|
pub fn dao_rev3_at(path: &std::path::Path) {
|
||||||
|
let mut store = weksa::InventoryStore::open(path).unwrap();
|
||||||
|
for (rev, entries) in [
|
||||||
|
weksa::inventory::seed(),
|
||||||
|
weksa::inventory::proof_revision(),
|
||||||
|
weksa::daodejing::revision(),
|
||||||
|
] {
|
||||||
|
store.admit_revision(rev, entries).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty speaker-state store beside `ontology` (same directory), made if absent.
|
||||||
|
pub fn speaker_state_beside(ontology: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let path = ontology.with_file_name("speaker-state.cc");
|
||||||
|
if !path.exists() {
|
||||||
|
std::fs::write(&path, [0x90]).unwrap();
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty speaker-state store at `<dir>/speaker-state.cc`, made if absent.
|
||||||
|
pub fn speaker_state_in(dir: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
speaker_state_beside(&dir.join("ontology.cc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run over the stores `world()` and `corpus()` keep in `dir`: `c.cc`,
|
||||||
|
/// `i.cc` and `entities.cc`. Read at the call, so create it after the items.
|
||||||
|
/// The run's en-US panel needs a live Train and a live Validation item: a
|
||||||
|
/// corpus short of one gets `item:panel:<n>` added, and no other item.
|
||||||
|
pub fn run_over(dir: &std::path::Path) -> weksa::run::RunStores {
|
||||||
|
for name in ["c.cc", "i.cc", "entities.cc"] {
|
||||||
|
if !dir.join(name).exists() {
|
||||||
|
std::fs::write(dir.join(name), [0x90]).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entities = entities_in(dir);
|
||||||
|
ontology_in(dir);
|
||||||
|
let mut corpus = CorpusStore::open(&dir.join("c.cc")).unwrap();
|
||||||
|
for (n, split) in [(1, weksa::Split::Train), (2, weksa::Split::Validation)] {
|
||||||
|
let held = corpus
|
||||||
|
.all()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|i| SplitScheme::V1.live(i) == Some(split));
|
||||||
|
if !held {
|
||||||
|
let group = group_in_bucket(if n == 1 { 10 } else { 50 });
|
||||||
|
let key = format!("item:panel:{n}");
|
||||||
|
let text = format!("Panel fixture line {n}.");
|
||||||
|
corpus.add(item_in(&key, &text, &group, cast()), &entities).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(corpus);
|
||||||
|
weksa::run::RunStores::create(
|
||||||
|
&dir.join("run"),
|
||||||
|
"t",
|
||||||
|
weksa::run::RunPaths {
|
||||||
|
corpus: &dir.join("c.cc"),
|
||||||
|
inventory: &dir.join("i.cc"),
|
||||||
|
entities: &dir.join("entities.cc"),
|
||||||
|
ontology: &dir.join("ontology.cc"),
|
||||||
|
speaker_state: &speaker_state_beside(&dir.join("ontology.cc")),
|
||||||
|
},
|
||||||
|
"fake-model",
|
||||||
|
"bag",
|
||||||
|
weksa::run::LowererReach::ToolLessRelay,
|
||||||
|
weksa::corpus::ModelFamily::Unrecorded,
|
||||||
|
&["en-US".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn item_in(key: &str, text: &str, group: &str, source: Source) -> CorpusItem {
|
pub fn item_in(key: &str, text: &str, group: &str, source: Source) -> CorpusItem {
|
||||||
CorpusItem {
|
CorpusItem {
|
||||||
id: key.into(),
|
id: key.into(),
|
||||||
|
|
@ -119,6 +205,7 @@ pub fn item_in(key: &str, text: &str, group: &str, source: Source) -> CorpusItem
|
||||||
language: "en-US".into(),
|
language: "en-US".into(),
|
||||||
speaker: eid("nibu"),
|
speaker: eid("nibu"),
|
||||||
names: vec![],
|
names: vec![],
|
||||||
|
names_by: None,
|
||||||
group: group.into(),
|
group: group.into(),
|
||||||
prior_turns: vec![],
|
prior_turns: vec![],
|
||||||
source,
|
source,
|
||||||
|
|
@ -185,6 +272,7 @@ pub fn world() -> World {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
|
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
|
||||||
let entities = entities_in(dir.path());
|
let entities = entities_in(dir.path());
|
||||||
|
ontology_in(dir.path());
|
||||||
let train = group_in_bucket(10);
|
let train = group_in_bucket(10);
|
||||||
let mut first = item_in("item:nibu:1", "Nibu says mind the hatch.", &train, cast());
|
let mut first = item_in("item:nibu:1", "Nibu says mind the hatch.", &train, cast());
|
||||||
first.names = vec![NameSpan {
|
first.names = vec![NameSpan {
|
||||||
|
|
@ -193,6 +281,10 @@ pub fn world() -> World {
|
||||||
end: 4,
|
end: 4,
|
||||||
}];
|
}];
|
||||||
corpus.add(first, &entities).unwrap();
|
corpus.add(first, &entities).unwrap();
|
||||||
|
// A run's panel needs a live Validation item as well as a Train one.
|
||||||
|
let validation = group_in_bucket(50);
|
||||||
|
let second = item_in("item:panel:1", "Nibu says watch the ladder.", &validation, cast());
|
||||||
|
corpus.add(second, &entities).unwrap();
|
||||||
let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
|
let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
|
||||||
let (rev1, entries) = seed();
|
let (rev1, entries) = seed();
|
||||||
inventory.admit_revision(rev1, entries).unwrap();
|
inventory.admit_revision(rev1, entries).unwrap();
|
||||||
|
|
@ -217,7 +309,6 @@ pub fn world() -> World {
|
||||||
Slot::Referent,
|
Slot::Referent,
|
||||||
Slot::Predication,
|
Slot::Predication,
|
||||||
Slot::Relation,
|
Slot::Relation,
|
||||||
Slot::Stance,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -265,6 +356,7 @@ pub fn good() -> Packet {
|
||||||
discourse: Discourse {
|
discourse: Discourse {
|
||||||
speech_act: Some(iv(WARN)),
|
speech_act: Some(iv(WARN)),
|
||||||
addressee: Some(nd("n1")),
|
addressee: Some(nd("n1")),
|
||||||
|
addressee_consent: None,
|
||||||
features: vec![FeatureValue {
|
features: vec![FeatureValue {
|
||||||
feature: iv(HEDGE),
|
feature: iv(HEDGE),
|
||||||
value: FeatValue::Grade(grade(300)),
|
value: FeatValue::Grade(grade(300)),
|
||||||
|
|
@ -329,3 +421,25 @@ pub fn good() -> Packet {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A deterministic bag-of-letters embedder, so cosine differs with the text.
|
||||||
|
pub struct Bag;
|
||||||
|
|
||||||
|
impl weksa::Embedder for Bag {
|
||||||
|
fn model(&self) -> &str {
|
||||||
|
"bag"
|
||||||
|
}
|
||||||
|
fn embed(&self, texts: &[&str]) -> anyhow::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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1225
crates/weksa/tests/context.rs
Normal file
1225
crates/weksa/tests/context.rs
Normal file
File diff suppressed because it is too large
Load diff
56
crates/weksa/tests/daodejing.rs
Normal file
56
crates/weksa/tests/daodejing.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//! The Dao De Jing test bed (`data/daodejing`): its Legge items carry full
|
||||||
|
//! public-domain provenance, and its packets are admitted
|
||||||
|
//! (`docs/targets/dao-de-jing-test-bed.md`).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use weksa::daodejing::packets;
|
||||||
|
use weksa::{CorpusStore, EntityStore, InventoryStore, PacketStore, Source};
|
||||||
|
|
||||||
|
fn root() -> std::path::PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/daodejing")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_legge_item_names_licence_jurisdiction_translator_and_edition_year() {
|
||||||
|
let items = CorpusStore::open(&root().join("corpus.cc"))
|
||||||
|
.unwrap()
|
||||||
|
.all()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(items.len(), 5);
|
||||||
|
for item in items {
|
||||||
|
let Source::PublicDomain {
|
||||||
|
licence,
|
||||||
|
jurisdiction,
|
||||||
|
translator,
|
||||||
|
edition_year,
|
||||||
|
..
|
||||||
|
} = item.source
|
||||||
|
else {
|
||||||
|
panic!("{} is not a public-domain item", item.id);
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!licence.is_empty() && !jurisdiction.is_empty(),
|
||||||
|
"{}",
|
||||||
|
item.id
|
||||||
|
);
|
||||||
|
assert_eq!((translator.as_str(), edition_year), ("James Legge", 1891));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_slice_packets_are_admitted_and_the_committed_store_holds_them() {
|
||||||
|
let inventory = InventoryStore::open(&root().join("inventory.cc")).unwrap();
|
||||||
|
let corpus = CorpusStore::open(&root().join("corpus.cc")).unwrap();
|
||||||
|
let entities = EntityStore::open(&root().join("entity.cc")).unwrap();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut store = PacketStore::open(&dir.path().join("p.cc")).unwrap();
|
||||||
|
for p in packets() {
|
||||||
|
store.admit(p, &inventory, &corpus, &entities).unwrap();
|
||||||
|
}
|
||||||
|
// The committed packet store holds exactly what the module builds.
|
||||||
|
let stored = PacketStore::open(&root().join("packets.cc")).unwrap();
|
||||||
|
for p in packets() {
|
||||||
|
assert_eq!(stored.get(&p.id.to_string()).unwrap(), Some(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
121
crates/weksa/tests/display.rs
Normal file
121
crates/weksa/tests/display.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
//! Error text is a fixed message naming the rule or variant. The caller's
|
||||||
|
//! identifiers (entity, node and inventory ids, IPA text, value strings) stay
|
||||||
|
//! on the typed value and never reach Display.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use weksa::packet::{EntityId, NodeId, ProvenanceSource};
|
||||||
|
use weksa::{Family, InvId, LowerError, Refusal, Slot};
|
||||||
|
|
||||||
|
const SENTINEL: &str = "sentinelzz";
|
||||||
|
|
||||||
|
fn inv() -> InvId {
|
||||||
|
InvId::parse("concept:4242").unwrap()
|
||||||
|
}
|
||||||
|
fn node() -> NodeId {
|
||||||
|
NodeId::parse("n4242").unwrap()
|
||||||
|
}
|
||||||
|
fn entity() -> EntityId {
|
||||||
|
EntityId::parse(&format!("entity:{SENTINEL}")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_clean_and_distinct<T: std::fmt::Display + std::fmt::Debug>(all: Vec<T>) {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for e in all {
|
||||||
|
let text = e.to_string();
|
||||||
|
assert!(!text.is_empty(), "{e:?}");
|
||||||
|
for leak in [SENTINEL, "4242", "{", "}"] {
|
||||||
|
assert!(!text.contains(leak), "{e:?} leaks {leak:?}: {text}");
|
||||||
|
}
|
||||||
|
assert!(seen.insert(text.clone()), "{e:?} repeats {text:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lower_error_display_names_the_variant_and_no_identifier() {
|
||||||
|
assert_clean_and_distinct(vec![
|
||||||
|
LowerError::UnknownEntity { entity: entity() },
|
||||||
|
LowerError::NoForm { entity: entity() },
|
||||||
|
LowerError::NoPronunciation { entity: entity() },
|
||||||
|
LowerError::NoEntityReferent,
|
||||||
|
LowerError::UnknownPhone {
|
||||||
|
entity: entity(),
|
||||||
|
at: 4242,
|
||||||
|
text: SENTINEL.into(),
|
||||||
|
},
|
||||||
|
LowerError::DanglingStress {
|
||||||
|
entity: entity(),
|
||||||
|
at: 4242,
|
||||||
|
},
|
||||||
|
LowerError::EntityRead {
|
||||||
|
entity: entity(),
|
||||||
|
why: SENTINEL.into(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
LowerError::NoPronunciation { entity: entity() }.to_string(),
|
||||||
|
"entity has no ruled pronunciation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refusal_display_names_the_rule_and_no_identifier() {
|
||||||
|
let all = vec![
|
||||||
|
Refusal::V1SchemaVersion { found: 4242 },
|
||||||
|
Refusal::V2PayloadRequired,
|
||||||
|
Refusal::V3DuplicateNode { id: node() },
|
||||||
|
Refusal::V4DanglingRef {
|
||||||
|
id: node(),
|
||||||
|
at: SENTINEL,
|
||||||
|
},
|
||||||
|
Refusal::V5Unresolved { id: inv() },
|
||||||
|
Refusal::V6WrongFamily {
|
||||||
|
id: inv(),
|
||||||
|
expected: Family::Role,
|
||||||
|
at: SENTINEL,
|
||||||
|
},
|
||||||
|
Refusal::V7ValueKind {
|
||||||
|
what: SENTINEL.into(),
|
||||||
|
},
|
||||||
|
Refusal::V8NotApplicable {
|
||||||
|
feature: inv(),
|
||||||
|
slot: Slot::Scene,
|
||||||
|
},
|
||||||
|
Refusal::V10UnknownRevision { rev: 4242 },
|
||||||
|
Refusal::V11DuplicateFeature {
|
||||||
|
feature: inv(),
|
||||||
|
slot: Some(Slot::Scene),
|
||||||
|
},
|
||||||
|
Refusal::V11DuplicateNodeRef {
|
||||||
|
id: node(),
|
||||||
|
at: SENTINEL,
|
||||||
|
},
|
||||||
|
Refusal::V11DuplicateRole {
|
||||||
|
role: inv(),
|
||||||
|
predication: node(),
|
||||||
|
},
|
||||||
|
Refusal::V12TooManyNodes {
|
||||||
|
count: 4242,
|
||||||
|
max: 4242,
|
||||||
|
},
|
||||||
|
Refusal::V14ConsentWithoutAddressee,
|
||||||
|
Refusal::V14ConsentNotAPredication { id: node() },
|
||||||
|
Refusal::V15ConsentSource {
|
||||||
|
source: ProvenanceSource::ImportedCorpus,
|
||||||
|
},
|
||||||
|
Refusal::V16SpeakerNotInScene,
|
||||||
|
Refusal::V17AddresseeNotAReferent { id: node() },
|
||||||
|
Refusal::V18RequestWithoutConsent { predication: node() },
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
Refusal::V16SpeakerNotInScene.to_string(),
|
||||||
|
"V16: the scene does not cover the speaker of every live item of its group"
|
||||||
|
);
|
||||||
|
for r in &all {
|
||||||
|
assert!(r.to_string().starts_with(r.rule()), "{r:?}");
|
||||||
|
}
|
||||||
|
assert_clean_and_distinct(all);
|
||||||
|
assert_eq!(
|
||||||
|
Refusal::V14ConsentNotAPredication { id: node() }.to_string(),
|
||||||
|
"V14: consent names no predication"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,7 @@ fn entity_forms_only_grow() {
|
||||||
more.forms.push(weksa::LanguageForms {
|
more.forms.push(weksa::LanguageForms {
|
||||||
language: "tr".into(),
|
language: "tr".into(),
|
||||||
forms: vec!["Kay".into()],
|
forms: vec!["Kay".into()],
|
||||||
|
pronunciation: None,
|
||||||
});
|
});
|
||||||
assert_eq!(store.put(more.clone()).unwrap(), AddOutcome::Added);
|
assert_eq!(store.put(more.clone()).unwrap(), AddOutcome::Added);
|
||||||
// The store read back from disk holds the growth.
|
// The store read back from disk holds the growth.
|
||||||
|
|
@ -131,6 +132,7 @@ fn entity_forms_only_grow() {
|
||||||
let lf = |language: &str, forms: &[&str]| weksa::LanguageForms {
|
let lf = |language: &str, forms: &[&str]| weksa::LanguageForms {
|
||||||
language: language.into(),
|
language: language.into(),
|
||||||
forms: forms.iter().map(|f| f.to_string()).collect(),
|
forms: forms.iter().map(|f| f.to_string()).collect(),
|
||||||
|
pronunciation: None,
|
||||||
};
|
};
|
||||||
for bad in [
|
for bad in [
|
||||||
vec![],
|
vec![],
|
||||||
|
|
@ -395,7 +397,7 @@ fn packet_entities_resolve() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn packet_schema_v2() {
|
fn packet_schema_v2() {
|
||||||
assert_eq!(SCHEMA_VERSION, 4);
|
assert_eq!(SCHEMA_VERSION, 5);
|
||||||
let mut p = good();
|
let mut p = good();
|
||||||
p.schema_version = 2;
|
p.schema_version = 2;
|
||||||
let Err(refused) = validate(&p, &inventory()) else {
|
let Err(refused) = validate(&p, &inventory()) else {
|
||||||
|
|
@ -427,3 +429,169 @@ fn packet_schema_v2() {
|
||||||
"array"
|
"array"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pronunciation_is_added_once_and_then_frozen() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||||
|
let kay = ent("kay", "en-US", &["Kay"]);
|
||||||
|
store.put(kay.clone()).unwrap();
|
||||||
|
let said = |ipa: &str, rejected: &[&str]| {
|
||||||
|
let mut e = kay.clone();
|
||||||
|
e.forms[0].pronunciation = Some(weksa::Pronunciation {
|
||||||
|
ipa: ipa.into(),
|
||||||
|
ruled_by: "Kay".into(),
|
||||||
|
date: "2026-11-07".into(),
|
||||||
|
source: "test".into(),
|
||||||
|
rejected: rejected.iter().map(|r| r.to_string()).collect(),
|
||||||
|
});
|
||||||
|
e
|
||||||
|
};
|
||||||
|
// An unruled entity gains a ruling.
|
||||||
|
let ruled = said("ˈkeɪ", &["ˈkaɪ"]);
|
||||||
|
assert_eq!(store.put(ruled.clone()).unwrap(), AddOutcome::Added);
|
||||||
|
let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||||
|
assert_eq!(store.get(&eid("kay")).unwrap(), Some(ruled.clone()));
|
||||||
|
// A ruling is neither changed nor dropped; a changed ruling is a new decision.
|
||||||
|
for e in [said("ˈkaɪ", &[]), said("ˈkeɪ", &[]), kay.clone()] {
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.put(e)),
|
||||||
|
StoreRefusal::EntityNotAppendOnly { id: eid("kay") }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(store.get(&eid("kay")).unwrap(), Some(ruled));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_pronunciation_is_refused() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||||
|
let with = |edit: fn(&mut weksa::Pronunciation)| {
|
||||||
|
let mut e = ent("kay", "en-US", &["Kay"]);
|
||||||
|
let mut p = weksa::Pronunciation {
|
||||||
|
ipa: "ˈkeɪ".into(),
|
||||||
|
ruled_by: "Kay".into(),
|
||||||
|
date: "2026-11-07".into(),
|
||||||
|
source: "test".into(),
|
||||||
|
rejected: vec!["ˈkaɪ".into()],
|
||||||
|
};
|
||||||
|
edit(&mut p);
|
||||||
|
e.forms[0].pronunciation = Some(p);
|
||||||
|
e
|
||||||
|
};
|
||||||
|
let edits: [fn(&mut weksa::Pronunciation); 9] = [
|
||||||
|
|p| p.ipa = "".into(),
|
||||||
|
|p| p.ipa = "/ˈkeɪ/".into(),
|
||||||
|
|p| p.ipa = "[ˈkeɪ]".into(),
|
||||||
|
|p| p.ipa = " ˈkeɪ".into(),
|
||||||
|
|p| p.ruled_by = "".into(),
|
||||||
|
|p| p.date = "07-11-2026".into(),
|
||||||
|
|p| p.date = "2026-13-07".into(),
|
||||||
|
|p| p.rejected = vec!["ˈkeɪ".into()],
|
||||||
|
|p| p.rejected = vec!["ˈkaɪ".into(), "ˈkaɪ".into()],
|
||||||
|
];
|
||||||
|
for edit in edits {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.put(with(edit)),
|
||||||
|
Err(StoreError::Refused(StoreRefusal::Malformed { .. }))
|
||||||
|
),
|
||||||
|
"{:?}",
|
||||||
|
with(edit)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(store.get(&eid("kay")).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANTERN: &str = r#"{"slug":"lantern-wager","language":"en-US","admitted_on":"2026-11-13",
|
||||||
|
"source":{"PublicDomain":{"work":"The Lantern Wager","author":"A. Invented","edition_url":"https://example.invalid/lantern","licence":"public domain","jurisdiction":"US","translator":"none","edition_year":1890}},
|
||||||
|
"cast":[{"entity":"entity:orsa","forms":["Orsa"]},{"entity":"entity:tamsin","forms":["Tamsin","Tam"]}],
|
||||||
|
"lines":[{"group":"s1","speaker":"entity:orsa","text":"Tamsin, bring the lantern."},
|
||||||
|
{"group":"s1","speaker":"entity:tamsin","text":"Tamsin's lantern, said Orsa."},
|
||||||
|
{"group":"s2","speaker":"entity:orsa","text":"Quiet, both of you."}]}"#;
|
||||||
|
|
||||||
|
fn marked(line: u32, text: &str) -> weksa::encode::MarkedLine {
|
||||||
|
weksa::encode::MarkedLine { line, marked: text.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_imported_work_passes_the_store_doors() {
|
||||||
|
use weksa::Annotator;
|
||||||
|
use weksa::corpus::ModelFamily;
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let mut entities = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||||
|
let mut corpus = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
|
let model = Annotator::Model { family: ModelFamily::Anthropic, generator: "g".into() };
|
||||||
|
let work = || weksa::encode::WorkDraft::parse(LANTERN).unwrap();
|
||||||
|
let (cast, items) = work()
|
||||||
|
.into_import(&[marked(1, "[[entity:tamsin|Tamsin]], bring the lantern.")], &model)
|
||||||
|
.unwrap();
|
||||||
|
for e in cast {
|
||||||
|
assert_eq!(entities.put(e).unwrap(), AddOutcome::Added);
|
||||||
|
}
|
||||||
|
for i in items.clone() {
|
||||||
|
assert_eq!(corpus.add(i, &entities).unwrap(), AddOutcome::Added);
|
||||||
|
}
|
||||||
|
drop(corpus);
|
||||||
|
let mut corpus = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
|
let stored = corpus.get("item:lantern-wager:1").unwrap().unwrap();
|
||||||
|
assert_eq!(stored.names_by, Some(model.clone()));
|
||||||
|
assert_eq!(stored.names.len(), 1);
|
||||||
|
assert_eq!(corpus.add(items[0].clone(), &entities).unwrap(), AddOutcome::Unchanged);
|
||||||
|
|
||||||
|
// A form the entity does not list is the store's refusal, not the importer's.
|
||||||
|
let (_, bad) = work()
|
||||||
|
.into_import(&[marked(2, "[[entity:tamsin|Tamsin's]] lantern, said Orsa.")], &model)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
refusal(corpus.add(bad[0].clone(), &entities)),
|
||||||
|
StoreRefusal::NameSpanNotAForm { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
// An operator run over another line labels its own spans.
|
||||||
|
let (_, op) = work()
|
||||||
|
.into_import(&[marked(3, "Quiet, both of you.")], &Annotator::Operator)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(corpus.add(op[0].clone(), &entities).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(
|
||||||
|
corpus.get("item:lantern-wager:3").unwrap().unwrap().names_by,
|
||||||
|
Some(Annotator::Operator)
|
||||||
|
);
|
||||||
|
// The first run's line keeps its label.
|
||||||
|
assert_eq!(corpus.get("item:lantern-wager:1").unwrap().unwrap().names_by, Some(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_items_read_with_no_names_label() {
|
||||||
|
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
|
||||||
|
for sub in ["daodejing", "original"] {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let copy = d.path().join("c.cc");
|
||||||
|
std::fs::copy(data.join(sub).join("corpus.cc"), ©).unwrap();
|
||||||
|
let items = CorpusStore::open(©).unwrap().all().unwrap();
|
||||||
|
assert!(!items.is_empty(), "{sub}");
|
||||||
|
assert!(items.iter().all(|i| i.names_by.is_none()), "{sub}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_names_by_generator_is_refused_by_the_corpus_door() {
|
||||||
|
use weksa::Annotator;
|
||||||
|
use weksa::corpus::ModelFamily;
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let mut entities = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||||
|
let mut corpus = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
|
let blank = Annotator::Model { family: ModelFamily::Anthropic, generator: " ".into() };
|
||||||
|
let (cast, items) = weksa::encode::WorkDraft::parse(LANTERN)
|
||||||
|
.unwrap()
|
||||||
|
.into_import(&[marked(3, "Quiet, both of you.")], &blank)
|
||||||
|
.unwrap();
|
||||||
|
for e in cast {
|
||||||
|
entities.put(e).unwrap();
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
refusal(corpus.add(items[0].clone(), &entities)),
|
||||||
|
StoreRefusal::Malformed { .. }
|
||||||
|
));
|
||||||
|
assert!(corpus.all().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ fn entity_capacity_is_bounded() {
|
||||||
let lf = |language: &str, forms: Vec<String>| LanguageForms {
|
let lf = |language: &str, forms: Vec<String>| LanguageForms {
|
||||||
language: language.into(),
|
language: language.into(),
|
||||||
forms,
|
forms,
|
||||||
|
pronunciation: None,
|
||||||
};
|
};
|
||||||
let one = |f: &str| vec![f.to_string()];
|
let one = |f: &str| vec![f.to_string()];
|
||||||
let make = |forms: Vec<LanguageForms>| Entity {
|
let make = |forms: Vec<LanguageForms>| Entity {
|
||||||
|
|
@ -331,6 +332,7 @@ fn entities_are_append_only_and_replay_needs_them() {
|
||||||
LanguageForms {
|
LanguageForms {
|
||||||
language: "fr".into(),
|
language: "fr".into(),
|
||||||
forms: vec!["Z\u{e8}de".into()],
|
forms: vec!["Z\u{e8}de".into()],
|
||||||
|
pronunciation: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(refusal(w.entities.put(prepend)), not_append);
|
assert_eq!(refusal(w.entities.put(prepend)), not_append);
|
||||||
|
|
@ -344,6 +346,7 @@ fn entities_are_append_only_and_replay_needs_them() {
|
||||||
duplicate.forms.push(LanguageForms {
|
duplicate.forms.push(LanguageForms {
|
||||||
language: "en-US".into(),
|
language: "en-US".into(),
|
||||||
forms: vec!["Zeddy".into()],
|
forms: vec!["Zeddy".into()],
|
||||||
|
pronunciation: None,
|
||||||
});
|
});
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
refusal(w.entities.put(duplicate)),
|
refusal(w.entities.put(duplicate)),
|
||||||
|
|
@ -371,6 +374,7 @@ fn with_form(slug: &str, form: &str) -> Entity {
|
||||||
forms: vec![LanguageForms {
|
forms: vec![LanguageForms {
|
||||||
language: "en-US".into(),
|
language: "en-US".into(),
|
||||||
forms: vec![form.into()],
|
forms: vec![form.into()],
|
||||||
|
pronunciation: None,
|
||||||
}],
|
}],
|
||||||
origin: EntityOrigin::Live {
|
origin: EntityOrigin::Live {
|
||||||
persona: "p".into(),
|
persona: "p".into(),
|
||||||
|
|
|
||||||
|
|
@ -51,15 +51,12 @@ fn forms_refuse_surface_text() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_refusal_names_the_form_and_the_value_and_a_form_reads_back_as_written() {
|
fn a_refusal_names_the_form_and_keeps_the_value_out_of_its_text() {
|
||||||
let e = NodeId::parse("n0").unwrap_err();
|
let e = NodeId::parse("n0").unwrap_err();
|
||||||
assert_eq!(e.form, "NodeId");
|
assert_eq!(e.form, "NodeId");
|
||||||
assert_eq!(e.got, "n0");
|
assert_eq!(e.got, "n0");
|
||||||
assert_eq!(e.to_string(), "\"n0\" is not a NodeId");
|
assert_eq!(e.to_string(), "not a NodeId");
|
||||||
assert_eq!(
|
assert_eq!(Grade::new(1001).unwrap_err().to_string(), "not a Grade");
|
||||||
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!(EncoderId::parse(ENCODER_ID).unwrap().as_str(), ENCODER_ID);
|
||||||
assert_eq!(PacketId::parse(PACKET_ID).unwrap().as_str(), PACKET_ID);
|
assert_eq!(PacketId::parse(PACKET_ID).unwrap().as_str(), PACKET_ID);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -150,3 +147,23 @@ fn grade_bound_is_the_type() {
|
||||||
assert_eq!(grade["minimum"], 0, "{grade}");
|
assert_eq!(grade["minimum"], 0, "{grade}");
|
||||||
assert_eq!(schema["$defs"]["NodeId"]["pattern"], "^n[1-9][0-9]{0,3}$");
|
assert_eq!(schema["$defs"]["NodeId"]["pattern"], "^n[1-9][0-9]{0,3}$");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calendar_dates_follow_the_gregorian_rule_and_ascii_digits_only() {
|
||||||
|
use weksa::form::is_calendar_date;
|
||||||
|
for ok in ["2024-02-29", "2000-02-29", "2026-12-31"] {
|
||||||
|
assert!(is_calendar_date(ok), "{ok}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"1900-02-29",
|
||||||
|
"2023-02-29",
|
||||||
|
"2026-04-31",
|
||||||
|
"2026-10-00",
|
||||||
|
"2026-00-10",
|
||||||
|
"+026-10-29",
|
||||||
|
"2026-10-2\u{669}",
|
||||||
|
"\u{6f0}026-10-29",
|
||||||
|
] {
|
||||||
|
assert!(!is_calendar_date(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
6560
crates/weksa/tests/golf_loop.rs
Normal file
6560
crates/weksa/tests/golf_loop.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +1,16 @@
|
||||||
//! The English lowerer against the committed inventory (revision 2) and the
|
//! The entity port, `pronounce` and `phones` against the committed stores and
|
||||||
//! `nibu-proof-001` packet, and the rules that keep a table row from speaking
|
//! the `nibu-proof-001` packet.
|
||||||
//! for a meaning it does not carry.
|
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use common::{iv, nd};
|
|
||||||
use weksa::entity::{Entity, EntityOrigin, LanguageForms};
|
use weksa::entity::{Entity, EntityOrigin, LanguageForms};
|
||||||
use weksa::inventory::proof_revision;
|
use weksa::packet::{EntityId, Packet, proof_packet};
|
||||||
use weksa::packet::{EntityId, FeatValue, FeatureValue, Packet, proof_packet};
|
|
||||||
use weksa::{
|
use weksa::{
|
||||||
CorpusStore, EntityForms, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput,
|
CorpusStore, EntityForms, EntityStore, Inventory, InventoryStore, LowerError, LoweringInput,
|
||||||
PacketStore, lower, validate,
|
PacketStore, PhoneInventory, PhoneLine, Pronunciation, Stress, pronounce,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROOF: &str = "Warning: you may endanger yourself by touching the control. \
|
|
||||||
Nibu will stop that.";
|
|
||||||
|
|
||||||
fn committed() -> (Inventory, tempfile::TempDir) {
|
fn committed() -> (Inventory, tempfile::TempDir) {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
let inv = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).unwrap();
|
let inv = InventoryStore::open(&root.join("data/interlingua/inventory.cc")).unwrap();
|
||||||
|
|
@ -48,179 +42,6 @@ fn committed_entities() -> EntityStore {
|
||||||
EntityStore::open(&root.join("data/entity/entity.cc")).unwrap()
|
EntityStore::open(&root.join("data/entity/entity.cc")).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lowered(packet: &Packet, inv: &Inventory) -> Result<String, LowerError> {
|
|
||||||
lower(&view(packet, inv), inv, &committed_entities())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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. \
|
|
||||||
Nibu will stop that."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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, &committed_entities()),
|
|
||||||
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,
|
|
||||||
&committed_entities()
|
|
||||||
),
|
|
||||||
Err(LowerError::GlossMismatch {
|
|
||||||
id: iv("concept:4"),
|
|
||||||
row: "control"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An entity port a test controls: ids to entities, and an optional read fault.
|
/// An entity port a test controls: ids to entities, and an optional read fault.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Entities {
|
struct Entities {
|
||||||
|
|
@ -245,6 +66,7 @@ fn nibu(forms: &[(&str, &[&str])]) -> Entity {
|
||||||
.map(|(language, forms)| LanguageForms {
|
.map(|(language, forms)| LanguageForms {
|
||||||
language: (*language).into(),
|
language: (*language).into(),
|
||||||
forms: forms.iter().map(|f| (*f).into()).collect(),
|
forms: forms.iter().map(|f| (*f).into()).collect(),
|
||||||
|
pronunciation: None,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
origin: EntityOrigin::Cast {
|
origin: EntityOrigin::Cast {
|
||||||
|
|
@ -254,55 +76,19 @@ fn nibu(forms: &[(&str, &[&str])]) -> Entity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn through(entities: &Entities) -> Result<String, LowerError> {
|
fn ruled(ipa: &str, rejected: &[&str]) -> Pronunciation {
|
||||||
|
Pronunciation {
|
||||||
|
ipa: ipa.into(),
|
||||||
|
ruled_by: "Nibu".into(),
|
||||||
|
date: "2026-11-07".into(),
|
||||||
|
source: "test".into(),
|
||||||
|
rejected: rejected.iter().map(|r| (*r).into()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn say(entities: &dyn EntityForms) -> Result<Vec<String>, LowerError> {
|
||||||
let (inv, _dir) = committed();
|
let (inv, _dir) = committed();
|
||||||
lower(&view(&proof_packet(), &inv), &inv, entities)
|
pronounce(&view(&proof_packet(), &inv), entities)
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_name_survives_lowering_verbatim_where_the_concept_word_was() {
|
|
||||||
// The entity's own form, not "the ship mind", and not re-cased or inflected:
|
|
||||||
// the form is lowercase and stays so mid-sentence.
|
|
||||||
let mut e = Entities::default();
|
|
||||||
let n = nibu(&[("en-US", &["nibu-of-the-deep"])]);
|
|
||||||
e.held.push(n);
|
|
||||||
// It opens the second sentence, where a template word would be capitalised.
|
|
||||||
assert_eq!(
|
|
||||||
through(&e).unwrap(),
|
|
||||||
"Warning: you may endanger yourself by touching the control. \
|
|
||||||
nibu-of-the-deep will stop that."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_the_target_languages_citation_form_is_chosen() {
|
|
||||||
// Forms after the first are inflected shapes no English frame asks for; a
|
|
||||||
// form under another language tag is never read, whatever order it is in.
|
|
||||||
let mut e = Entities::default();
|
|
||||||
let n = nibu(&[
|
|
||||||
("sa-Latn", &["Nibuwa"]),
|
|
||||||
("en-US", &["Nibu", "Nibu's", "Nibus"]),
|
|
||||||
]);
|
|
||||||
e.held.push(n);
|
|
||||||
assert_eq!(through(&e).unwrap(), PROOF);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_entity_the_port_does_not_hold_is_refused_not_replaced_by_its_concept_word() {
|
|
||||||
let id = EntityId::parse("entity:nibu").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
through(&Entities::default()),
|
|
||||||
Err(LowerError::UnknownEntity { entity: id })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_entity_with_no_target_language_form_is_refused() {
|
|
||||||
let mut e = Entities::default();
|
|
||||||
let n = nibu(&[("sa-Latn", &["Nibuwa"])]);
|
|
||||||
let id = n.id.clone();
|
|
||||||
e.held.push(n);
|
|
||||||
assert_eq!(through(&e), Err(LowerError::NoForm { entity: id }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -312,7 +98,7 @@ fn a_failing_entity_port_is_refused_by_name() {
|
||||||
..Entities::default()
|
..Entities::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
through(&e),
|
say(&e),
|
||||||
Err(LowerError::EntityRead {
|
Err(LowerError::EntityRead {
|
||||||
entity: EntityId::parse("entity:nibu").unwrap(),
|
entity: EntityId::parse("entity:nibu").unwrap(),
|
||||||
why: "store unreadable".into()
|
why: "store unreadable".into()
|
||||||
|
|
@ -321,38 +107,200 @@ fn a_failing_entity_port_is_refused_by_name() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_addressee_entity_still_lowers_to_you_and_is_never_looked_up() {
|
fn nibu_round_trips_from_the_committed_seed_to_its_ruled_ipa() {
|
||||||
// Naming the addressee would break "you"/"yourself": the port is not asked,
|
let seed = committed_entities();
|
||||||
// so a port that faults on every read changes nothing.
|
let nibu = seed
|
||||||
|
.get(&EntityId::parse("entity:nibu").unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let p = nibu.pronunciation_in("en-US").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(p.ipa.as_str(), p.ruled_by.as_str(), p.date.as_str()),
|
||||||
|
("ˈnibu", "Nibu", "2026-11-07")
|
||||||
|
);
|
||||||
|
assert_eq!(say(&seed).unwrap(), ["ˈnibu"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rejected_pronunciation_is_never_emitted() {
|
||||||
|
let mut e = Entities::default();
|
||||||
|
let mut n = nibu(&[("en-US", &["Nibu"])]);
|
||||||
|
n.forms[0].pronunciation = Some(ruled("ˈnibu", &["ˈnɪbu", "ˈnaɪbu"]));
|
||||||
|
e.held.push(n);
|
||||||
|
assert_eq!(say(&e).unwrap(), ["ˈnibu"]);
|
||||||
|
// The committed seed carries the same rejection and still says only the ruling.
|
||||||
|
let seed = say(&committed_entities()).unwrap().concat();
|
||||||
|
assert!(!seed.contains('ɪ'), "{seed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_entity_with_no_ruled_pronunciation_is_refused_not_spelled_out() {
|
||||||
|
let id = EntityId::parse("entity:nibu").unwrap();
|
||||||
|
// No pronunciation at all.
|
||||||
|
let mut e = Entities::default();
|
||||||
|
e.held.push(nibu(&[("en-US", &["Nibu"])]));
|
||||||
|
assert_eq!(
|
||||||
|
say(&e),
|
||||||
|
Err(LowerError::NoPronunciation { entity: id.clone() })
|
||||||
|
);
|
||||||
|
// A pronunciation under another language is not the target language's.
|
||||||
|
let mut n = nibu(&[("sa-Latn", &["Nibuwa"]), ("en-US", &["Nibu"])]);
|
||||||
|
n.forms[0].pronunciation = Some(ruled("ˈnibuwa", &[]));
|
||||||
|
let mut e = Entities::default();
|
||||||
|
e.held.push(n);
|
||||||
|
assert_eq!(say(&e), Err(LowerError::NoPronunciation { entity: id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipa_needs_an_entity_the_port_holds_and_a_packet_that_cites_one() {
|
||||||
|
assert_eq!(
|
||||||
|
say(&Entities::default()),
|
||||||
|
Err(LowerError::UnknownEntity {
|
||||||
|
entity: EntityId::parse("entity:nibu").unwrap()
|
||||||
|
})
|
||||||
|
);
|
||||||
let (inv, _dir) = committed();
|
let (inv, _dir) = committed();
|
||||||
let mut p = proof_packet();
|
let mut p = proof_packet();
|
||||||
p.referents[0].entity = Some(EntityId::parse("entity:nibu").unwrap());
|
|
||||||
p.referents[2].entity = None;
|
p.referents[2].entity = None;
|
||||||
let faulting = Entities {
|
|
||||||
fault: true,
|
|
||||||
..Entities::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lower(&view(&p, &inv), &inv, &faulting).unwrap(),
|
pronounce(&view(&p, &inv), &committed_entities()),
|
||||||
"Warning: you may endanger yourself by touching the control. \
|
Err(LowerError::NoEntityReferent)
|
||||||
The ship mind will stop that."
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the phones line: the phone inventory and the segmenter ----
|
||||||
|
|
||||||
|
fn phones_of(ipa: &str) -> Result<Vec<PhoneLine>, LowerError> {
|
||||||
|
let mut n = nibu(&[("en-US", &["Nibu"])]);
|
||||||
|
n.forms[0].pronunciation = Some(ruled(ipa, &[]));
|
||||||
|
let mut e = Entities::default();
|
||||||
|
e.held.push(n);
|
||||||
|
let (inv, _dir) = committed();
|
||||||
|
weksa::phones(&view(&proof_packet(), &inv), &e)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seg(ipa: &str) -> Vec<(String, Stress)> {
|
||||||
|
let inv = PhoneInventory::for_language("en-US").unwrap();
|
||||||
|
inv.segment(ipa)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p.ipa, p.stress))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pairs(v: &[(&str, Stress)]) -> Vec<(String, Stress)> {
|
||||||
|
v.iter().map(|(i, s)| ((*i).to_string(), *s)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nibus_committed_ruling_lowers_to_the_committed_phones_fixture() {
|
||||||
|
let (inv, _dir) = committed();
|
||||||
|
let lines = weksa::phones(&view(&proof_packet(), &inv), &committed_entities()).unwrap();
|
||||||
|
let fixture = std::fs::read_to_string(
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../data/phonology/fixtures/nibu-en-US.phones.json"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(lines.len(), 1);
|
||||||
|
assert_eq!(serde_json::to_string(&lines[0]).unwrap(), fixture.trim_end());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tense_i_is_a_phone_and_the_rejected_lax_form_is_a_different_one() {
|
||||||
|
use weksa::phones::{Class, Tenseness};
|
||||||
|
let inv = PhoneInventory::for_language("en-US").unwrap();
|
||||||
|
assert_eq!(inv.class("i"), Some(&Class::Vowel(Tenseness::Tense)));
|
||||||
|
assert_eq!(inv.class("ɪ"), Some(&Class::Vowel(Tenseness::Lax)));
|
||||||
|
assert_eq!(inv.class("u"), Some(&Class::Vowel(Tenseness::Tense)));
|
||||||
|
let ruled_form = seg("ˈnibu");
|
||||||
|
let lax = seg("ˈnɪbu");
|
||||||
|
assert_ne!(ruled_form, lax);
|
||||||
|
assert_eq!(lax[1], ("ɪ".to_string(), Stress::Primary));
|
||||||
|
// The committed seed rejects the lax form, and it is not what the seed emits.
|
||||||
|
let seed = committed_entities();
|
||||||
|
let nibu = seed.get(&EntityId::parse("entity:nibu").unwrap()).unwrap().unwrap();
|
||||||
|
let p = nibu.pronunciation_in("en-US").unwrap();
|
||||||
|
assert_eq!(p.rejected, ["ˈnɪbu"]);
|
||||||
|
assert_eq!(seg(&p.ipa), ruled_form);
|
||||||
|
assert!(p.rejected.iter().all(|r| seg(r) != seg(&p.ipa)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stress_lands_on_the_vowel_after_the_mark_and_nowhere_else() {
|
||||||
|
use Stress::{None as N, Primary as P, Secondary as S};
|
||||||
|
assert_eq!(seg("ˈnibu"), pairs(&[("n", N), ("i", P), ("b", N), ("u", N)]));
|
||||||
|
assert_eq!(seg("nˈibu"), seg("ˈnibu"));
|
||||||
|
assert_eq!(seg("bˈunˌi"), pairs(&[("b", N), ("u", P), ("n", N), ("i", S)]));
|
||||||
|
assert_eq!(seg("nibu"), pairs(&[("n", N), ("i", N), ("b", N), ("u", N)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_character_phones_are_one_phone_by_longest_match() {
|
||||||
|
use Stress::{None as N, Primary as P};
|
||||||
|
assert_eq!(seg("ˈtʃeɪ"), pairs(&[("tʃ", N), ("eɪ", P)]));
|
||||||
|
assert_eq!(seg("dʒaʊ"), pairs(&[("dʒ", N), ("aʊ", N)]));
|
||||||
|
// /t/ then /ʃ/ is not what a lone "t" and "ʃ" mean when the affricate is listed,
|
||||||
|
// but a symbol sequence the inventory cannot join still splits.
|
||||||
|
assert_eq!(seg("ɔɪt"), pairs(&[("ɔɪ", N), ("t", N)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_phone_the_inventory_lacks_is_refused_with_where_and_never_guessed() {
|
||||||
|
let id = EntityId::parse("entity:nibu").unwrap();
|
||||||
|
for (ipa, at, text) in [
|
||||||
|
("ˈnixu", "ˈni".len(), "xu"),
|
||||||
|
("ˈniːbu", "ˈni".len(), "ːbu"),
|
||||||
|
("ˈnibu.", "ˈnibu".len(), "."),
|
||||||
|
("ˈni bu", "ˈni".len(), " bu"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
phones_of(ipa),
|
||||||
|
Err(LowerError::UnknownPhone { entity: id.clone(), at, text: text.into() }),
|
||||||
|
"{ipa}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stress_mark_with_no_vowel_to_land_on_is_refused() {
|
||||||
|
let id = EntityId::parse("entity:nibu").unwrap();
|
||||||
|
for (ipa, at) in [("nibuˈ", "nibu".len()), ("ˈˈnibu", 0), ("ˈnˈibu", 0), ("ˈnb", 0)] {
|
||||||
|
assert_eq!(
|
||||||
|
phones_of(ipa),
|
||||||
|
Err(LowerError::DanglingStress { entity: id.clone(), at }),
|
||||||
|
"{ipa}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phones_need_a_ruling_and_an_entity_exactly_as_ipa_does() {
|
||||||
|
assert_eq!(
|
||||||
|
weksa::phones(&view(&proof_packet(), &committed().0), &Entities::default()),
|
||||||
|
Err(LowerError::UnknownEntity { entity: EntityId::parse("entity:nibu").unwrap() })
|
||||||
|
);
|
||||||
|
let mut e = Entities::default();
|
||||||
|
e.held.push(nibu(&[("en-US", &["Nibu"])]));
|
||||||
|
assert_eq!(
|
||||||
|
weksa::phones(&view(&proof_packet(), &committed().0), &e),
|
||||||
|
Err(LowerError::NoPronunciation { entity: EntityId::parse("entity:nibu").unwrap() })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_purpose_the_frame_does_not_say_is_refused_not_dropped() {
|
fn the_inventory_parser_refuses_what_it_cannot_read() {
|
||||||
// The intervention row says "will stop"; a packet that also fills the
|
for bad in [
|
||||||
// beneficiary role would assert a purpose the text does not carry.
|
"i\tvowel\ttense",
|
||||||
let (inv, _dir) = committed();
|
"i\tvowel\t-\tx",
|
||||||
let mut p = proof_packet();
|
"n\tconsonant\ttense\tx",
|
||||||
p.predications[1].roles.push(weksa::packet::RoleFill {
|
"i\tvowel\tmid\tx",
|
||||||
role: iv("role:8"),
|
"ˈi\tvowel\ttense\tx",
|
||||||
filler: nd("n1"),
|
"\tvowel\ttense\tx",
|
||||||
});
|
"i\tvowel\ttense\t",
|
||||||
assert_eq!(
|
"i\tvowel\ttense\ta\ni\tvowel\tlax\tb",
|
||||||
lowered(&p, &inv),
|
] {
|
||||||
Err(LowerError::Unrendered {
|
assert!(PhoneInventory::parse(bad).is_err(), "{bad:?}");
|
||||||
what: "a role the frame does not name"
|
}
|
||||||
})
|
assert!(PhoneInventory::parse("# c\n\ni\tvowel\ttense\ta\n").is_ok());
|
||||||
);
|
assert!(PhoneInventory::for_language("sa-Latn").is_none());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
411
crates/weksa/tests/modifier_letters.txt
Normal file
411
crates/weksa/tests/modifier_letters.txt
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
# Lm, Node 24 (Unicode 17.0) over 0..10FFFF, one hex code point per line
|
||||||
|
2B0
|
||||||
|
2B1
|
||||||
|
2B2
|
||||||
|
2B3
|
||||||
|
2B4
|
||||||
|
2B5
|
||||||
|
2B6
|
||||||
|
2B7
|
||||||
|
2B8
|
||||||
|
2B9
|
||||||
|
2BA
|
||||||
|
2BB
|
||||||
|
2BC
|
||||||
|
2BD
|
||||||
|
2BE
|
||||||
|
2BF
|
||||||
|
2C0
|
||||||
|
2C1
|
||||||
|
2C6
|
||||||
|
2C7
|
||||||
|
2C8
|
||||||
|
2C9
|
||||||
|
2CA
|
||||||
|
2CB
|
||||||
|
2CC
|
||||||
|
2CD
|
||||||
|
2CE
|
||||||
|
2CF
|
||||||
|
2D0
|
||||||
|
2D1
|
||||||
|
2E0
|
||||||
|
2E1
|
||||||
|
2E2
|
||||||
|
2E3
|
||||||
|
2E4
|
||||||
|
2EC
|
||||||
|
2EE
|
||||||
|
374
|
||||||
|
37A
|
||||||
|
559
|
||||||
|
640
|
||||||
|
6E5
|
||||||
|
6E6
|
||||||
|
7F4
|
||||||
|
7F5
|
||||||
|
7FA
|
||||||
|
81A
|
||||||
|
824
|
||||||
|
828
|
||||||
|
8C9
|
||||||
|
971
|
||||||
|
E46
|
||||||
|
EC6
|
||||||
|
10FC
|
||||||
|
17D7
|
||||||
|
1843
|
||||||
|
1AA7
|
||||||
|
1C78
|
||||||
|
1C79
|
||||||
|
1C7A
|
||||||
|
1C7B
|
||||||
|
1C7C
|
||||||
|
1C7D
|
||||||
|
1D2C
|
||||||
|
1D2D
|
||||||
|
1D2E
|
||||||
|
1D2F
|
||||||
|
1D30
|
||||||
|
1D31
|
||||||
|
1D32
|
||||||
|
1D33
|
||||||
|
1D34
|
||||||
|
1D35
|
||||||
|
1D36
|
||||||
|
1D37
|
||||||
|
1D38
|
||||||
|
1D39
|
||||||
|
1D3A
|
||||||
|
1D3B
|
||||||
|
1D3C
|
||||||
|
1D3D
|
||||||
|
1D3E
|
||||||
|
1D3F
|
||||||
|
1D40
|
||||||
|
1D41
|
||||||
|
1D42
|
||||||
|
1D43
|
||||||
|
1D44
|
||||||
|
1D45
|
||||||
|
1D46
|
||||||
|
1D47
|
||||||
|
1D48
|
||||||
|
1D49
|
||||||
|
1D4A
|
||||||
|
1D4B
|
||||||
|
1D4C
|
||||||
|
1D4D
|
||||||
|
1D4E
|
||||||
|
1D4F
|
||||||
|
1D50
|
||||||
|
1D51
|
||||||
|
1D52
|
||||||
|
1D53
|
||||||
|
1D54
|
||||||
|
1D55
|
||||||
|
1D56
|
||||||
|
1D57
|
||||||
|
1D58
|
||||||
|
1D59
|
||||||
|
1D5A
|
||||||
|
1D5B
|
||||||
|
1D5C
|
||||||
|
1D5D
|
||||||
|
1D5E
|
||||||
|
1D5F
|
||||||
|
1D60
|
||||||
|
1D61
|
||||||
|
1D62
|
||||||
|
1D63
|
||||||
|
1D64
|
||||||
|
1D65
|
||||||
|
1D66
|
||||||
|
1D67
|
||||||
|
1D68
|
||||||
|
1D69
|
||||||
|
1D6A
|
||||||
|
1D78
|
||||||
|
1D9B
|
||||||
|
1D9C
|
||||||
|
1D9D
|
||||||
|
1D9E
|
||||||
|
1D9F
|
||||||
|
1DA0
|
||||||
|
1DA1
|
||||||
|
1DA2
|
||||||
|
1DA3
|
||||||
|
1DA4
|
||||||
|
1DA5
|
||||||
|
1DA6
|
||||||
|
1DA7
|
||||||
|
1DA8
|
||||||
|
1DA9
|
||||||
|
1DAA
|
||||||
|
1DAB
|
||||||
|
1DAC
|
||||||
|
1DAD
|
||||||
|
1DAE
|
||||||
|
1DAF
|
||||||
|
1DB0
|
||||||
|
1DB1
|
||||||
|
1DB2
|
||||||
|
1DB3
|
||||||
|
1DB4
|
||||||
|
1DB5
|
||||||
|
1DB6
|
||||||
|
1DB7
|
||||||
|
1DB8
|
||||||
|
1DB9
|
||||||
|
1DBA
|
||||||
|
1DBB
|
||||||
|
1DBC
|
||||||
|
1DBD
|
||||||
|
1DBE
|
||||||
|
1DBF
|
||||||
|
2071
|
||||||
|
207F
|
||||||
|
2090
|
||||||
|
2091
|
||||||
|
2092
|
||||||
|
2093
|
||||||
|
2094
|
||||||
|
2095
|
||||||
|
2096
|
||||||
|
2097
|
||||||
|
2098
|
||||||
|
2099
|
||||||
|
209A
|
||||||
|
209B
|
||||||
|
209C
|
||||||
|
2C7C
|
||||||
|
2C7D
|
||||||
|
2D6F
|
||||||
|
2E2F
|
||||||
|
3005
|
||||||
|
3031
|
||||||
|
3032
|
||||||
|
3033
|
||||||
|
3034
|
||||||
|
3035
|
||||||
|
303B
|
||||||
|
309D
|
||||||
|
309E
|
||||||
|
30FC
|
||||||
|
30FD
|
||||||
|
30FE
|
||||||
|
A015
|
||||||
|
A4F8
|
||||||
|
A4F9
|
||||||
|
A4FA
|
||||||
|
A4FB
|
||||||
|
A4FC
|
||||||
|
A4FD
|
||||||
|
A60C
|
||||||
|
A67F
|
||||||
|
A69C
|
||||||
|
A69D
|
||||||
|
A717
|
||||||
|
A718
|
||||||
|
A719
|
||||||
|
A71A
|
||||||
|
A71B
|
||||||
|
A71C
|
||||||
|
A71D
|
||||||
|
A71E
|
||||||
|
A71F
|
||||||
|
A770
|
||||||
|
A788
|
||||||
|
A7F1
|
||||||
|
A7F2
|
||||||
|
A7F3
|
||||||
|
A7F4
|
||||||
|
A7F8
|
||||||
|
A7F9
|
||||||
|
A9CF
|
||||||
|
A9E6
|
||||||
|
AA70
|
||||||
|
AADD
|
||||||
|
AAF3
|
||||||
|
AAF4
|
||||||
|
AB5C
|
||||||
|
AB5D
|
||||||
|
AB5E
|
||||||
|
AB5F
|
||||||
|
AB69
|
||||||
|
FF70
|
||||||
|
FF9E
|
||||||
|
FF9F
|
||||||
|
10780
|
||||||
|
10781
|
||||||
|
10782
|
||||||
|
10783
|
||||||
|
10784
|
||||||
|
10785
|
||||||
|
10787
|
||||||
|
10788
|
||||||
|
10789
|
||||||
|
1078A
|
||||||
|
1078B
|
||||||
|
1078C
|
||||||
|
1078D
|
||||||
|
1078E
|
||||||
|
1078F
|
||||||
|
10790
|
||||||
|
10791
|
||||||
|
10792
|
||||||
|
10793
|
||||||
|
10794
|
||||||
|
10795
|
||||||
|
10796
|
||||||
|
10797
|
||||||
|
10798
|
||||||
|
10799
|
||||||
|
1079A
|
||||||
|
1079B
|
||||||
|
1079C
|
||||||
|
1079D
|
||||||
|
1079E
|
||||||
|
1079F
|
||||||
|
107A0
|
||||||
|
107A1
|
||||||
|
107A2
|
||||||
|
107A3
|
||||||
|
107A4
|
||||||
|
107A5
|
||||||
|
107A6
|
||||||
|
107A7
|
||||||
|
107A8
|
||||||
|
107A9
|
||||||
|
107AA
|
||||||
|
107AB
|
||||||
|
107AC
|
||||||
|
107AD
|
||||||
|
107AE
|
||||||
|
107AF
|
||||||
|
107B0
|
||||||
|
107B2
|
||||||
|
107B3
|
||||||
|
107B4
|
||||||
|
107B5
|
||||||
|
107B6
|
||||||
|
107B7
|
||||||
|
107B8
|
||||||
|
107B9
|
||||||
|
107BA
|
||||||
|
10D4E
|
||||||
|
10D6F
|
||||||
|
10EC5
|
||||||
|
11DD9
|
||||||
|
16B40
|
||||||
|
16B41
|
||||||
|
16B42
|
||||||
|
16B43
|
||||||
|
16D40
|
||||||
|
16D41
|
||||||
|
16D42
|
||||||
|
16D6B
|
||||||
|
16D6C
|
||||||
|
16F93
|
||||||
|
16F94
|
||||||
|
16F95
|
||||||
|
16F96
|
||||||
|
16F97
|
||||||
|
16F98
|
||||||
|
16F99
|
||||||
|
16F9A
|
||||||
|
16F9B
|
||||||
|
16F9C
|
||||||
|
16F9D
|
||||||
|
16F9E
|
||||||
|
16F9F
|
||||||
|
16FE0
|
||||||
|
16FE1
|
||||||
|
16FE3
|
||||||
|
16FF2
|
||||||
|
16FF3
|
||||||
|
1AFF0
|
||||||
|
1AFF1
|
||||||
|
1AFF2
|
||||||
|
1AFF3
|
||||||
|
1AFF5
|
||||||
|
1AFF6
|
||||||
|
1AFF7
|
||||||
|
1AFF8
|
||||||
|
1AFF9
|
||||||
|
1AFFA
|
||||||
|
1AFFB
|
||||||
|
1AFFD
|
||||||
|
1AFFE
|
||||||
|
1E030
|
||||||
|
1E031
|
||||||
|
1E032
|
||||||
|
1E033
|
||||||
|
1E034
|
||||||
|
1E035
|
||||||
|
1E036
|
||||||
|
1E037
|
||||||
|
1E038
|
||||||
|
1E039
|
||||||
|
1E03A
|
||||||
|
1E03B
|
||||||
|
1E03C
|
||||||
|
1E03D
|
||||||
|
1E03E
|
||||||
|
1E03F
|
||||||
|
1E040
|
||||||
|
1E041
|
||||||
|
1E042
|
||||||
|
1E043
|
||||||
|
1E044
|
||||||
|
1E045
|
||||||
|
1E046
|
||||||
|
1E047
|
||||||
|
1E048
|
||||||
|
1E049
|
||||||
|
1E04A
|
||||||
|
1E04B
|
||||||
|
1E04C
|
||||||
|
1E04D
|
||||||
|
1E04E
|
||||||
|
1E04F
|
||||||
|
1E050
|
||||||
|
1E051
|
||||||
|
1E052
|
||||||
|
1E053
|
||||||
|
1E054
|
||||||
|
1E055
|
||||||
|
1E056
|
||||||
|
1E057
|
||||||
|
1E058
|
||||||
|
1E059
|
||||||
|
1E05A
|
||||||
|
1E05B
|
||||||
|
1E05C
|
||||||
|
1E05D
|
||||||
|
1E05E
|
||||||
|
1E05F
|
||||||
|
1E060
|
||||||
|
1E061
|
||||||
|
1E062
|
||||||
|
1E063
|
||||||
|
1E064
|
||||||
|
1E065
|
||||||
|
1E066
|
||||||
|
1E067
|
||||||
|
1E068
|
||||||
|
1E069
|
||||||
|
1E06A
|
||||||
|
1E06B
|
||||||
|
1E06C
|
||||||
|
1E06D
|
||||||
|
1E137
|
||||||
|
1E138
|
||||||
|
1E139
|
||||||
|
1E13A
|
||||||
|
1E13B
|
||||||
|
1E13C
|
||||||
|
1E13D
|
||||||
|
1E4EB
|
||||||
|
1E6FF
|
||||||
|
1E94B
|
||||||
25
crates/weksa/tests/mutants_a.rs
Normal file
25
crates/weksa/tests/mutants_a.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
//! Pins for behaviour of the small and middle weksa files that mutation testing
|
||||||
|
//! found unpinned. Each test states the behaviour in domain terms.
|
||||||
|
|
||||||
|
use weksa::form::is_calendar_date;
|
||||||
|
|
||||||
|
/// A calendar date is exactly `YYYY-MM-DD`: both hyphens sit where the format
|
||||||
|
/// puts them, so a ten-byte string with a digit in the second hyphen's place is
|
||||||
|
/// no date.
|
||||||
|
#[test]
|
||||||
|
fn a_calendar_date_needs_both_hyphens_in_place() {
|
||||||
|
assert!(is_calendar_date("2026-01-01"));
|
||||||
|
assert!(!is_calendar_date("2026-01201"));
|
||||||
|
assert!(!is_calendar_date("2026101-01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// February has 29 days in a Gregorian leap year and 28 otherwise.
|
||||||
|
#[test]
|
||||||
|
fn february_has_29_days_only_in_gregorian_leap_years() {
|
||||||
|
assert!(is_calendar_date("2024-02-29"));
|
||||||
|
assert!(is_calendar_date("2000-02-29"));
|
||||||
|
assert!(!is_calendar_date("1900-02-29"));
|
||||||
|
assert!(!is_calendar_date("2023-02-29"));
|
||||||
|
assert!(is_calendar_date("2023-02-28"));
|
||||||
|
assert!(!is_calendar_date("2024-02-30"));
|
||||||
|
}
|
||||||
297
crates/weksa/tests/mutants_b.rs
Normal file
297
crates/weksa/tests/mutants_b.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
//! Pins found by mutation testing store.rs, inventory.rs and main.rs. Each test
|
||||||
|
//! states what the production code must do, in domain terms.
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
use std::process::{Command, Output};
|
||||||
|
use weksa::packet::Grade;
|
||||||
|
use weksa::store::EntityBound;
|
||||||
|
use weksa::{
|
||||||
|
Annotator, Condition, EntityStore, Layer, OntologyEntry, OntologyStore, Pronunciation, Refusal,
|
||||||
|
Slot, StoreError, StoreRefusal, Test,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn refusal<T: std::fmt::Debug>(r: Result<T, StoreError>) -> StoreRefusal {
|
||||||
|
match r {
|
||||||
|
Err(StoreError::Refused(r)) => r,
|
||||||
|
other => panic!("expected a store refusal, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kay_saying(p: Pronunciation) -> weksa::Entity {
|
||||||
|
let mut e = ent("kay", "en-US", &["Kay"]);
|
||||||
|
e.forms[0].pronunciation = Some(p);
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ruling(ipa: &str, rejected: Vec<String>) -> Pronunciation {
|
||||||
|
Pronunciation {
|
||||||
|
ipa: ipa.into(),
|
||||||
|
ruled_by: "Kay".into(),
|
||||||
|
date: "2026-11-07".into(),
|
||||||
|
source: "test".into(),
|
||||||
|
rejected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pronunciation_with_a_control_or_invisible_format_character_is_refused() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||||
|
// Either kind alone is refused: a control character, and a zero-width space.
|
||||||
|
for ipa in ["ˈk\u{7}eɪ", "ˈk\u{200b}eɪ"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.put(kay_saying(ruling(ipa, vec![]))),
|
||||||
|
Err(StoreError::Refused(StoreRefusal::Malformed { .. }))
|
||||||
|
),
|
||||||
|
"{ipa:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The same for a rejected pronunciation.
|
||||||
|
for bad in ["ˈk\u{7}aɪ", "ˈk\u{200b}aɪ"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.put(kay_saying(ruling("ˈkeɪ", vec![bad.into()]))),
|
||||||
|
Err(StoreError::Refused(StoreRefusal::Malformed { .. }))
|
||||||
|
),
|
||||||
|
"{bad:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(store.get(&eid("kay")).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pronunciation_keeps_at_most_eight_rejected_variants() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let mut store = EntityStore::open(&d.path().join("e.cc")).unwrap();
|
||||||
|
let variants = |n: usize| -> Vec<String> { (0..n).map(|i| format!("ˈkaɪ{i}")).collect() };
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.put(kay_saying(ruling("ˈkeɪ", variants(9))))),
|
||||||
|
StoreRefusal::EntityBounds {
|
||||||
|
id: eid("kay"),
|
||||||
|
bound: EntityBound::Forms
|
||||||
|
}
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.put(kay_saying(ruling("ˈkeɪ", variants(8))))
|
||||||
|
.expect("eight rejected variants are the bound, not beyond it");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ontology_entry_read_at_an_unknown_revision_is_refused_with_one_refusal() {
|
||||||
|
let w = world();
|
||||||
|
let mut onto = OntologyStore::open(&w.dir.path().join("o2.cc")).unwrap();
|
||||||
|
let entry = OntologyEntry {
|
||||||
|
language: "en-US".into(),
|
||||||
|
n: 1,
|
||||||
|
inventory_rev: 99,
|
||||||
|
layer: Layer::Discourse,
|
||||||
|
when: vec![Condition {
|
||||||
|
slot: Slot::Stance,
|
||||||
|
feature: iv(ANGER),
|
||||||
|
test: Test::AtLeast(Grade::new(200).unwrap()),
|
||||||
|
}],
|
||||||
|
choice: "WARM".into(),
|
||||||
|
examples: vec![],
|
||||||
|
annotator: Annotator::Operator,
|
||||||
|
};
|
||||||
|
match onto.admit(vec![entry], &w.inventory) {
|
||||||
|
Err(StoreError::Invalid(r)) => assert_eq!(r, vec![Refusal::V10UnknownRevision { rev: 99 }]),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"--ontology",
|
||||||
|
"./o.cc",
|
||||||
|
"--speaker-state",
|
||||||
|
"./s.cc",
|
||||||
|
])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn out(o: &Output) -> String {
|
||||||
|
String::from_utf8_lossy(&o.stdout).into_owned() + &String::from_utf8_lossy(&o.stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok(o: Output) -> String {
|
||||||
|
assert!(o.status.success(), "{}", out(&o));
|
||||||
|
out(&o)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scene bed: entities nibu and brokkr, the inventory through the
|
||||||
|
/// speaker-scene revision (rev 3).
|
||||||
|
fn scene_bed() -> tempfile::TempDir {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
for slug in ["nibu", "brokkr"] {
|
||||||
|
let id = format!("entity:{slug}");
|
||||||
|
let form = format!("en-US={}", slug.to_uppercase());
|
||||||
|
ok(weksa(
|
||||||
|
d.path(),
|
||||||
|
&[
|
||||||
|
"entity",
|
||||||
|
"put",
|
||||||
|
&id,
|
||||||
|
"--form",
|
||||||
|
&form,
|
||||||
|
"--origin",
|
||||||
|
"cast",
|
||||||
|
"--persona",
|
||||||
|
slug,
|
||||||
|
"--fixture",
|
||||||
|
"f.yaml",
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for step in ["seed", "proof", "speaker-scene"] {
|
||||||
|
ok(weksa(d.path(), &["inventory", step]));
|
||||||
|
}
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a cast-authored line; returns the key it printed ("Added <key>").
|
||||||
|
fn add_line(d: &std::path::Path, slug: &str, speaker: &str, group: &str, text: &str) -> String {
|
||||||
|
let speaker = format!("entity:{speaker}");
|
||||||
|
let printed = ok(weksa(
|
||||||
|
d,
|
||||||
|
&[
|
||||||
|
"corpus",
|
||||||
|
"add",
|
||||||
|
"--slug",
|
||||||
|
slug,
|
||||||
|
"--text",
|
||||||
|
text,
|
||||||
|
"--speaker",
|
||||||
|
&speaker,
|
||||||
|
"--group",
|
||||||
|
group,
|
||||||
|
"--admitted-on",
|
||||||
|
"2026-10-29",
|
||||||
|
"--source",
|
||||||
|
"cast-authored",
|
||||||
|
"--language",
|
||||||
|
"en-US",
|
||||||
|
"--persona",
|
||||||
|
"nibu",
|
||||||
|
"--fixture",
|
||||||
|
"f.yaml",
|
||||||
|
"--author",
|
||||||
|
"Ada Writer",
|
||||||
|
],
|
||||||
|
));
|
||||||
|
printed.split_whitespace().nth(1).unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene_put(d: &std::path::Path, group: &str, to: &str) -> Output {
|
||||||
|
weksa(
|
||||||
|
d,
|
||||||
|
&[
|
||||||
|
"scene",
|
||||||
|
"put",
|
||||||
|
"--group",
|
||||||
|
group,
|
||||||
|
"--to",
|
||||||
|
to,
|
||||||
|
"--inventory-rev",
|
||||||
|
"3",
|
||||||
|
"--model-family",
|
||||||
|
"anthropic",
|
||||||
|
"--generator",
|
||||||
|
"a scene annotator",
|
||||||
|
"--set",
|
||||||
|
"intimacy=200",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scene_group_holds_one_live_speaker_among_its_own_live_items() {
|
||||||
|
let d = scene_bed();
|
||||||
|
add_line(d.path(), "nibu", "nibu", "scene-1", "Mind the hatch.");
|
||||||
|
// Another group's speaker and a withdrawn line of this group are not the
|
||||||
|
// scene's speakers.
|
||||||
|
add_line(d.path(), "brokkr", "brokkr", "scene-2", "Brokkr hums.");
|
||||||
|
let gone = add_line(
|
||||||
|
d.path(),
|
||||||
|
"brokkr",
|
||||||
|
"brokkr",
|
||||||
|
"scene-1",
|
||||||
|
"Brokkr leaves early.",
|
||||||
|
);
|
||||||
|
ok(weksa(d.path(), &["corpus", "withdraw", &gone]));
|
||||||
|
let put = ok(scene_put(d.path(), "scene-1", "unnamed"));
|
||||||
|
assert!(put.contains("scene:scene-1@1"), "{put}");
|
||||||
|
assert!(ok(weksa(d.path(), &["scene", "dump"])).contains("entity:nibu->unnamed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scene_names_its_addressee_as_a_participant_unless_it_is_the_speaker() {
|
||||||
|
let d = scene_bed();
|
||||||
|
add_line(d.path(), "nibu", "nibu", "scene-1", "Mind the hatch.");
|
||||||
|
add_line(d.path(), "nibu", "nibu", "scene-2", "Mind the ladder.");
|
||||||
|
ok(scene_put(d.path(), "scene-1", "entity:brokkr"));
|
||||||
|
let dump = ok(weksa(d.path(), &["scene", "dump"]));
|
||||||
|
assert!(dump.contains("scene:scene-1@1 rev:3"), "{dump}");
|
||||||
|
assert!(dump.contains("[entity:nibu,entity:brokkr]"), "{dump}");
|
||||||
|
// A speaker addressing themself is a one-participant scene, whose
|
||||||
|
// relation to a named party is refused as the store reads it.
|
||||||
|
let own = scene_put(d.path(), "scene-2", "entity:nibu");
|
||||||
|
assert!(!own.status.success());
|
||||||
|
assert!(
|
||||||
|
out(&own).contains("a relation's named to is another participant"),
|
||||||
|
"{}",
|
||||||
|
out(&own)
|
||||||
|
);
|
||||||
|
assert_eq!(ok(weksa(d.path(), &["scene", "dump"])), dump);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relower_refuses_malformed_arm_and_floor_labels_before_reading_the_run() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let relower = |arm: &str, floor: &[&str]| {
|
||||||
|
let mut args = vec![
|
||||||
|
"loop",
|
||||||
|
"relower",
|
||||||
|
"no-such-run",
|
||||||
|
"--round",
|
||||||
|
"1",
|
||||||
|
"--arm",
|
||||||
|
arm,
|
||||||
|
"--lowerer",
|
||||||
|
"m",
|
||||||
|
];
|
||||||
|
for f in floor {
|
||||||
|
args.extend(["--floor", f]);
|
||||||
|
}
|
||||||
|
weksa(d.path(), &args)
|
||||||
|
};
|
||||||
|
for (arm, floor) in [
|
||||||
|
("BAD", vec!["b"]),
|
||||||
|
("a", vec!["Bad"]),
|
||||||
|
("a", vec!["a", "b"]),
|
||||||
|
("a", vec!["b", "b"]),
|
||||||
|
] {
|
||||||
|
let o = relower(arm, &floor);
|
||||||
|
assert!(!o.status.success());
|
||||||
|
assert!(
|
||||||
|
out(&o).contains("arm and floor labels must match"),
|
||||||
|
"{arm} {floor:?}: {}",
|
||||||
|
out(&o)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Well-formed, distinct labels get past the label check (to the missing run).
|
||||||
|
let o = relower("a", &["b", "c"]);
|
||||||
|
assert!(!o.status.success());
|
||||||
|
assert!(out(&o).contains("the root holds no run"), "{}", out(&o));
|
||||||
|
}
|
||||||
136
crates/weksa/tests/mutants_c.rs
Normal file
136
crates/weksa/tests/mutants_c.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
//! Pins for behaviour of `context.rs`, `encode.rs` and `run.rs` found unpinned
|
||||||
|
//! by mutation testing. Each test states what the code does in domain terms.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use weksa::context::Prompt;
|
||||||
|
use weksa::entity::{Entity, EntityOrigin, LanguageForms};
|
||||||
|
use weksa::packet::{EntityId, proof_packet};
|
||||||
|
use weksa::{CorpusItemId, CorpusStore, EntityForms, EntityStore, InventoryStore, PacketStore};
|
||||||
|
|
||||||
|
fn data(sub: &str) -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../data")
|
||||||
|
.join(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Held(Vec<Entity>);
|
||||||
|
|
||||||
|
impl EntityForms for Held {
|
||||||
|
fn entity(&self, id: &EntityId) -> anyhow::Result<Option<Entity>> {
|
||||||
|
Ok(self.0.iter().find(|e| &e.id == id).cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spoken(slug: &str, form: &str) -> Entity {
|
||||||
|
Entity {
|
||||||
|
id: EntityId::parse(&format!("entity:{slug}")).unwrap(),
|
||||||
|
forms: vec![LanguageForms {
|
||||||
|
language: "en-US".into(),
|
||||||
|
forms: vec![form.into()],
|
||||||
|
pronunciation: None,
|
||||||
|
}],
|
||||||
|
origin: EntityOrigin::Cast {
|
||||||
|
persona: slug.into(),
|
||||||
|
fixture: "f.yaml".into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A speaker whom no referent cites contributes no entry to the lowering
|
||||||
|
/// prompt, even when another entity is cited: only the cited entity is listed.
|
||||||
|
#[test]
|
||||||
|
fn an_uncited_speaker_is_absent_from_the_lowering_prompt() {
|
||||||
|
let cast_inv = InventoryStore::open(&data("interlingua/inventory.cc")).unwrap();
|
||||||
|
let entities = EntityStore::open(&data("entity/entity.cc")).unwrap();
|
||||||
|
let corpus = CorpusStore::open(&data("corpus/corpus.cc")).unwrap();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let view = PacketStore::open(&dir.path().join("p.cc"))
|
||||||
|
.unwrap()
|
||||||
|
.admit(proof_packet(), &cast_inv, &corpus, &entities)
|
||||||
|
.unwrap();
|
||||||
|
let state = weksa::SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
|
||||||
|
let view = view
|
||||||
|
.with_scene(&cast_inv.inventory().unwrap(), &state, &corpus)
|
||||||
|
.unwrap();
|
||||||
|
let held = Held(vec![
|
||||||
|
spoken("nibu", "Nibu"),
|
||||||
|
spoken("other", "SPEAKERCANARY"),
|
||||||
|
]);
|
||||||
|
let item = CorpusItemId::parse("item:nibu:1").unwrap();
|
||||||
|
let speaker = EntityId::parse("entity:other").unwrap();
|
||||||
|
let prompt = Prompt::lower(
|
||||||
|
&view,
|
||||||
|
&item,
|
||||||
|
&speaker,
|
||||||
|
"en-US",
|
||||||
|
&cast_inv.inventory().unwrap(),
|
||||||
|
&held,
|
||||||
|
&weksa::Ontology::empty(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let text = prompt.text();
|
||||||
|
assert!(
|
||||||
|
text.contains("\n\nentities: {\"entity:nibu\":[\"Nibu\"]}\n\n"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(!text.contains("SPEAKERCANARY"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run refuses a panel language that lacks a live Train or a live Validation
|
||||||
|
/// item: one no item carries, one with only a Test item, one with only Train.
|
||||||
|
#[test]
|
||||||
|
fn a_panel_language_no_item_carries_is_refused_at_open() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let d = dir.path();
|
||||||
|
// Builds the corpus, entity and ontology stores the run reads.
|
||||||
|
drop(common::run_over(d));
|
||||||
|
let entities = common::entities_in(d);
|
||||||
|
let mut corpus = CorpusStore::open(&d.join("c.cc")).unwrap();
|
||||||
|
for (key, language, bucket) in [("item:jp:1", "ja-JP", 90), ("item:pt:1", "pt-BR", 10)] {
|
||||||
|
let mut item = common::item_in(
|
||||||
|
key,
|
||||||
|
&format!("line of {key}"),
|
||||||
|
&common::group_in_bucket(bucket),
|
||||||
|
common::cast(),
|
||||||
|
);
|
||||||
|
item.language = language.into();
|
||||||
|
corpus.add(item, &entities).unwrap();
|
||||||
|
}
|
||||||
|
drop(corpus);
|
||||||
|
let create = |panel: &[&str]| {
|
||||||
|
weksa::run::RunStores::create(
|
||||||
|
&d.join(format!("run-{}", panel.join("-"))),
|
||||||
|
"t",
|
||||||
|
weksa::run::RunPaths {
|
||||||
|
corpus: &d.join("c.cc"),
|
||||||
|
inventory: &d.join("i.cc"),
|
||||||
|
entities: &d.join("entities.cc"),
|
||||||
|
ontology: &d.join("ontology.cc"),
|
||||||
|
speaker_state: &common::speaker_state_beside(&d.join("ontology.cc")),
|
||||||
|
},
|
||||||
|
"fake-model",
|
||||||
|
"bag",
|
||||||
|
weksa::run::LowererReach::ToolLessRelay,
|
||||||
|
weksa::corpus::ModelFamily::Unrecorded,
|
||||||
|
&panel.iter().map(|t| (*t).to_string()).collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert!(create(&["en-US"]).is_ok());
|
||||||
|
for panel in [&["ja-JP"][..], &["pt-BR"], &["xx-XX"], &["en-US", "ja-JP"]] {
|
||||||
|
assert!(
|
||||||
|
matches!(create(panel), Err(weksa::run::RunError::Panel)),
|
||||||
|
"{panel:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run keeps its recall probes in `recall` under its own root.
|
||||||
|
#[test]
|
||||||
|
fn a_run_keeps_its_recall_under_its_root() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = common::run_over(dir.path());
|
||||||
|
assert_eq!(run.recall_dir(), dir.path().join("run").join("recall"));
|
||||||
|
}
|
||||||
402
crates/weksa/tests/ontology_mint.rs
Normal file
402
crates/weksa/tests/ontology_mint.rs
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
//! The ontology mint (`weksa loop mint-ontology`): a Train-only call that drafts
|
||||||
|
//! entries for `weksa ontology admit`. It shows a model the Train lines of one
|
||||||
|
//! panel language, the features a condition can read and that language's entry
|
||||||
|
//! lines; it admits nothing and writes nothing of the run.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use common::{cast, entities_in, group_in_bucket, item_in};
|
||||||
|
use weksa::context::Prompt;
|
||||||
|
use weksa::corpus::{ModelFamily, Source};
|
||||||
|
use weksa::inventory::{Change, seed};
|
||||||
|
use weksa::model::{AnswerFiles, ModelPort, PortError};
|
||||||
|
use weksa::run::{LowererReach, OntologyMintOutcome, RunPaths, RunStores};
|
||||||
|
use weksa::speaker::Annotator;
|
||||||
|
use weksa::{CorpusStore, InventoryStore, OntologyStore, Slot, ValueKind};
|
||||||
|
|
||||||
|
/// Records every prompt shown; answers with `reply`.
|
||||||
|
struct Record {
|
||||||
|
prompts: Vec<(String, Vec<String>)>,
|
||||||
|
reply: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
fn new(reply: &str) -> Self {
|
||||||
|
Self { prompts: vec![], reply: reply.to_string() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelPort for Record {
|
||||||
|
fn model(&self) -> &str {
|
||||||
|
"record"
|
||||||
|
}
|
||||||
|
fn answer(&mut self, p: &Prompt) -> Result<String, PortError> {
|
||||||
|
self.prompts.push((p.text().to_string(), p.item_keys().to_vec()));
|
||||||
|
Ok(self.reply.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public() -> Source {
|
||||||
|
Source::PublicDomain {
|
||||||
|
work: "A Work".into(),
|
||||||
|
author: "An Author".into(),
|
||||||
|
edition_url: "https://example.org/e".into(),
|
||||||
|
licence: "CC0".into(),
|
||||||
|
jurisdiction: "US".into(),
|
||||||
|
translator: "A Translator".into(),
|
||||||
|
edition_year: 1900,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STANCE_G: &str = "feature:60";
|
||||||
|
const DISC_FLAG: &str = "feature:61";
|
||||||
|
const REF_ONLY: &str = "feature:62";
|
||||||
|
const PRED_ONLY: &str = "feature:63";
|
||||||
|
const STANCE_RETIRED: &str = "feature:64";
|
||||||
|
const SCENE_G: &str = "feature:65";
|
||||||
|
const SCENE_OPT: &str = "feature:66";
|
||||||
|
|
||||||
|
/// A fixture: items in en-US and ja-JP across every split and a withdrawal, an
|
||||||
|
/// inventory with condition-readable and unreadable features, an ontology with
|
||||||
|
/// en-US and ja-JP entries, and a run over them with `panel`.
|
||||||
|
fn fixture(dir: &Path, panel: &[&str]) -> RunStores {
|
||||||
|
let entities = entities_in(dir);
|
||||||
|
let mut corpus = CorpusStore::open(&dir.join("c.cc")).unwrap();
|
||||||
|
let rows = [
|
||||||
|
("item:en:1", "en-US", "entrain mark one", 10, cast(), false),
|
||||||
|
("item:en:2", "en-US", "entrain mark two", 11, cast(), false),
|
||||||
|
("item:en:3", "en-US", "envalid mark three", 50, cast(), false),
|
||||||
|
("item:en:4", "en-US", "engone mark four", 12, cast(), true),
|
||||||
|
("item:en:5", "en-US", "entest mark five", 90, public(), false),
|
||||||
|
("item:ja:1", "ja-JP", "jatrain mark one", 13, cast(), false),
|
||||||
|
("item:ja:2", "ja-JP", "javalid mark two", 51, cast(), false),
|
||||||
|
("item:ja:3", "ja-JP", "jatest mark three", 91, public(), false),
|
||||||
|
];
|
||||||
|
for (id, language, text, bucket, source, withdrawn) in rows {
|
||||||
|
let mut item = item_in(id, text, &group_in_bucket(bucket), source);
|
||||||
|
item.language = language.into();
|
||||||
|
corpus.add(item, &entities).unwrap();
|
||||||
|
if withdrawn {
|
||||||
|
corpus.withdraw(id).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(corpus);
|
||||||
|
|
||||||
|
let mut inv = InventoryStore::open(&dir.join("i.cc")).unwrap();
|
||||||
|
let (rev1, entries) = seed();
|
||||||
|
inv.admit_revision(rev1, entries).unwrap();
|
||||||
|
let f = |id, kind, slots: &[Slot]| common::feature(id, kind, slots);
|
||||||
|
let features = vec![
|
||||||
|
f(STANCE_G, ValueKind::Grade, &[Slot::Stance]),
|
||||||
|
f(DISC_FLAG, ValueKind::Flag, &[Slot::Discourse]),
|
||||||
|
f(REF_ONLY, ValueKind::Grade, &[Slot::Referent]),
|
||||||
|
f(PRED_ONLY, ValueKind::Grade, &[Slot::Predication]),
|
||||||
|
f(STANCE_RETIRED, ValueKind::Grade, &[Slot::Stance]),
|
||||||
|
f(SCENE_G, ValueKind::Grade, &[Slot::Scene]),
|
||||||
|
f(SCENE_OPT, ValueKind::Options(weksa::OptionSet::new(vec![weksa::OptionLabel::parse("below").unwrap(), weksa::OptionLabel::parse("above").unwrap()]).unwrap()), &[Slot::Scene]),
|
||||||
|
];
|
||||||
|
let changes = features.iter().map(|e| Change::Add(e.id)).collect();
|
||||||
|
let mut features = features;
|
||||||
|
for e in &mut features {
|
||||||
|
e.introduced_rev = 2;
|
||||||
|
}
|
||||||
|
inv.admit_revision(common::rev_with(changes, 2, Some(1)), features).unwrap();
|
||||||
|
inv.admit_revision(
|
||||||
|
common::rev_with(vec![Change::Retire(common::iv(STANCE_RETIRED))], 3, Some(2)),
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut onto = OntologyStore::open(&dir.join("ontology.cc")).unwrap();
|
||||||
|
let annotator = Annotator::Model { family: ModelFamily::Anthropic, generator: "seed".into() };
|
||||||
|
let entry = |language: &str, choice: &str, examples: &[&str]| {
|
||||||
|
weksa::encode::OntologyDraft::parse(&format!(
|
||||||
|
r#"{{"entries":[{{"layer":"Grammar","choice":"{choice}","examples":[{}]}}]}}"#,
|
||||||
|
examples.iter().map(|x| format!("\"{x}\"")).collect::<Vec<_>>().join(",")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
.into_entries(language, 3, 1, &annotator)
|
||||||
|
};
|
||||||
|
onto.admit(entry("en-US", "enchoice marker", &["enexample marker"]), &inv).unwrap();
|
||||||
|
onto.admit(entry("ja-JP", "jachoice marker", &["jaexample marker"]), &inv).unwrap();
|
||||||
|
drop(onto);
|
||||||
|
drop(inv);
|
||||||
|
|
||||||
|
std::fs::write(dir.join("speaker-state.cc"), [0x90]).unwrap();
|
||||||
|
let panel: Vec<String> = panel.iter().map(|s| s.to_string()).collect();
|
||||||
|
RunStores::create(
|
||||||
|
&dir.join("run"),
|
||||||
|
"t",
|
||||||
|
RunPaths {
|
||||||
|
corpus: &dir.join("c.cc"),
|
||||||
|
inventory: &dir.join("i.cc"),
|
||||||
|
entities: &dir.join("entities.cc"),
|
||||||
|
ontology: &dir.join("ontology.cc"),
|
||||||
|
speaker_state: &dir.join("speaker-state.cc"),
|
||||||
|
},
|
||||||
|
"fake-model",
|
||||||
|
"bag",
|
||||||
|
LowererReach::ToolLessRelay,
|
||||||
|
ModelFamily::Unrecorded,
|
||||||
|
&panel,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAFT: &str =
|
||||||
|
r#"{"entries":[{"layer":"Discourse","when":[],"choice":"a pattern","examples":[]}]}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mint_ontology_prompt_shows_only_train_items_of_the_language() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US", "ja-JP"]);
|
||||||
|
let mut m = Record::new(DRAFT);
|
||||||
|
run.mint_ontology(&mut m, "en-US").unwrap();
|
||||||
|
let (text, keys) = &m.prompts[0];
|
||||||
|
assert!(text.contains("language: en-US"));
|
||||||
|
assert_eq!(keys, &["item:en:1", "item:en:2"]);
|
||||||
|
for shown in ["entrain mark one", "entrain mark two"] {
|
||||||
|
assert!(text.contains(shown), "{shown}");
|
||||||
|
}
|
||||||
|
for hidden in [
|
||||||
|
"envalid", "engone", "entest", "jatrain", "javalid", "jatest", "item:en:3", "item:en:5",
|
||||||
|
"item:ja",
|
||||||
|
] {
|
||||||
|
assert!(!text.contains(hidden), "the en-US mint showed {hidden}");
|
||||||
|
}
|
||||||
|
let mut m = Record::new(DRAFT);
|
||||||
|
run.mint_ontology(&mut m, "ja-JP").unwrap();
|
||||||
|
let (text, keys) = &m.prompts[0];
|
||||||
|
assert_eq!(keys, &["item:ja:1"]);
|
||||||
|
assert!(text.contains("jatrain mark one") && !text.contains("entrain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mint_ontology_refuses_a_language_outside_the_panel() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US"]);
|
||||||
|
let mut m = Record::new(DRAFT);
|
||||||
|
// ja-JP has Train items in the corpus but is not in the panel.
|
||||||
|
for (language, code) in [("ja-JP", "language-not-in-panel"), ("fr-FR", "language-not-in-panel")] {
|
||||||
|
assert_eq!(
|
||||||
|
run.mint_ontology(&mut m, language).unwrap(),
|
||||||
|
OntologyMintOutcome::Refused { code }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(m.prompts.is_empty(), "a refused mint asked the model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mint_ontology_prompt_lists_only_condition_readable_features() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US"]);
|
||||||
|
let mut m = Record::new(DRAFT);
|
||||||
|
run.mint_ontology(&mut m, "en-US").unwrap();
|
||||||
|
let text = &m.prompts[0].0;
|
||||||
|
let listed = |id: &str| text.contains(&format!("\n{id}: "));
|
||||||
|
for id in [STANCE_G, DISC_FLAG, SCENE_G] {
|
||||||
|
assert!(listed(id), "{id} is readable by a condition");
|
||||||
|
}
|
||||||
|
for id in [REF_ONLY, PRED_ONLY, STANCE_RETIRED] {
|
||||||
|
assert!(!listed(id), "{id} is not");
|
||||||
|
}
|
||||||
|
// Nothing that is not a feature: no concept or role line.
|
||||||
|
assert!(!text.contains("\nconcept:") && !text.contains("\nrole:"));
|
||||||
|
assert!(text.contains("features rev:3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mint_ontology_prompt_lists_only_the_languages_existing_entries() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US", "ja-JP"]);
|
||||||
|
let mut m = Record::new(DRAFT);
|
||||||
|
run.mint_ontology(&mut m, "en-US").unwrap();
|
||||||
|
let text = &m.prompts[0].0;
|
||||||
|
assert!(text.contains("onto:en-US:1 enchoice marker"));
|
||||||
|
for hidden in ["jachoice", "onto:ja-JP", "enexample", "jaexample"] {
|
||||||
|
assert!(!text.contains(hidden), "{hidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digests(dir: &Path) -> Vec<(String, Vec<u8>)> {
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut stack = vec![dir.to_path_buf()];
|
||||||
|
while let Some(d) = stack.pop() {
|
||||||
|
for e in std::fs::read_dir(&d).unwrap() {
|
||||||
|
let p = e.unwrap().path();
|
||||||
|
if p.is_dir() {
|
||||||
|
stack.push(p);
|
||||||
|
} else if !p.starts_with(dir.join("run").join("ontology-mint")) {
|
||||||
|
out.push((p.display().to_string(), std::fs::read(&p).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mint_ontology_writes_nothing_but_its_prompt_and_answer_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US", "ja-JP"]);
|
||||||
|
let before = digests(dir.path());
|
||||||
|
let mut files = AnswerFiles::new(run.ontology_mint_dir(), "m".into(), vec![]);
|
||||||
|
let OntologyMintOutcome::Unanswered { prompt } =
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap()
|
||||||
|
else {
|
||||||
|
panic!("unanswered");
|
||||||
|
};
|
||||||
|
std::fs::write(run.ontology_mint_dir().join(format!("{prompt}.answer")), DRAFT).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap(),
|
||||||
|
OntologyMintOutcome::Drafted { entries: 1, .. }
|
||||||
|
));
|
||||||
|
assert_eq!(digests(dir.path()), before, "the mint wrote outside ontology-mint");
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(run.ontology_mint_dir())
|
||||||
|
.unwrap()
|
||||||
|
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names, [format!("{prompt}.answer"), format!("{prompt}.prompt")]);
|
||||||
|
drop(run);
|
||||||
|
RunStores::open(&dir.path().join("run")).expect("no Drift after a mint");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mint_ontology_unanswered_then_drafted_then_refused() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US"]);
|
||||||
|
let mut files = AnswerFiles::new(run.ontology_mint_dir(), "m".into(), vec![]);
|
||||||
|
let OntologyMintOutcome::Unanswered { prompt } =
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap()
|
||||||
|
else {
|
||||||
|
panic!("unanswered");
|
||||||
|
};
|
||||||
|
let mint = run.ontology_mint_dir();
|
||||||
|
assert!(mint.join(format!("{prompt}.prompt")).exists());
|
||||||
|
let answer = mint.join(format!("{prompt}.answer"));
|
||||||
|
std::fs::write(&answer, DRAFT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap(),
|
||||||
|
OntologyMintOutcome::Drafted { prompt: prompt.clone(), entries: 1 }
|
||||||
|
);
|
||||||
|
let two = r#"{"entries":[{"layer":"Grammar","choice":"a"},{"layer":"Lexicon","choice":"b"}]}"#;
|
||||||
|
std::fs::write(&answer, two).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap(),
|
||||||
|
OntologyMintOutcome::Drafted { entries: 2, .. }
|
||||||
|
));
|
||||||
|
let unknown = r#"{"entries":[{"layer":"Grammar","choice":"a","key":"x"}]}"#;
|
||||||
|
std::fs::write(&answer, unknown).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap(),
|
||||||
|
OntologyMintOutcome::Refused { code: "unknown-field" }
|
||||||
|
);
|
||||||
|
std::fs::write(&answer, "not json").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap(),
|
||||||
|
OntologyMintOutcome::Refused { code: "syntax" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mint_answer_admits_into_a_copy_and_a_new_run_pins_the_copy() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US"]);
|
||||||
|
let pinned = std::fs::read(dir.path().join("ontology.cc")).unwrap();
|
||||||
|
let pin = run.record().ontology_digest.clone();
|
||||||
|
let draft = format!(
|
||||||
|
r#"{{"entries":[{{"layer":"Discourse","when":[{{"slot":"Stance","feature":"{STANCE_G}","test":{{"AtLeast":500}}}}],"choice":"a grown choice","examples":[]}}]}}"#
|
||||||
|
);
|
||||||
|
let mut files = AnswerFiles::new(run.ontology_mint_dir(), "m".into(), vec![]);
|
||||||
|
let OntologyMintOutcome::Unanswered { prompt } =
|
||||||
|
run.mint_ontology(&mut files, "en-US").unwrap()
|
||||||
|
else {
|
||||||
|
panic!("unanswered");
|
||||||
|
};
|
||||||
|
let answer = run.ontology_mint_dir().join(format!("{prompt}.answer"));
|
||||||
|
std::fs::write(&answer, &draft).unwrap();
|
||||||
|
|
||||||
|
let copy = dir.path().join("grown.cc");
|
||||||
|
std::fs::copy(dir.path().join("ontology.cc"), ©).unwrap();
|
||||||
|
let inv = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
|
||||||
|
let mut grown = OntologyStore::open(©).unwrap();
|
||||||
|
let annotator = Annotator::Model { family: ModelFamily::Anthropic, generator: "minter".into() };
|
||||||
|
let first_n = grown.ontology().unwrap().entries().iter().filter(|e| e.language == "en-US").count() as u32 + 1;
|
||||||
|
let entries = weksa::encode::OntologyDraft::parse(&std::fs::read_to_string(&answer).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.into_entries("en-US", 3, first_n, &annotator);
|
||||||
|
grown.admit(entries, &inv).unwrap();
|
||||||
|
drop(grown);
|
||||||
|
|
||||||
|
assert_eq!(std::fs::read(dir.path().join("ontology.cc")).unwrap(), pinned);
|
||||||
|
let again = RunStores::open(&dir.path().join("run")).unwrap();
|
||||||
|
assert_eq!(again.record().ontology_digest, pin);
|
||||||
|
|
||||||
|
let second = RunStores::create(
|
||||||
|
&dir.path().join("run-grown"),
|
||||||
|
"t",
|
||||||
|
RunPaths {
|
||||||
|
corpus: &dir.path().join("c.cc"),
|
||||||
|
inventory: &dir.path().join("i.cc"),
|
||||||
|
entities: &dir.path().join("entities.cc"),
|
||||||
|
ontology: ©,
|
||||||
|
speaker_state: &dir.path().join("speaker-state.cc"),
|
||||||
|
},
|
||||||
|
"fake-model",
|
||||||
|
"bag",
|
||||||
|
LowererReach::ToolLessRelay,
|
||||||
|
ModelFamily::Unrecorded,
|
||||||
|
&["en-US".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(second.record().ontology_digest, pin);
|
||||||
|
|
||||||
|
// A condition on a feature outside the inventory is the store's to refuse.
|
||||||
|
let outside = draft.replace(STANCE_G, "feature:99");
|
||||||
|
let entries = weksa::encode::OntologyDraft::parse(&outside)
|
||||||
|
.unwrap()
|
||||||
|
.into_entries("en-US", 3, 9, &annotator);
|
||||||
|
let mut grown = OntologyStore::open(©).unwrap();
|
||||||
|
assert!(grown.admit(entries, &inv).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line after `example ` in a mint prompt.
|
||||||
|
fn example_of(text: &str) -> &str {
|
||||||
|
text.lines().find_map(|l| l.strip_prefix("example ")).expect("the prompt carries an example")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mint_prompts_own_example_parses_and_admits_through_the_door() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US"]);
|
||||||
|
let mut m = Record::new(DRAFT);
|
||||||
|
run.mint_ontology(&mut m, "en-US").unwrap();
|
||||||
|
let example = example_of(&m.prompts[0].0);
|
||||||
|
// It teaches the door's spelling: a capitalized layer, feature ids, each test form.
|
||||||
|
assert!(example.contains(r#""layer":"Grammar""#), "{example}");
|
||||||
|
for form in [r#"{"AtLeast":500}"#, r#"{"Is":{"Option":"#, r#"{"Is":{"Flag":true}}"#] {
|
||||||
|
assert!(example.contains(form), "{form} in {example}");
|
||||||
|
}
|
||||||
|
let draft = weksa::encode::OntologyDraft::parse(example).unwrap();
|
||||||
|
let inv = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
|
||||||
|
let copy = dir.path().join("grown.cc");
|
||||||
|
std::fs::copy(dir.path().join("ontology.cc"), ©).unwrap();
|
||||||
|
let mut grown = OntologyStore::open(©).unwrap();
|
||||||
|
let annotator = Annotator::Model { family: ModelFamily::Anthropic, generator: "minter".into() };
|
||||||
|
grown.admit(draft.into_entries("en-US", 3, 2, &annotator), &inv).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mint_example_names_only_features_the_prompt_lists() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let run = fixture(dir.path(), &["en-US"]);
|
||||||
|
let mut m = Record::new(DRAFT);
|
||||||
|
run.mint_ontology(&mut m, "en-US").unwrap();
|
||||||
|
let example = example_of(&m.prompts[0].0);
|
||||||
|
for unreadable in [REF_ONLY, PRED_ONLY, STANCE_RETIRED] {
|
||||||
|
assert!(!example.contains(unreadable), "{unreadable}");
|
||||||
|
}
|
||||||
|
}
|
||||||
1384
crates/weksa/tests/original_corpus.rs
Normal file
1384
crates/weksa/tests/original_corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,156 +1,463 @@
|
||||||
//! The speaker's relation to the addressee as a lowering input: typed feature
|
//! The speaker's relation to the addressee as a lowering input. `with_scene`
|
||||||
//! values of slot `Scene`, checked against the inventory at the view's revision,
|
//! names neither speaker nor scene: the view knows its corpus item, so the
|
||||||
//! never wording, and only for a packet that names an addressee.
|
//! speaker is the item's speaker and the scene is the store's latest for the
|
||||||
|
//! item's group. The relation is the one from that speaker to the addressee
|
||||||
|
//! referent's entity. Typed feature values only, never wording.
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
use std::path::Path;
|
use common::{cast, eid, entities_in, grade, item_in, lab, nd};
|
||||||
|
use weksa::inventory::{proof_revision, seed, speaker_scene_revision};
|
||||||
use common::{grade, iv, lab};
|
|
||||||
use weksa::inventory::{proof_revision, scene_revision, seed};
|
|
||||||
use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet};
|
use weksa::packet::{FeatValue, FeatureValue, Packet, proof_packet};
|
||||||
|
use weksa::speaker::{Annotator, PairRelation, Party, Scene};
|
||||||
use weksa::{
|
use weksa::{
|
||||||
CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, PacketStore, Refusal, lower,
|
CorpusStore, EntityStore, Inventory, InventoryStore, LoweringInput, NameSpan, PacketStore,
|
||||||
|
Refusal, SceneRead, SpeakerStateStore, StoreError,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn store() -> (InventoryStore, Inventory, tempfile::TempDir) {
|
struct W {
|
||||||
|
dir: tempfile::TempDir,
|
||||||
|
inventory: InventoryStore,
|
||||||
|
corpus: CorpusStore,
|
||||||
|
entities: EntityStore,
|
||||||
|
state: SpeakerStateStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `g` holds `item:nibu:1`: Nibu says "Brokkr, come here." Group `h`
|
||||||
|
/// holds `item:nibu:2`, also spoken by Nibu.
|
||||||
|
fn world() -> W {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut store = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
|
let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
|
||||||
for (rev, entries) in [seed(), proof_revision(), scene_revision()] {
|
for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision(3)] {
|
||||||
store.admit_revision(rev, entries).unwrap();
|
inventory.admit_revision(rev, entries).unwrap();
|
||||||
}
|
}
|
||||||
let inv = store.inventory().unwrap();
|
let entities = entities_in(dir.path());
|
||||||
(store, inv, dir)
|
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
|
||||||
}
|
let mut first = item_in("item:nibu:1", "Brokkr, come here.", "g", cast());
|
||||||
|
first.names = vec![NameSpan {
|
||||||
fn admitted(packet: &Packet, inventory: &InventoryStore) -> LoweringInput {
|
entity: eid("brokkr"),
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
start: 0,
|
||||||
let corpus = CorpusStore::open(&root.join("data/corpus/corpus.cc")).unwrap();
|
end: 6,
|
||||||
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
|
}];
|
||||||
let dir = tempfile::tempdir().unwrap();
|
corpus.add(first, &entities).unwrap();
|
||||||
PacketStore::open(&dir.path().join("p.cc"))
|
corpus
|
||||||
.unwrap()
|
.add(item_in("item:nibu:2", "Go away.", "h", cast()), &entities)
|
||||||
.admit(packet.clone(), inventory, &corpus, &entities)
|
.unwrap();
|
||||||
.unwrap()
|
let state = SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
|
||||||
}
|
W {
|
||||||
|
dir,
|
||||||
fn at_rev(rev: u32) -> Packet {
|
inventory,
|
||||||
let mut p = proof_packet();
|
corpus,
|
||||||
p.inventory_rev = rev;
|
entities,
|
||||||
p
|
state,
|
||||||
}
|
|
||||||
|
|
||||||
fn feat(id: &str, value: FeatValue) -> FeatureValue {
|
|
||||||
FeatureValue {
|
|
||||||
feature: iv(id),
|
|
||||||
value,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status(inv: &Inventory) -> String {
|
fn inv(w: &W) -> Inventory {
|
||||||
inv.entries()
|
w.inventory.inventory().unwrap()
|
||||||
.find(|e| e.gloss == "relative_status")
|
|
||||||
.unwrap()
|
|
||||||
.id
|
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn id_of(inv: &Inventory, gloss: &str) -> String {
|
fn feat(gloss: &str, value: FeatValue) -> FeatureValue {
|
||||||
inv.entries()
|
let feature = seed()
|
||||||
|
.1
|
||||||
|
.into_iter()
|
||||||
|
.chain(speaker_scene_revision(3).1)
|
||||||
.find(|e| e.gloss == gloss)
|
.find(|e| e.gloss == gloss)
|
||||||
|
.unwrap_or_else(|| panic!("no feature {gloss}"))
|
||||||
|
.id;
|
||||||
|
FeatureValue { feature, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intimacy(v: u16) -> Vec<FeatureValue> {
|
||||||
|
vec![feat("intimacy", FeatValue::Grade(grade(v)))]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status() -> Vec<FeatureValue> {
|
||||||
|
vec![
|
||||||
|
feat("relative_status", FeatValue::Option(lab("above"))),
|
||||||
|
feat("intimacy", FeatValue::Grade(grade(200))),
|
||||||
|
feat("in_group", FeatValue::Flag(false)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relation(from: &str, to: Party, features: Vec<FeatureValue>) -> PairRelation {
|
||||||
|
PairRelation {
|
||||||
|
from: eid(from),
|
||||||
|
to,
|
||||||
|
features,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nibu's relations to Brokkr and to an unnamed party, and Brokkr's to Nibu.
|
||||||
|
fn scene(group: &str, n: u32, participants: &[&str], relations: Vec<PairRelation>) -> Scene {
|
||||||
|
Scene {
|
||||||
|
group: group.into(),
|
||||||
|
n,
|
||||||
|
inventory_rev: 3,
|
||||||
|
participants: participants.iter().map(|p| eid(p)).collect(),
|
||||||
|
relations,
|
||||||
|
annotator: Annotator::Operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn standard(group: &str, n: u32) -> Scene {
|
||||||
|
scene(
|
||||||
|
group,
|
||||||
|
n,
|
||||||
|
&["nibu", "brokkr"],
|
||||||
|
vec![
|
||||||
|
relation("nibu", Party::Named(eid("brokkr")), status()),
|
||||||
|
relation("nibu", Party::Unnamed, intimacy(7)),
|
||||||
|
relation("brokkr", Party::Named(eid("nibu")), intimacy(99)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(w: &mut W, s: Scene) {
|
||||||
|
w.state
|
||||||
|
.put_scene(s, &w.inventory, &w.corpus, &w.entities)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line of `item:nibu:1` at `rev`, its addressee node `addressee` (`n1`
|
||||||
|
/// has no entity, `n3` is Brokkr), through the packet door.
|
||||||
|
fn view_of(w: &W, rev: u32, addressee: Option<&str>) -> LoweringInput {
|
||||||
|
view_to(w, rev, addressee, "brokkr")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As `view_of`, with referent `n3` naming `entity` (Nibu is the item's
|
||||||
|
/// speaker, so he may be named on the line; Brokkr is named by its text).
|
||||||
|
fn view_to(w: &W, rev: u32, addressee: Option<&str>, entity: &str) -> LoweringInput {
|
||||||
|
let mut p: Packet = proof_packet();
|
||||||
|
p.inventory_rev = rev;
|
||||||
|
p.referents[2].entity = Some(eid(entity));
|
||||||
|
p.discourse.addressee = addressee.map(nd);
|
||||||
|
// A packet key holds its packet for good, so each view gets a fresh store.
|
||||||
|
let scratch = tempfile::tempdir().unwrap();
|
||||||
|
PacketStore::open(&scratch.path().join("p.cc"))
|
||||||
|
.unwrap()
|
||||||
|
.admit(p, &w.inventory, &w.corpus, &w.entities)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id
|
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rules(r: Result<LoweringInput, Vec<Refusal>>) -> Vec<&'static str> {
|
fn rules(r: Result<LoweringInput, StoreError>) -> Vec<&'static str> {
|
||||||
r.unwrap_err().iter().map(Refusal::rule).collect()
|
match r.map(|_| ()).unwrap_err() {
|
||||||
|
StoreError::Invalid(rs) => rs.iter().map(Refusal::rule).collect(),
|
||||||
|
other => panic!("not a rule refusal: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(w: &W, view: LoweringInput) -> SceneRead {
|
||||||
|
view.with_scene(&inv(w), &w.state, &w.corpus)
|
||||||
|
.unwrap()
|
||||||
|
.scene_read()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected(rev: u32, features: Vec<FeatureValue>) -> SceneRead {
|
||||||
|
SceneRead::Selected { rev, features }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_scene_relation_reaches_the_lowering_view_and_changes_no_english() {
|
fn a_scene_relation_reaches_the_lowering_view() {
|
||||||
let (store, inv, _dir) = store();
|
let mut w = world();
|
||||||
let plain = admitted(&at_rev(3), &store);
|
put(&mut w, standard("g", 1));
|
||||||
|
let plain = view_of(&w, 3, Some("n3"));
|
||||||
|
assert_eq!(plain.scene_read(), &SceneRead::Unread);
|
||||||
assert!(plain.scene().is_empty());
|
assert!(plain.scene().is_empty());
|
||||||
let scene = vec![
|
let with = plain
|
||||||
feat(&status(&inv), FeatValue::Option(lab("above"))),
|
.clone()
|
||||||
feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(200))),
|
.with_scene(&inv(&w), &w.state, &w.corpus)
|
||||||
feat(&id_of(&inv, "in_group"), FeatValue::Flag(false)),
|
.unwrap();
|
||||||
];
|
assert_eq!(with.scene(), status().as_slice());
|
||||||
let with = plain.clone().with_scene(&inv, scene.clone()).unwrap();
|
assert_eq!(with.scene_read(), &selected(1, status()));
|
||||||
assert_eq!(with.scene(), scene.as_slice());
|
}
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
||||||
let entities = EntityStore::open(&root.join("data/entity/entity.cc")).unwrap();
|
#[test]
|
||||||
|
fn with_scene_says_which_of_five_things_it_found() {
|
||||||
|
let mut w = world();
|
||||||
|
// No scene for the group.
|
||||||
|
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene);
|
||||||
|
put(&mut w, standard("g", 1));
|
||||||
|
// A referent with an entity selects the relation to that entity; a
|
||||||
|
// referent without one, the relation to an unnamed party; and the
|
||||||
|
// speaker's relation, never Brokkr's to Nibu.
|
||||||
|
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(1, status()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lower(&with, &inv, &entities).unwrap(),
|
read(&w, view_of(&w, 3, Some("n1"))),
|
||||||
lower(&plain, &inv, &entities).unwrap()
|
selected(1, intimacy(7))
|
||||||
|
);
|
||||||
|
// The scene was read and the packet names no addressee.
|
||||||
|
assert_eq!(
|
||||||
|
read(&w, view_of(&w, 3, None)),
|
||||||
|
SceneRead::NoAddressee { rev: 1 }
|
||||||
|
);
|
||||||
|
// The addressee is the speaker.
|
||||||
|
assert_eq!(
|
||||||
|
read(&w, view_to(&w, 3, Some("n3"), "nibu")),
|
||||||
|
SceneRead::SelfAddressed { rev: 1 }
|
||||||
|
);
|
||||||
|
// The scene holds no relation to this addressee.
|
||||||
|
put(
|
||||||
|
&mut w,
|
||||||
|
scene(
|
||||||
|
"g",
|
||||||
|
2,
|
||||||
|
&["nibu", "brokkr"],
|
||||||
|
vec![relation("nibu", Party::Unnamed, intimacy(7))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read(&w, view_of(&w, 3, Some("n3"))),
|
||||||
|
SceneRead::NoRelation { rev: 2 }
|
||||||
|
);
|
||||||
|
// A relation with no features is found, not absent.
|
||||||
|
put(
|
||||||
|
&mut w,
|
||||||
|
scene(
|
||||||
|
"g",
|
||||||
|
3,
|
||||||
|
&["nibu", "brokkr"],
|
||||||
|
vec![relation("nibu", Party::Named(eid("brokkr")), vec![])],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), selected(3, vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_addressee_the_scene_lacks_never_falls_back_to_unnamed() {
|
||||||
|
let mut w = world();
|
||||||
|
// Brokkr is not a participant and Nibu has only an unnamed relation.
|
||||||
|
put(
|
||||||
|
&mut w,
|
||||||
|
scene(
|
||||||
|
"g",
|
||||||
|
1,
|
||||||
|
&["nibu", "player"],
|
||||||
|
vec![relation("nibu", Party::Unnamed, intimacy(7))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read(&w, view_of(&w, 3, Some("n3"))),
|
||||||
|
SceneRead::NoRelation { rev: 1 }
|
||||||
|
);
|
||||||
|
// A participant without a relation behaves the same.
|
||||||
|
put(
|
||||||
|
&mut w,
|
||||||
|
scene(
|
||||||
|
"g",
|
||||||
|
2,
|
||||||
|
&["nibu", "brokkr"],
|
||||||
|
vec![relation("nibu", Party::Unnamed, intimacy(7))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read(&w, view_of(&w, 3, Some("n3"))),
|
||||||
|
SceneRead::NoRelation { rev: 2 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_relation_needs_an_addressee() {
|
fn the_speaker_is_the_items_not_the_first_participant() {
|
||||||
let (store, inv, _dir) = store();
|
let mut w = world();
|
||||||
let mut p = at_rev(3);
|
// Brokkr is listed first and has an unnamed relation of his own.
|
||||||
p.discourse.addressee = None;
|
put(
|
||||||
let view = admitted(&p, &store);
|
&mut w,
|
||||||
let scene = vec![feat(&id_of(&inv, "in_group"), FeatValue::Flag(true))];
|
scene(
|
||||||
assert_eq!(rules(view.clone().with_scene(&inv, scene)), ["V13"]);
|
"g",
|
||||||
assert!(view.with_scene(&inv, vec![]).unwrap().scene().is_empty());
|
1,
|
||||||
|
&["brokkr", "nibu"],
|
||||||
|
vec![
|
||||||
|
relation("brokkr", Party::Unnamed, intimacy(99)),
|
||||||
|
relation("nibu", Party::Unnamed, intimacy(7)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read(&w, view_of(&w, 3, Some("n1"))),
|
||||||
|
selected(1, intimacy(7))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_relation_value_is_checked_like_any_feature() {
|
fn the_scene_is_the_items_group_and_its_latest_revision() {
|
||||||
let (store, inv, _dir) = store();
|
let mut w = world();
|
||||||
let view = admitted(&at_rev(3), &store);
|
// Group h holds a different relation for the same pair.
|
||||||
let status = status(&inv);
|
put(
|
||||||
let intimacy = id_of(&inv, "intimacy");
|
&mut w,
|
||||||
let take = |scene| view.clone().with_scene(&inv, scene);
|
scene(
|
||||||
// A label the feature does not declare.
|
"h",
|
||||||
assert_eq!(
|
1,
|
||||||
rules(take(vec![feat(&status, FeatValue::Option(lab("lordly")))])),
|
&["nibu", "brokkr"],
|
||||||
["V7"]
|
vec![relation("nibu", Party::Named(eid("brokkr")), intimacy(1))],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// A value of the wrong kind.
|
// No scene for the item's group, whatever other groups hold.
|
||||||
assert_eq!(
|
assert_eq!(read(&w, view_of(&w, 3, Some("n3"))), SceneRead::NoScene);
|
||||||
rules(take(vec![feat(&intimacy, FeatValue::Flag(true))])),
|
// Revisions: the highest n is read.
|
||||||
["V7"]
|
put(&mut w, standard("g", 1));
|
||||||
);
|
let mut second = standard("g", 2);
|
||||||
// A feature declared for another slot: seed feature 1 sits in Predication.
|
second.relations[0].features = intimacy(5);
|
||||||
assert_eq!(
|
put(&mut w, second);
|
||||||
rules(take(vec![feat(
|
let got = view_of(&w, 3, Some("n3"))
|
||||||
"feature:1",
|
.with_scene(&inv(&w), &w.state, &w.corpus)
|
||||||
FeatValue::Option(lab("negative"))
|
.unwrap();
|
||||||
)])),
|
assert_eq!(got.scene_read(), &selected(2, intimacy(5)));
|
||||||
["V8"]
|
assert_eq!(got.scene(), intimacy(5).as_slice());
|
||||||
);
|
// A read replaces the earlier one: a store without the scene says so.
|
||||||
// An id that is not a feature at all.
|
let empty = SpeakerStateStore::open(&w.dir.path().join("empty.cc")).unwrap();
|
||||||
assert_eq!(
|
let cleared = got.with_scene(&inv(&w), &empty, &w.corpus).unwrap();
|
||||||
rules(take(vec![feat("concept:3", FeatValue::Flag(true))])),
|
assert_eq!(cleared.scene_read(), &SceneRead::NoScene);
|
||||||
["V6"]
|
assert!(cleared.scene().is_empty());
|
||||||
);
|
|
||||||
// Twice in one list.
|
|
||||||
let twice = vec![
|
|
||||||
feat(&intimacy, FeatValue::Grade(grade(1))),
|
|
||||||
feat(&intimacy, FeatValue::Grade(grade(2))),
|
|
||||||
];
|
|
||||||
assert_eq!(rules(take(twice)), ["V11"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_view_of_a_revision_before_the_scene_vocabulary_cannot_carry_one() {
|
fn with_scene_refusals() {
|
||||||
let (store, inv, _dir) = store();
|
let mut w = world();
|
||||||
// Revision 2 predates the Scene features, so their ids do not resolve there.
|
put(&mut w, standard("g", 1));
|
||||||
let view = admitted(&at_rev(2), &store);
|
// The scene was admitted at revision 3; a view of revision 2 predates the
|
||||||
let scene = vec![feat(&id_of(&inv, "intimacy"), FeatValue::Grade(grade(1)))];
|
// Scene features, so the selected relation's ids do not resolve there.
|
||||||
assert_eq!(rules(view.with_scene(&inv, scene)), ["V5"]);
|
assert_eq!(
|
||||||
|
rules(view_of(&w, 2, Some("n3")).with_scene(&inv(&w), &w.state, &w.corpus)),
|
||||||
|
["V5", "V5", "V5"]
|
||||||
|
);
|
||||||
|
// A scene that does not list the item's speaker: group k admitted while
|
||||||
|
// Brokkr was its only speaker, then an item of Nibu's joins it.
|
||||||
|
let mut item = item_in("item:brokkr:1", "Hello.", "k", cast());
|
||||||
|
item.speaker = eid("brokkr");
|
||||||
|
w.corpus.add(item, &w.entities).unwrap();
|
||||||
|
put(&mut w, scene("k", 1, &["brokkr"], vec![]));
|
||||||
|
w.corpus
|
||||||
|
.add(
|
||||||
|
item_in("item:nibu:3", "Hello there.", "k", cast()),
|
||||||
|
&w.entities,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for addressee in [Some("n3"), None] {
|
||||||
|
let mut p = proof_packet();
|
||||||
|
p.inventory_rev = 3;
|
||||||
|
p.provenance.corpus_item = weksa::CorpusItemId::parse("item:nibu:3").unwrap();
|
||||||
|
p.referents[2].entity = Some(eid("nibu"));
|
||||||
|
p.discourse.addressee = addressee.map(nd);
|
||||||
|
let scratch = tempfile::tempdir().unwrap();
|
||||||
|
let view = PacketStore::open(&scratch.path().join("p.cc"))
|
||||||
|
.unwrap()
|
||||||
|
.admit(p, &w.inventory, &w.corpus, &w.entities)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rules(view.with_scene(&inv(&w), &w.state, &w.corpus)),
|
||||||
|
["V16"],
|
||||||
|
"{addressee:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stance_feature_is_not_a_scene_feature() {
|
fn read_refuses_a_scene_that_stopped_covering_a_live_speaker_of_its_group() {
|
||||||
let (store, inv, _dir) = store();
|
// Group g's scene covers Nibu alone, and Nibu's is the only item: admitted.
|
||||||
let view = admitted(&at_rev(3), &store);
|
let mut w = world();
|
||||||
let anger = id_of(&inv, "anger");
|
put(&mut w, scene("g", 1, &["nibu"], vec![]));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rules(view.with_scene(&inv, vec![feat(&anger, FeatValue::Grade(grade(1)))])),
|
read(&w, view_of(&w, 3, None)),
|
||||||
["V8"]
|
SceneRead::NoAddressee { rev: 1 }
|
||||||
|
);
|
||||||
|
// Brokkr then speaks in g. The corpus door lets him in; the view of Nibu's
|
||||||
|
// own line, whose speaker the scene still lists, reads the stale scene no more.
|
||||||
|
let mut item = item_in("item:brokkr:1", "Hello.", "g", cast());
|
||||||
|
item.speaker = eid("brokkr");
|
||||||
|
w.corpus.add(item, &w.entities).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rules(view_of(&w, 3, None).with_scene(&inv(&w), &w.state, &w.corpus)),
|
||||||
|
["V16"]
|
||||||
|
);
|
||||||
|
// The put door applies the same rule, so the repair is a revision that
|
||||||
|
// covers him.
|
||||||
|
put(&mut w, scene("g", 2, &["nibu", "brokkr"], vec![]));
|
||||||
|
assert_eq!(
|
||||||
|
read(&w, view_of(&w, 3, None)),
|
||||||
|
SceneRead::NoAddressee { rev: 2 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_live_speakers_of_the_views_group_and_the_views_own_speaker_must_be_covered() {
|
||||||
|
let mut w = world();
|
||||||
|
put(&mut w, scene("g", 1, &["nibu"], vec![]));
|
||||||
|
let ok = SceneRead::NoAddressee { rev: 1 };
|
||||||
|
// A withdrawn speaker the scene lacks does not count.
|
||||||
|
let mut gone = item_in("item:brokkr:1", "Hello.", "g", cast());
|
||||||
|
gone.speaker = eid("brokkr");
|
||||||
|
w.corpus.add(gone, &w.entities).unwrap();
|
||||||
|
w.corpus.withdraw("item:brokkr:1").unwrap();
|
||||||
|
assert_eq!(read(&w, view_of(&w, 3, None)), ok);
|
||||||
|
// Nor does a live speaker the scene lacks in another group.
|
||||||
|
let mut other = item_in("item:brokkr:2", "Hello you.", "h", cast());
|
||||||
|
other.speaker = eid("brokkr");
|
||||||
|
w.corpus.add(other, &w.entities).unwrap();
|
||||||
|
assert_eq!(read(&w, view_of(&w, 3, None)), ok);
|
||||||
|
// The view's own speaker must be listed even when its item was withdrawn
|
||||||
|
// after the view was built: scene g@2 lists Brokkr alone.
|
||||||
|
let view = view_of(&w, 3, None);
|
||||||
|
w.corpus.withdraw("item:nibu:1").unwrap();
|
||||||
|
put(&mut w, scene("g", 2, &["brokkr"], vec![]));
|
||||||
|
assert_eq!(
|
||||||
|
rules(view.with_scene(&inv(&w), &w.state, &w.corpus)),
|
||||||
|
["V16"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mod select {
|
||||||
|
use super::*;
|
||||||
|
use weksa::speaker::Annotator;
|
||||||
|
use weksa::{Condition, Layer, OntologyEntry, OntologyStore, Slot, Test, select};
|
||||||
|
|
||||||
|
fn scene_feature(gloss: &str) -> weksa::InvId {
|
||||||
|
speaker_scene_revision(3).1.into_iter().find(|e| e.gloss == gloss).unwrap().id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(n: u32, slot: Slot, gloss: &str, test: Test) -> OntologyEntry {
|
||||||
|
OntologyEntry {
|
||||||
|
language: "ja-JP".into(),
|
||||||
|
n,
|
||||||
|
inventory_rev: 3,
|
||||||
|
layer: Layer::Grammar,
|
||||||
|
when: vec![Condition { slot, feature: scene_feature(gloss), test }],
|
||||||
|
choice: "a choice".into(),
|
||||||
|
examples: vec![],
|
||||||
|
annotator: Annotator::Operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_reads_the_scene_relation() {
|
||||||
|
let mut w = world();
|
||||||
|
put(&mut w, standard("g", 1));
|
||||||
|
let mut store = OntologyStore::open(&w.dir.path().join("o.cc")).unwrap();
|
||||||
|
store
|
||||||
|
.admit(
|
||||||
|
vec![
|
||||||
|
entry(1, Slot::Scene, "relative_status", Test::Is(FeatValue::Option(lab("above")))),
|
||||||
|
entry(2, Slot::Scene, "intimacy", Test::AtLeast(grade(150))),
|
||||||
|
],
|
||||||
|
&w.inventory,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ontology = store.ontology().unwrap();
|
||||||
|
let keys = |read: SceneRead, w: &W| {
|
||||||
|
let view = view_of(w, 3, Some("n3")).with_scene(&inv(w), &w.state, &w.corpus).unwrap();
|
||||||
|
assert_eq!(view.scene_read(), &read);
|
||||||
|
select(&ontology, "ja-JP", &view).iter().map(|e| e.n).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
// Brokkr is above-status at intimacy 200: both Scene entries select.
|
||||||
|
assert_eq!(keys(selected(1, status()), &w), [1, 2]);
|
||||||
|
// No scene for the group: neither selects.
|
||||||
|
let empty = world();
|
||||||
|
assert_eq!(keys(SceneRead::NoScene, &empty), Vec::<u32>::new());
|
||||||
|
// No addressee, and no relation to the addressee: neither selects.
|
||||||
|
let view = view_of(&w, 3, None).with_scene(&inv(&w), &w.state, &w.corpus).unwrap();
|
||||||
|
assert_eq!(view.scene_read(), &SceneRead::NoAddressee { rev: 1 });
|
||||||
|
assert!(select(&ontology, "ja-JP", &view).is_empty());
|
||||||
|
let mut lone = world();
|
||||||
|
put(&mut lone, scene("g", 1, &["nibu", "brokkr"], vec![relation("brokkr", Party::Named(eid("nibu")), intimacy(99))]));
|
||||||
|
assert_eq!(keys(SceneRead::NoRelation { rev: 1 }, &lone), Vec::<u32>::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use std::net::TcpListener;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use common::{cast, group_in_bucket, item_in, model};
|
use common::{Bag, cast, group_in_bucket, item_in, model};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use weksa::CorpusItem;
|
use weksa::CorpusItem;
|
||||||
use weksa::score::{
|
use weksa::score::{
|
||||||
|
|
@ -45,8 +45,8 @@ fn chrf_matches_sacrebleu_vectors() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chrf_argument_order() {
|
fn chrf_argument_order() {
|
||||||
let short = "mind the hatch";
|
let short = "shut the gate";
|
||||||
let long = "mind the hatch before the storm arrives tonight";
|
let long = "shut the gate before the storm arrives tonight";
|
||||||
let (as_hypothesis, as_reference) = (chrf(short, long), chrf(long, short));
|
let (as_hypothesis, as_reference) = (chrf(short, long), chrf(long, short));
|
||||||
assert!(
|
assert!(
|
||||||
as_hypothesis < as_reference - 1.0,
|
as_hypothesis < as_reference - 1.0,
|
||||||
|
|
@ -71,28 +71,6 @@ impl Embedder for Recorder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
fn score_one<E: Embedder>(scorer: &Scorer<E>, reference: &str, output: &str) -> ItemScore {
|
||||||
scorer
|
scorer
|
||||||
.score(&[ScoreInput { reference, output }])
|
.score(&[ScoreInput { reference, output }])
|
||||||
|
|
@ -126,8 +104,8 @@ fn chrf_whitespace_parity() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scorer_argument_order() {
|
fn scorer_argument_order() {
|
||||||
let short = "mind the hatch";
|
let short = "shut the gate";
|
||||||
let long = "mind the hatch before the storm arrives tonight";
|
let long = "shut the gate before the storm arrives tonight";
|
||||||
let scorer = Scorer::new(Bag);
|
let scorer = Scorer::new(Bag);
|
||||||
let got = score_one(&scorer, long, short);
|
let got = score_one(&scorer, long, short);
|
||||||
assert_eq!(got.chrf, chrf(short, long));
|
assert_eq!(got.chrf, chrf(short, long));
|
||||||
|
|
@ -178,7 +156,7 @@ impl Embedder for Fixed {
|
||||||
fn scorer_refuses_bad_vectors() {
|
fn scorer_refuses_bad_vectors() {
|
||||||
let run = |vectors: Vec<Vec<f32>>| {
|
let run = |vectors: Vec<Vec<f32>>| {
|
||||||
Scorer::new(Fixed(vectors)).score(&[ScoreInput {
|
Scorer::new(Fixed(vectors)).score(&[ScoreInput {
|
||||||
reference: "mind the hatch",
|
reference: "shut the gate",
|
||||||
output: "mind the door",
|
output: "mind the door",
|
||||||
}])
|
}])
|
||||||
};
|
};
|
||||||
|
|
@ -353,12 +331,6 @@ fn aggregate_by_derived_split() {
|
||||||
assert_eq!(agg.train.chrf, Some(15.0));
|
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.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.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);
|
assert_eq!(aggregate(&[]).validation.chrf, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,7 +357,6 @@ fn train(key: &str, text: &str) -> CorpusItem {
|
||||||
/// (novel, covered) per order for the Validation split.
|
/// (novel, covered) per order for the Validation split.
|
||||||
fn read(minted: &[&str], items: &[CorpusItem]) -> Vec<(usize, usize)> {
|
fn read(minted: &[&str], items: &[CorpusItem]) -> Vec<(usize, usize)> {
|
||||||
leak_diagnostic(minted, items)
|
leak_diagnostic(minted, items)
|
||||||
.validation
|
|
||||||
.coverage_by_n
|
.coverage_by_n
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| (c.novel, c.covered))
|
.map(|c| (c.novel, c.covered))
|
||||||
|
|
@ -414,7 +385,7 @@ fn leak_diagnostic_catches_chunked_glosses() {
|
||||||
test_model,
|
test_model,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let pair = d.validation.max_chrf.as_ref().unwrap();
|
let pair = d.max_chrf.as_ref().unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
pair.chrf < 50.0,
|
pair.chrf < 50.0,
|
||||||
"each chunk alone scores low: {}",
|
"each chunk alone scores low: {}",
|
||||||
|
|
@ -426,24 +397,21 @@ fn leak_diagnostic_catches_chunked_glosses() {
|
||||||
read(&minted, &[held.clone(), unrelated, train_item.clone()]),
|
read(&minted, &[held.clone(), unrelated, train_item.clone()]),
|
||||||
[(16, 12), (14, 8), (12, 4)]
|
[(16, 12), (14, 8), (12, 4)]
|
||||||
);
|
);
|
||||||
let worst = d.validation.worst.as_ref().unwrap();
|
let worst = d.worst.as_ref().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(worst.item.as_str(), worst.n, worst.covered, worst.novel),
|
(worst.item.as_str(), worst.n, worst.covered, worst.novel),
|
||||||
("item:v:1", 2, 8, 11)
|
("item:v:1", 2, 8, 11)
|
||||||
);
|
);
|
||||||
assert_eq!(d.validation.items, 2);
|
assert_eq!(d.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]);
|
let only_train = leak_diagnostic(&minted, &[train_item]);
|
||||||
assert_eq!(only_train.validation.items, 0);
|
assert_eq!(only_train.items, 0);
|
||||||
assert_eq!(only_train.validation.max_chrf, None);
|
assert_eq!(only_train.max_chrf, None);
|
||||||
assert_eq!(only_train.validation.worst, None);
|
assert_eq!(only_train.worst, None);
|
||||||
|
|
||||||
let full = leak_diagnostic(&[HELD_OUT], &[held]);
|
let full = leak_diagnostic(&[HELD_OUT], &[held]);
|
||||||
assert_eq!(full.validation.coverage_by_n[2].share, Some(1.0));
|
assert_eq!(full.coverage_by_n[2].share, Some(1.0));
|
||||||
assert!(full.validation.max_chrf.unwrap().chrf > 99.0);
|
assert!(full.max_chrf.unwrap().chrf > 99.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -485,9 +453,9 @@ fn coverage_reads_joined_labels() {
|
||||||
"kick the bucket",
|
"kick the bucket",
|
||||||
] {
|
] {
|
||||||
let d = leak_diagnostic(&[label], &items);
|
let d = leak_diagnostic(&[label], &items);
|
||||||
let by_n = &d.validation.coverage_by_n;
|
let by_n = &d.coverage_by_n;
|
||||||
assert_eq!((by_n[2].novel, by_n[2].covered), (3, 1), "{label}");
|
assert_eq!((by_n[2].novel, by_n[2].covered), (3, 1), "{label}");
|
||||||
let worst = d.validation.worst.unwrap();
|
let worst = d.worst.unwrap();
|
||||||
assert_eq!((worst.n, worst.covered), (2, 2), "{label}");
|
assert_eq!((worst.n, worst.covered), (2, 2), "{label}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -520,7 +488,7 @@ fn coverage_pooled_per_order() {
|
||||||
"left", "group", "found", "place", "storm", "after", "warn", "leave", "harbor",
|
"left", "group", "found", "place", "storm", "after", "warn", "leave", "harbor",
|
||||||
];
|
];
|
||||||
let d = leak_diagnostic(&glosses, &items);
|
let d = leak_diagnostic(&glosses, &items);
|
||||||
let by_n = &d.validation.coverage_by_n;
|
let by_n = &d.coverage_by_n;
|
||||||
assert_eq!(by_n.iter().map(|c| c.n).collect::<Vec<_>>(), [1, 2, 3]);
|
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_eq!((by_n[0].novel, by_n[0].covered), (6, 5));
|
||||||
assert!(by_n[0].share.unwrap() < 1.0);
|
assert!(by_n[0].share.unwrap() < 1.0);
|
||||||
|
|
@ -528,7 +496,7 @@ fn coverage_pooled_per_order() {
|
||||||
assert!(c.novel > 0);
|
assert!(c.novel > 0);
|
||||||
assert_eq!((c.covered, c.share), (0, Some(0.0)));
|
assert_eq!((c.covered, c.share), (0, Some(0.0)));
|
||||||
}
|
}
|
||||||
assert_eq!(d.validation.worst, None);
|
assert_eq!(d.worst, None);
|
||||||
|
|
||||||
// no novel n-gram reads None, never NaN; an empty split reads the same
|
// no novel n-gram reads None, never NaN; an empty split reads the same
|
||||||
let items = [
|
let items = [
|
||||||
|
|
@ -536,16 +504,13 @@ fn coverage_pooled_per_order() {
|
||||||
validation("item:v:1", "harbors guard the quiet"),
|
validation("item:v:1", "harbors guard the quiet"),
|
||||||
];
|
];
|
||||||
let d = leak_diagnostic(&["anything at all"], &items);
|
let d = leak_diagnostic(&["anything at all"], &items);
|
||||||
for split in [&d.validation, &d.test] {
|
assert_eq!(d.coverage_by_n.len(), 3);
|
||||||
assert_eq!(split.coverage_by_n.len(), 3);
|
assert!(
|
||||||
assert!(
|
d.coverage_by_n
|
||||||
split
|
.iter()
|
||||||
.coverage_by_n
|
.all(|c| (c.novel, c.share) == (0, None))
|
||||||
.iter()
|
);
|
||||||
.all(|c| (c.novel, c.share) == (0, None))
|
assert_eq!(d.worst, None);
|
||||||
);
|
|
||||||
assert_eq!(split.worst, None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -569,7 +534,6 @@ fn worst_item_rules() {
|
||||||
validation("item:v:2", "delta epsilon"),
|
validation("item:v:2", "delta epsilon"),
|
||||||
];
|
];
|
||||||
let w = leak_diagnostic(&["alpha beta", "delta epsilon"], &items)
|
let w = leak_diagnostic(&["alpha beta", "delta epsilon"], &items)
|
||||||
.validation
|
|
||||||
.worst
|
.worst
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -582,7 +546,6 @@ fn worst_item_rules() {
|
||||||
validation("item:v:2", "alpha beta gamma"),
|
validation("item:v:2", "alpha beta gamma"),
|
||||||
];
|
];
|
||||||
let w = leak_diagnostic(&["alpha beta", "delta epsilon"], &items)
|
let w = leak_diagnostic(&["alpha beta", "delta epsilon"], &items)
|
||||||
.validation
|
|
||||||
.worst
|
.worst
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(w.item, "item:v:1");
|
assert_eq!(w.item, "item:v:1");
|
||||||
|
|
@ -592,7 +555,6 @@ fn worst_item_rules() {
|
||||||
validation("item:v:2", "alpha beta gamma delta"),
|
validation("item:v:2", "alpha beta gamma delta"),
|
||||||
];
|
];
|
||||||
let w = leak_diagnostic(&["alpha beta gamma", "delta epsilon"], &items)
|
let w = leak_diagnostic(&["alpha beta gamma", "delta epsilon"], &items)
|
||||||
.validation
|
|
||||||
.worst
|
.worst
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((w.item.as_str(), w.covered, w.novel), ("item:v:2", 2, 3));
|
assert_eq!((w.item.as_str(), w.covered, w.novel), ("item:v:2", 2, 3));
|
||||||
|
|
@ -601,17 +563,13 @@ fn worst_item_rules() {
|
||||||
validation("item:v:1", "delta epsilon"),
|
validation("item:v:1", "delta epsilon"),
|
||||||
validation("item:v:2", "delta epsilon"),
|
validation("item:v:2", "delta epsilon"),
|
||||||
];
|
];
|
||||||
let w = leak_diagnostic(&["delta epsilon"], &items)
|
let w = leak_diagnostic(&["delta epsilon"], &items).worst.unwrap();
|
||||||
.validation
|
|
||||||
.worst
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(w.item, "item:v:1");
|
assert_eq!(w.item, "item:v:1");
|
||||||
let items = [
|
let items = [
|
||||||
validation("item:v:1", "alpha beta gamma"),
|
validation("item:v:1", "alpha beta gamma"),
|
||||||
train("item:t:1", "alpha beta"),
|
train("item:t:1", "alpha beta"),
|
||||||
];
|
];
|
||||||
let w = leak_diagnostic(&["alpha beta gamma"], &items)
|
let w = leak_diagnostic(&["alpha beta gamma"], &items)
|
||||||
.validation
|
|
||||||
.worst
|
.worst
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!((w.n, w.covered, w.novel), (2, 1, 1));
|
assert_eq!((w.n, w.covered, w.novel), (2, 1, 1));
|
||||||
|
|
@ -631,7 +589,7 @@ fn leak_diagnostic_reports_the_best_pair_and_the_first_of_ties() {
|
||||||
let b = validation("item:v:2", HELD_OUT);
|
let b = validation("item:v:2", HELD_OUT);
|
||||||
// the better minted string comes second; equal items tie, the first is kept
|
// the better minted string comes second; equal items tie, the first is kept
|
||||||
let d = leak_diagnostic(&["zzzzzz qqqqqq", HELD_OUT], &[a, b]);
|
let d = leak_diagnostic(&["zzzzzz qqqqqq", HELD_OUT], &[a, b]);
|
||||||
let pair = d.validation.max_chrf.unwrap();
|
let pair = d.max_chrf.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(pair.minted.as_str(), pair.item.as_str()),
|
(pair.minted.as_str(), pair.item.as_str()),
|
||||||
(HELD_OUT, "item:v:1")
|
(HELD_OUT, "item:v:1")
|
||||||
|
|
@ -690,3 +648,229 @@ fn every_script_alike() {
|
||||||
assert_eq!(got.chrf, 100.0, "{text}");
|
assert_eq!(got.chrf, 100.0, "{text}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (reference, hypothesis, sacrebleu 2.4.3 sentence_chrf), one near miss per
|
||||||
|
/// script: the Thai hypothesis is the reference with its vowels and tone mark
|
||||||
|
/// stripped.
|
||||||
|
const SCRIPT_PAIRS: [(&str, &str, f64); 5] = [
|
||||||
|
("민수가 왔다", "민수는 왔다", 26.0),
|
||||||
|
("Brokkr'ın gemisi geldi", "Brokkr'ın gemisi gitti", 77.7548),
|
||||||
|
("Brokkrin laiva tuli", "Brokkrin laiva meni", 73.002),
|
||||||
|
("สมชายมาแล้ว", "สมชยมแลว", 21.8989),
|
||||||
|
("ブロッカーが来た。", "ブロッカーが行った。", 54.4013),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_script_scores_a_near_miss_as_sacrebleu_does() {
|
||||||
|
let scorer = Scorer::new(Bag);
|
||||||
|
for (reference, hypothesis, want) in SCRIPT_PAIRS {
|
||||||
|
close(score_one(&scorer, reference, hypothesis).chrf, want);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_splits_at_any_non_word_character() {
|
||||||
|
let items = [
|
||||||
|
validation("item:v:1", "shut the gate before the storm"),
|
||||||
|
train("item:t:1", UNRELATED),
|
||||||
|
];
|
||||||
|
let want = read(&["shut the gate"], &items);
|
||||||
|
assert_eq!(want, [(5, 3), (5, 2), (4, 1)]);
|
||||||
|
for joiner in [
|
||||||
|
":", "\u{b7}", "'", "\u{2019}", "\u{ff3f}", "-", "/", "\u{2014}", "|", "+", "\u{ff1a}",
|
||||||
|
"\u{30fb}",
|
||||||
|
] {
|
||||||
|
let glued = format!("shut{joiner}the{joiner}gate");
|
||||||
|
assert_eq!(read(&[&glued], &items), want, "{joiner:?}");
|
||||||
|
}
|
||||||
|
// a mark, a joiner or a digit is part of its word, not a break
|
||||||
|
let items = [validation(
|
||||||
|
"item:v:1",
|
||||||
|
"\u{e2a}\u{e21}\u{e0a}\u{e32}\u{e22} \u{e21}\u{e32}\u{e41}\u{e25}\u{e49}\u{e27} 0400 \u{628}\u{200c}\u{628}",
|
||||||
|
)];
|
||||||
|
assert_eq!(
|
||||||
|
read(&["\u{e21}\u{e32}\u{e41}\u{e25}\u{e49}\u{e27}"], &items)[0],
|
||||||
|
(4, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(read(&["\u{628}\u{200c}\u{628}", "0400"], &items)[0], (4, 2));
|
||||||
|
// a joiner keeps its word whole: the minted half is not a held-out word
|
||||||
|
let items = [validation("item:v:1", "\u{628}\u{200c}\u{62a} \u{62c}")];
|
||||||
|
assert_eq!(read(&["\u{628}\u{200c}\u{62a}"], &items)[0], (2, 1));
|
||||||
|
// decomposed and composed spellings are one word
|
||||||
|
let items = [validation("item:v:1", "Cafe\u{301}")];
|
||||||
|
assert_eq!(read(&["Caf\u{e9}"], &items)[0], (1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_reads_unspaced_scripts_by_run() {
|
||||||
|
// Han, kana and Thai write no word boundaries and the scorer holds no
|
||||||
|
// dictionary, so a run of them is one word, never one word per character.
|
||||||
|
// Honest single-word glosses then read nothing above n=1, as they do in a
|
||||||
|
// spaced script.
|
||||||
|
for (held, glosses) in [
|
||||||
|
(
|
||||||
|
"\u{5c71}\u{7530}\u{592a}\u{90ce}\u{304c}\u{6765}\u{305f}",
|
||||||
|
["\u{5c71}\u{7530}", "\u{592a}\u{90ce}", "\u{6765}\u{305f}"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"\u{30d6}\u{30ed}\u{30c3}\u{30ab}\u{30fc}\u{304c}\u{6765}\u{305f}\u{3002}",
|
||||||
|
["\u{30d6}\u{30ed}", "\u{30ab}\u{30fc}", "\u{6765}"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"\u{e2a}\u{e21}\u{e0a}\u{e32}\u{e22}\u{e21}\u{e32}\u{e41}\u{e25}\u{e49}\u{e27}",
|
||||||
|
[
|
||||||
|
"\u{e2a}\u{e21}",
|
||||||
|
"\u{e0a}\u{e32}\u{e22}",
|
||||||
|
"\u{e41}\u{e25}\u{e49}",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let items = [validation("item:v:1", held), train("item:t:1", UNRELATED)];
|
||||||
|
assert_eq!(read(&glosses, &items), [(1, 0), (0, 0), (0, 0)], "{held}");
|
||||||
|
assert_eq!(read(&[held], &items), [(1, 1), (0, 0), (0, 0)], "{held}");
|
||||||
|
}
|
||||||
|
// Delimiters still make words: spaces and punctuation between runs, and a
|
||||||
|
// change between an unspaced script and a spaced one.
|
||||||
|
let items = [
|
||||||
|
validation(
|
||||||
|
"item:v:1",
|
||||||
|
"\u{72ac}\u{304c} \u{8d70}\u{308b}\u{3001}\u{732b}\u{3082} Brokkr\u{5c71}",
|
||||||
|
),
|
||||||
|
train("item:t:1", UNRELATED),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
read(
|
||||||
|
&["\u{72ac}\u{304c} \u{8d70}\u{308b} \u{732b}\u{3082} brokkr \u{5c71}"],
|
||||||
|
&items
|
||||||
|
),
|
||||||
|
[(5, 5), (4, 4), (3, 3)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unspaced_script_ends_a_word_beside_any_other_script() {
|
||||||
|
// the first and last letters or digits of every range the rule names
|
||||||
|
let unspaced = [
|
||||||
|
'\u{3005}',
|
||||||
|
'\u{3007}',
|
||||||
|
'\u{3021}',
|
||||||
|
'\u{3029}',
|
||||||
|
'\u{3038}',
|
||||||
|
'\u{303b}',
|
||||||
|
'\u{3041}',
|
||||||
|
'\u{30ff}',
|
||||||
|
'\u{31f0}',
|
||||||
|
'\u{31ff}',
|
||||||
|
'\u{3400}',
|
||||||
|
'\u{4dbf}',
|
||||||
|
'\u{4e00}',
|
||||||
|
'\u{9fff}',
|
||||||
|
'\u{f900}',
|
||||||
|
'\u{fad9}',
|
||||||
|
'\u{ff66}',
|
||||||
|
'\u{ff9f}',
|
||||||
|
'\u{20000}',
|
||||||
|
'\u{2a6df}',
|
||||||
|
'\u{31350}',
|
||||||
|
'\u{323af}',
|
||||||
|
'\u{e01}',
|
||||||
|
'\u{e59}',
|
||||||
|
'\u{e81}',
|
||||||
|
'\u{edf}',
|
||||||
|
'\u{1000}',
|
||||||
|
'\u{1049}',
|
||||||
|
'\u{1780}',
|
||||||
|
'\u{17e9}',
|
||||||
|
];
|
||||||
|
for c in unspaced {
|
||||||
|
assert!(c.is_alphanumeric(), "{c:?} is a letter or digit");
|
||||||
|
let items = [validation("item:v:1", &format!("ab{c}"))];
|
||||||
|
assert_eq!(read(&["ab", &c.to_string()], &items)[0], (2, 2), "{c:?}");
|
||||||
|
}
|
||||||
|
// Hangul, Latin and Cyrillic are spaced scripts: no break between them
|
||||||
|
for c in ['\u{ac00}', '\u{d7a3}', '\u{e9}', '\u{44f}', '7'] {
|
||||||
|
let glued = format!("ab{c}");
|
||||||
|
let items = [validation("item:v:1", &glued)];
|
||||||
|
assert_eq!(read(&[&glued], &items)[0], (1, 1), "{c:?}");
|
||||||
|
assert_eq!(read(&["ab"], &items)[0], (1, 0), "{c:?}");
|
||||||
|
}
|
||||||
|
// a combining mark or a joiner after an unspaced character stays on it
|
||||||
|
let held = "\u{4e00}\u{301}\u{200d}ab";
|
||||||
|
let items = [validation("item:v:1", held)];
|
||||||
|
assert_eq!(read(&["\u{4e00}\u{301}\u{200d}", "ab"], &items)[0], (2, 2));
|
||||||
|
assert_eq!(read(&["\u{4e00}"], &items)[0], (2, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_path_reads_no_test() {
|
||||||
|
let train = item_in("item:a:1", "t", &group_in_bucket(10), cast());
|
||||||
|
let validation = item_in("item:b:1", "v", &group_in_bucket(50), cast());
|
||||||
|
let mut withdrawn = item_in("item:b:2", "w", &group_in_bucket(50), cast());
|
||||||
|
withdrawn.withdrawn = true;
|
||||||
|
let test = item_in("item:c:1", "x", &group_in_bucket(90), cast());
|
||||||
|
let test_two = item_in("item:c:2", "y", &group_in_bucket(90), cast());
|
||||||
|
let reading = |test_score: f64, extra: bool| {
|
||||||
|
let mut scored = vec![
|
||||||
|
(&train, score(10.0, Some(0.5))),
|
||||||
|
(&validation, score(40.0, Some(0.8))),
|
||||||
|
(&withdrawn, score(99.0, Some(0.99))),
|
||||||
|
(&test, score(test_score, Some(0.1))),
|
||||||
|
];
|
||||||
|
if extra {
|
||||||
|
scored.push((&test_two, score(100.0, Some(1.0))));
|
||||||
|
}
|
||||||
|
aggregate(&scored)
|
||||||
|
};
|
||||||
|
let base = reading(60.0, false);
|
||||||
|
assert_eq!(
|
||||||
|
(base.validation.count, base.validation.chrf),
|
||||||
|
(1, Some(40.0))
|
||||||
|
);
|
||||||
|
assert_eq!(base.validation.meaning, Some(0.8));
|
||||||
|
assert_eq!((base.train.count, base.train.chrf), (1, Some(10.0)));
|
||||||
|
assert_eq!(reading(5.0, false), base, "a Test score moves nothing");
|
||||||
|
assert_eq!(reading(5.0, true), base, "a Test item adds nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leak_diagnostic_reads_validation_only() {
|
||||||
|
let test = item_in("item:t:9", HELD_OUT, &group_in_bucket(90), cast());
|
||||||
|
let val = validation("item:v:1", "gardening is quiet work");
|
||||||
|
let mut gone = validation("item:v:2", HELD_OUT);
|
||||||
|
gone.withdrawn = true;
|
||||||
|
let train_item = train("item:t:1", UNRELATED);
|
||||||
|
let items = [test, val.clone(), gone, train_item.clone()];
|
||||||
|
|
||||||
|
let d = leak_diagnostic(&[HELD_OUT], &items);
|
||||||
|
assert_eq!(d.items, 1, "Validation only, withdrawn dropped");
|
||||||
|
assert_ne!(d.max_chrf.as_ref().unwrap().item, "item:t:9");
|
||||||
|
assert_eq!(d.max_chrf.as_ref().unwrap().item, "item:v:1");
|
||||||
|
assert!(d.max_chrf.as_ref().unwrap().chrf < 50.0);
|
||||||
|
assert_eq!(d.worst, None);
|
||||||
|
let without_test = leak_diagnostic(&[HELD_OUT], &[val.clone(), train_item.clone()]);
|
||||||
|
assert_eq!(d, without_test, "removing the Test item changes nothing");
|
||||||
|
|
||||||
|
let on_validation = leak_diagnostic(&["gardening is quiet work"], &items);
|
||||||
|
assert!(on_validation.max_chrf.unwrap().chrf > 99.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn held_out_train_union_excludes_withdrawn_train_items() {
|
||||||
|
let mut gone = train("item:t:1", HELD_OUT);
|
||||||
|
gone.withdrawn = true;
|
||||||
|
let held = validation("item:v:1", HELD_OUT);
|
||||||
|
let d = leak_diagnostic(
|
||||||
|
&[HELD_OUT],
|
||||||
|
&[gone, held.clone(), train("item:t:2", UNRELATED)],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
d.coverage_by_n
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.novel, c.covered))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[(12, 12), (11, 11), (10, 10)],
|
||||||
|
"a withdrawn Train text makes no n-gram known"
|
||||||
|
);
|
||||||
|
let live = leak_diagnostic(&[HELD_OUT], &[train("item:t:1", HELD_OUT), held]);
|
||||||
|
assert_eq!(live.coverage_by_n[0].novel, 0, "a live Train text does");
|
||||||
|
}
|
||||||
|
|
|
||||||
503
crates/weksa/tests/speaker_state.rs
Normal file
503
crates/weksa/tests/speaker_state.rs
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
//! The speaker-state store: speaker and scene documents are append-only
|
||||||
|
//! revisions, checked against the inventory, the entity store and the corpus.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::{cast, eid, entities_in, grade, item_in, lab};
|
||||||
|
use weksa::inventory::{proof_revision, seed, speaker_scene_revision};
|
||||||
|
use weksa::packet::{FeatValue, FeatureValue};
|
||||||
|
use weksa::speaker::{
|
||||||
|
Annotator, MAX_PARTICIPANTS, PairRelation, Party, Scene, Speaker, scene_key, speaker_key,
|
||||||
|
};
|
||||||
|
use weksa::{
|
||||||
|
AddOutcome, CorpusStore, EntityStore, InventoryStore, Refusal, SpeakerStateStore, StoreError,
|
||||||
|
StoreRefusal,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct W {
|
||||||
|
dir: tempfile::TempDir,
|
||||||
|
inventory: InventoryStore,
|
||||||
|
corpus: CorpusStore,
|
||||||
|
entities: EntityStore,
|
||||||
|
state: SpeakerStateStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `g` holds two live items (speakers nibu and brokkr) and one withdrawn
|
||||||
|
/// item (speaker hero); group `g2` holds one live item of nibu's.
|
||||||
|
fn world() -> W {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut inventory = InventoryStore::open(&dir.path().join("i.cc")).unwrap();
|
||||||
|
for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision(3)] {
|
||||||
|
inventory.admit_revision(rev, entries).unwrap();
|
||||||
|
}
|
||||||
|
let entities = entities_in(dir.path());
|
||||||
|
let mut corpus = CorpusStore::open(&dir.path().join("c.cc")).unwrap();
|
||||||
|
for (key, text, speaker) in [
|
||||||
|
("item:nibu:1", "Come here.", "nibu"),
|
||||||
|
("item:nibu:2", "Go there.", "brokkr"),
|
||||||
|
("item:nibu:3", "Stay away.", "hero"),
|
||||||
|
("item:nibu:4", "Elsewhere.", "g2"),
|
||||||
|
] {
|
||||||
|
let mut item = item_in(key, text, "g", cast());
|
||||||
|
item.speaker = eid(speaker);
|
||||||
|
if key == "item:nibu:4" {
|
||||||
|
item.group = "g2".into();
|
||||||
|
item.speaker = eid("nibu");
|
||||||
|
}
|
||||||
|
corpus.add(item, &entities).unwrap();
|
||||||
|
}
|
||||||
|
corpus.withdraw("item:nibu:3").unwrap();
|
||||||
|
let state = SpeakerStateStore::open(&dir.path().join("s.cc")).unwrap();
|
||||||
|
W {
|
||||||
|
dir,
|
||||||
|
inventory,
|
||||||
|
corpus,
|
||||||
|
entities,
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feat(gloss: &str, value: FeatValue) -> FeatureValue {
|
||||||
|
let feature = seed()
|
||||||
|
.1
|
||||||
|
.into_iter()
|
||||||
|
.chain(speaker_scene_revision(3).1)
|
||||||
|
.find(|e| e.gloss == gloss)
|
||||||
|
.unwrap_or_else(|| panic!("no feature {gloss}"))
|
||||||
|
.id;
|
||||||
|
FeatureValue { feature, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn speaker(n: u32, age: &str) -> Speaker {
|
||||||
|
Speaker {
|
||||||
|
entity: eid("nibu"),
|
||||||
|
n,
|
||||||
|
inventory_rev: 3,
|
||||||
|
features: vec![feat("age_cohort", FeatValue::Option(lab(age)))],
|
||||||
|
annotator: Annotator::Operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene(n: u32, intimacy: u16) -> Scene {
|
||||||
|
Scene {
|
||||||
|
group: "g".into(),
|
||||||
|
n,
|
||||||
|
inventory_rev: 3,
|
||||||
|
participants: vec![eid("nibu"), eid("brokkr")],
|
||||||
|
relations: vec![PairRelation {
|
||||||
|
from: eid("nibu"),
|
||||||
|
to: Party::Named(eid("brokkr")),
|
||||||
|
features: vec![feat("intimacy", FeatValue::Grade(grade(intimacy)))],
|
||||||
|
}],
|
||||||
|
annotator: Annotator::Operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_speaker(w: &mut W, s: Speaker) -> Result<AddOutcome, StoreError> {
|
||||||
|
w.state.put_speaker(s, &w.inventory, &w.entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_scene(w: &mut W, s: Scene) -> Result<AddOutcome, StoreError> {
|
||||||
|
w.state.put_scene(s, &w.inventory, &w.corpus, &w.entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refused<T: std::fmt::Debug>(r: Result<T, StoreError>) -> StoreRefusal {
|
||||||
|
match r.unwrap_err() {
|
||||||
|
StoreError::Refused(r) => r,
|
||||||
|
other => panic!("not a store refusal: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn malformed(r: Result<AddOutcome, StoreError>, key: &str, why: &str) {
|
||||||
|
match refused(r) {
|
||||||
|
StoreRefusal::Malformed { key: k, why: w } => assert_eq!((k.as_str(), w), (key, why)),
|
||||||
|
other => panic!("not Malformed: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rules(r: Result<AddOutcome, StoreError>) -> Vec<&'static str> {
|
||||||
|
match r.unwrap_err() {
|
||||||
|
StoreError::Invalid(rs) => rs.iter().map(Refusal::rule).collect(),
|
||||||
|
other => panic!("not a rule refusal: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_next(key: &str, expected: u32, got: u32) -> StoreRefusal {
|
||||||
|
StoreRefusal::RevisionNotNext {
|
||||||
|
key: key.into(),
|
||||||
|
expected,
|
||||||
|
got,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_derive_from_the_document() {
|
||||||
|
assert_eq!(speaker_key(&eid("nibu"), 2), "speaker:nibu@2");
|
||||||
|
assert_eq!(scene_key("g", 3), "scene:g@3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speaker_revisions_append() {
|
||||||
|
let mut w = world();
|
||||||
|
let key = |n| speaker_key(&eid("nibu"), n);
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_speaker(&mut w, speaker(2, "adult"))),
|
||||||
|
not_next(&key(2), 1, 2)
|
||||||
|
);
|
||||||
|
assert!(w.state.speaker(&eid("nibu")).unwrap().is_none());
|
||||||
|
assert_eq!(
|
||||||
|
put_speaker(&mut w, speaker(1, "adult")).unwrap(),
|
||||||
|
AddOutcome::Added
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
put_speaker(&mut w, speaker(1, "adult")).unwrap(),
|
||||||
|
AddOutcome::Unchanged
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_speaker(&mut w, speaker(1, "elder"))),
|
||||||
|
not_next(&key(1), 2, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_speaker(&mut w, speaker(3, "elder"))),
|
||||||
|
not_next(&key(3), 2, 3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
put_speaker(&mut w, speaker(2, "elder")).unwrap(),
|
||||||
|
AddOutcome::Added
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
w.state.speaker(&eid("nibu")).unwrap(),
|
||||||
|
Some(speaker(2, "elder"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
w.state.get_speaker(&key(1)).unwrap(),
|
||||||
|
Some(speaker(1, "adult"))
|
||||||
|
);
|
||||||
|
// Another entity's revisions count from 1 and stay apart.
|
||||||
|
let mut other = speaker(1, "child");
|
||||||
|
other.entity = eid("brokkr");
|
||||||
|
assert_eq!(put_speaker(&mut w, other).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(w.state.speaker(&eid("nibu")).unwrap().unwrap().n, 2);
|
||||||
|
// Revision 10 sorts after 2 in the listing.
|
||||||
|
for n in 3..=10 {
|
||||||
|
put_speaker(&mut w, speaker(n, "adult")).unwrap();
|
||||||
|
}
|
||||||
|
let listed: Vec<_> = w
|
||||||
|
.state
|
||||||
|
.all_speakers()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|s| (s.entity.to_string(), s.n))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(listed.len(), 11);
|
||||||
|
assert_eq!(listed[0], ("entity:brokkr".into(), 1));
|
||||||
|
assert_eq!(listed[10], ("entity:nibu".into(), 10));
|
||||||
|
assert_eq!(w.state.speaker(&eid("nibu")).unwrap().unwrap().n, 10);
|
||||||
|
// The documents survive a reopen.
|
||||||
|
let again = SpeakerStateStore::open(&w.dir.path().join("s.cc")).unwrap();
|
||||||
|
assert_eq!(again.all_speakers().unwrap().len(), 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_revisions_append() {
|
||||||
|
let mut w = world();
|
||||||
|
let key = |n| scene_key("g", n);
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_scene(&mut w, scene(2, 5))),
|
||||||
|
not_next(&key(2), 1, 2)
|
||||||
|
);
|
||||||
|
assert!(w.state.scene("g").unwrap().is_none());
|
||||||
|
assert_eq!(put_scene(&mut w, scene(1, 5)).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(
|
||||||
|
put_scene(&mut w, scene(1, 5)).unwrap(),
|
||||||
|
AddOutcome::Unchanged
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_scene(&mut w, scene(1, 6))),
|
||||||
|
not_next(&key(1), 2, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(put_scene(&mut w, scene(2, 6)).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(w.state.scene("g").unwrap(), Some(scene(2, 6)));
|
||||||
|
assert_eq!(w.state.get_scene(&key(1)).unwrap(), Some(scene(1, 5)));
|
||||||
|
for n in 3..=10 {
|
||||||
|
put_scene(&mut w, scene(n, 6)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10);
|
||||||
|
let listed: Vec<u32> = w.state.all_scenes().unwrap().iter().map(|s| s.n).collect();
|
||||||
|
assert_eq!(listed, (1..=10).collect::<Vec<_>>());
|
||||||
|
// Numbering is per group: the first scene of another group is revision 1
|
||||||
|
// whatever g holds, and its second is 2.
|
||||||
|
let other = |n| Scene {
|
||||||
|
group: "g2".into(),
|
||||||
|
participants: vec![eid("nibu")],
|
||||||
|
relations: vec![],
|
||||||
|
..scene(n, 1)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_scene(&mut w, other(2))),
|
||||||
|
not_next(&scene_key("g2", 2), 1, 2)
|
||||||
|
);
|
||||||
|
assert_eq!(put_scene(&mut w, other(1)).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_scene(&mut w, other(3))),
|
||||||
|
not_next(&scene_key("g2", 3), 2, 3)
|
||||||
|
);
|
||||||
|
assert_eq!(put_scene(&mut w, other(2)).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(w.state.scene("g2").unwrap().unwrap().n, 2);
|
||||||
|
assert_eq!(w.state.scene("g").unwrap().unwrap().n, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speaker_needs_a_known_entity() {
|
||||||
|
let mut w = world();
|
||||||
|
let mut s = speaker(1, "adult");
|
||||||
|
s.entity = eid("nobody");
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_speaker(&mut w, s)),
|
||||||
|
StoreRefusal::UnknownEntity {
|
||||||
|
entity: eid("nobody")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(w.state.all_speakers().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotation_features_are_checked_like_packet_features() {
|
||||||
|
let mut w = world();
|
||||||
|
let age = || feat("age_cohort", FeatValue::Option(lab("adult")));
|
||||||
|
let with = |features: Vec<FeatureValue>, rev| Speaker {
|
||||||
|
features,
|
||||||
|
inventory_rev: rev,
|
||||||
|
..speaker(1, "adult")
|
||||||
|
};
|
||||||
|
// A Scene feature and a Stance feature do not apply to a speaker.
|
||||||
|
let f = vec![feat("intimacy", FeatValue::Grade(grade(1)))];
|
||||||
|
assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V8"]);
|
||||||
|
let f = vec![feat("anger", FeatValue::Grade(grade(1)))];
|
||||||
|
assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V8"]);
|
||||||
|
// A wrong kind and an undeclared label.
|
||||||
|
let f = vec![feat("education", FeatValue::Flag(true))];
|
||||||
|
assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V7"]);
|
||||||
|
let f = vec![feat("age_cohort", FeatValue::Option(lab("ancient")))];
|
||||||
|
assert_eq!(rules(put_speaker(&mut w, with(f, 3))), ["V7"]);
|
||||||
|
// Feature 22 does not exist at revision 2; revision 9 has no record.
|
||||||
|
assert_eq!(rules(put_speaker(&mut w, with(vec![age()], 2))), ["V5"]);
|
||||||
|
assert_eq!(rules(put_speaker(&mut w, with(vec![age()], 9))), ["V10"]);
|
||||||
|
// A repeat.
|
||||||
|
assert_eq!(
|
||||||
|
rules(put_speaker(&mut w, with(vec![age(), age()], 3))),
|
||||||
|
["V11"]
|
||||||
|
);
|
||||||
|
// A Speaker feature in a scene relation.
|
||||||
|
let mut s = scene(1, 1);
|
||||||
|
s.relations[0].features = vec![age()];
|
||||||
|
assert_eq!(rules(put_scene(&mut w, s)), ["V8"]);
|
||||||
|
// Every relation is checked, and a scene of an unknown revision is V10.
|
||||||
|
let mut s = scene(1, 1);
|
||||||
|
s.relations.push(PairRelation {
|
||||||
|
from: eid("brokkr"),
|
||||||
|
to: Party::Unnamed,
|
||||||
|
features: vec![age()],
|
||||||
|
});
|
||||||
|
assert_eq!(rules(put_scene(&mut w, s)), ["V8"]);
|
||||||
|
let mut s = scene(1, 1);
|
||||||
|
s.inventory_rev = 9;
|
||||||
|
assert_eq!(rules(put_scene(&mut w, s)), ["V10"]);
|
||||||
|
let mut s = scene(1, 1);
|
||||||
|
s.inventory_rev = 9;
|
||||||
|
s.relations.clear();
|
||||||
|
assert_eq!(rules(put_scene(&mut w, s)), ["V10"]);
|
||||||
|
assert!(w.state.all_speakers().unwrap().is_empty());
|
||||||
|
assert!(w.state.all_scenes().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_shape_rules() {
|
||||||
|
let mut w = world();
|
||||||
|
let key = scene_key("g", 1);
|
||||||
|
let relation = |from: &str, to: Party| PairRelation {
|
||||||
|
from: eid(from),
|
||||||
|
to,
|
||||||
|
features: vec![],
|
||||||
|
};
|
||||||
|
let named = |to: &str| Party::Named(eid(to));
|
||||||
|
let base = || scene(1, 1);
|
||||||
|
|
||||||
|
let mut s = base();
|
||||||
|
s.group = "Not A Slug".into();
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&scene_key("Not A Slug", 1),
|
||||||
|
"a scene group is a slug",
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.group = "elsewhere".into();
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&scene_key("elsewhere", 1),
|
||||||
|
"no corpus item holds the scene's group",
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.participants.clear();
|
||||||
|
s.relations.clear();
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"a scene has 1 to 64 participants",
|
||||||
|
);
|
||||||
|
// 64 participants are fine as far as the count goes (the rest fail later);
|
||||||
|
// 65 are refused for the count.
|
||||||
|
let many = |count: usize| -> Vec<_> { (0..count).map(|i| eid(&format!("p{i}"))).collect() };
|
||||||
|
let mut s = base();
|
||||||
|
s.participants = many(MAX_PARTICIPANTS + 1);
|
||||||
|
s.relations.clear();
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"a scene has 1 to 64 participants",
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.participants = many(MAX_PARTICIPANTS);
|
||||||
|
s.relations.clear();
|
||||||
|
assert!(matches!(
|
||||||
|
refused(put_scene(&mut w, s)),
|
||||||
|
StoreRefusal::UnknownEntity { .. }
|
||||||
|
));
|
||||||
|
let mut s = base();
|
||||||
|
s.participants.push(eid("nibu"));
|
||||||
|
malformed(put_scene(&mut w, s), &key, "a participant is named once");
|
||||||
|
let mut s = base();
|
||||||
|
s.participants.push(eid("nobody"));
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_scene(&mut w, s)),
|
||||||
|
StoreRefusal::UnknownEntity {
|
||||||
|
entity: eid("nobody")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.relations = vec![relation("hero", named("nibu"))];
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"a relation's from is a participant",
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.relations = vec![relation("nibu", named("hero"))];
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"a relation's named to is another participant",
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.relations = vec![relation("nibu", named("nibu"))];
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"a relation's named to is another participant",
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.relations = vec![
|
||||||
|
relation("nibu", named("brokkr")),
|
||||||
|
relation("nibu", named("brokkr")),
|
||||||
|
];
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"an ordered pair has one relation",
|
||||||
|
);
|
||||||
|
let mut s = base();
|
||||||
|
s.relations = vec![
|
||||||
|
relation("nibu", Party::Unnamed),
|
||||||
|
relation("nibu", Party::Unnamed),
|
||||||
|
];
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"an ordered pair has one relation",
|
||||||
|
);
|
||||||
|
// The reverse pair, and an unnamed party beside a named one, are distinct.
|
||||||
|
let mut s = base();
|
||||||
|
s.relations = vec![
|
||||||
|
relation("nibu", named("brokkr")),
|
||||||
|
relation("brokkr", named("nibu")),
|
||||||
|
relation("nibu", Party::Unnamed),
|
||||||
|
];
|
||||||
|
assert_eq!(put_scene(&mut w, s).unwrap(), AddOutcome::Added);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scene_holds_every_live_speaker_of_its_group() {
|
||||||
|
let mut w = world();
|
||||||
|
// Brokkr speaks a live item of the group.
|
||||||
|
let mut s = scene(1, 1);
|
||||||
|
s.participants = vec![eid("nibu")];
|
||||||
|
s.relations.clear();
|
||||||
|
assert_eq!(
|
||||||
|
refused(put_scene(&mut w, s)),
|
||||||
|
StoreRefusal::SceneSpeakerMissing {
|
||||||
|
group: "g".into(),
|
||||||
|
entity: eid("brokkr")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Hero speaks only a withdrawn item, so the scene may omit them.
|
||||||
|
assert_eq!(put_scene(&mut w, scene(1, 1)).unwrap(), AddOutcome::Added);
|
||||||
|
// The group is held by a withdrawn item alone: still a group.
|
||||||
|
w.corpus.withdraw("item:nibu:1").unwrap();
|
||||||
|
w.corpus.withdraw("item:nibu:2").unwrap();
|
||||||
|
let mut s = scene(2, 1);
|
||||||
|
s.participants = vec![eid("player")];
|
||||||
|
s.relations.clear();
|
||||||
|
assert_eq!(put_scene(&mut w, s).unwrap(), AddOutcome::Added);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_annotator_attests_its_generator() {
|
||||||
|
let mut w = world();
|
||||||
|
let model = |generator: &str| Annotator::Model {
|
||||||
|
family: weksa::corpus::ModelFamily::Anthropic,
|
||||||
|
generator: generator.into(),
|
||||||
|
};
|
||||||
|
let key = speaker_key(&eid("nibu"), 1);
|
||||||
|
for bad in ["", " ", "gen\u{7}erator", "gen\nerator", "\u{200d}"] {
|
||||||
|
let mut s = speaker(1, "adult");
|
||||||
|
s.annotator = model(bad);
|
||||||
|
malformed(
|
||||||
|
put_speaker(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"a model annotator attests its generator",
|
||||||
|
);
|
||||||
|
let mut s = scene(1, 1);
|
||||||
|
s.annotator = model(bad);
|
||||||
|
malformed(
|
||||||
|
put_scene(&mut w, s),
|
||||||
|
&scene_key("g", 1),
|
||||||
|
"a model annotator attests its generator",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut s = speaker(1, "adult");
|
||||||
|
s.annotator = model(&"x".repeat(201));
|
||||||
|
malformed(
|
||||||
|
put_speaker(&mut w, s),
|
||||||
|
&key,
|
||||||
|
"a model annotator attests its generator",
|
||||||
|
);
|
||||||
|
let mut s = speaker(1, "adult");
|
||||||
|
s.annotator = model("claude-sonnet 5.5");
|
||||||
|
assert_eq!(put_speaker(&mut w, s).unwrap(), AddOutcome::Added);
|
||||||
|
let mut s = scene(1, 1);
|
||||||
|
s.annotator = model("claude-sonnet 5.5");
|
||||||
|
assert_eq!(put_scene(&mut w, s).unwrap(), AddOutcome::Added);
|
||||||
|
let mut s = speaker(2, "adult");
|
||||||
|
s.annotator = Annotator::Operator;
|
||||||
|
assert_eq!(put_speaker(&mut w, s).unwrap(), AddOutcome::Added);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refusal_displays_print_numbers_and_no_identifiers() {
|
||||||
|
let r = not_next("speaker:secretname@2", 1, 2);
|
||||||
|
assert_eq!(r.to_string(), "RevisionNotNext expected 1 got 2");
|
||||||
|
let r = StoreRefusal::SceneSpeakerMissing {
|
||||||
|
group: "secretgroup".into(),
|
||||||
|
entity: eid("secretname"),
|
||||||
|
};
|
||||||
|
assert_eq!(r.to_string(), "SceneSpeakerMissing");
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ fn item(group: &str, source: Source) -> CorpusItem {
|
||||||
language: "en-US".into(),
|
language: "en-US".into(),
|
||||||
speaker: weksa::EntityId::parse("entity:s").unwrap(),
|
speaker: weksa::EntityId::parse("entity:s").unwrap(),
|
||||||
names: vec![],
|
names: vec![],
|
||||||
|
names_by: None,
|
||||||
group: group.into(),
|
group: group.into(),
|
||||||
prior_turns: vec![],
|
prior_turns: vec![],
|
||||||
source,
|
source,
|
||||||
|
|
@ -134,6 +135,12 @@ fn published_schemas_match() {
|
||||||
let published = weksa::schema::published();
|
let published = weksa::schema::published();
|
||||||
let mut names: Vec<String> = published.iter().map(|(n, _)| n.clone()).collect();
|
let mut names: Vec<String> = published.iter().map(|(n, _)| n.clone()).collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
|
for loop_type in ["run", "round", "decision"] {
|
||||||
|
assert!(
|
||||||
|
names.contains(&format!("weksa.loop_{loop_type}.schema.json")),
|
||||||
|
"{loop_type}"
|
||||||
|
);
|
||||||
|
}
|
||||||
let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
|
let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||||
|
|
@ -151,3 +158,32 @@ fn published_schemas_match() {
|
||||||
assert_eq!(held, json, "{name} drifted; run `weksa schema`");
|
assert_eq!(held, json, "{name} drifted; run `weksa schema`");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_is_the_split_of_an_item_that_is_not_withdrawn() {
|
||||||
|
for (bucket, want) in [
|
||||||
|
(10, Split::Train),
|
||||||
|
(50, Split::Validation),
|
||||||
|
(90, Split::Test),
|
||||||
|
] {
|
||||||
|
let group = group_in_bucket(bucket);
|
||||||
|
let live = item(&group, cast());
|
||||||
|
assert_eq!(SplitScheme::V1.live(&live), Some(want));
|
||||||
|
let mut gone = live.clone();
|
||||||
|
gone.withdrawn = true;
|
||||||
|
assert_eq!(
|
||||||
|
SplitScheme::V1.live(&gone),
|
||||||
|
None,
|
||||||
|
"withdrawn: no live split"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SplitScheme::V1.split(&gone),
|
||||||
|
Some(want),
|
||||||
|
"split still names the group's split"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut model_test = item(&group_in_bucket(90), model());
|
||||||
|
assert_eq!(SplitScheme::V1.live(&model_test), None);
|
||||||
|
model_test.withdrawn = true;
|
||||||
|
assert_eq!(SplitScheme::V1.live(&model_test), None);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,210 @@ fn s6_reads_text_under_compatibility_case_folding() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn s6_folds_dotless_i_kana_and_arabic_indic_digits() {
|
||||||
|
let d = dir();
|
||||||
|
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
|
// Turkish dotless and dotted capital i read as i; hiragana and katakana
|
||||||
|
// (small, voiced, iteration marks, halfwidth through NFKC) read as one
|
||||||
|
// syllabary; Arabic-Indic and Extended Arabic-Indic digits read as 0-9.
|
||||||
|
let pairs = [
|
||||||
|
("isi", "\u{131}s\u{131}"),
|
||||||
|
("istanbul", "\u{130}stanbul"),
|
||||||
|
(
|
||||||
|
"\u{3072}\u{3089}\u{304c}\u{306a}",
|
||||||
|
"\u{30d2}\u{30e9}\u{30ac}\u{30ca}",
|
||||||
|
),
|
||||||
|
("\u{3041}\u{3083}", "\u{30a1}\u{30e3}"),
|
||||||
|
("\u{3042}\u{309d}\u{309e}", "\u{3042}\u{30fd}\u{30fe}"),
|
||||||
|
("\u{306d}\u{3053}", "\u{ff88}\u{ff7a}"),
|
||||||
|
(
|
||||||
|
"room 1234567890",
|
||||||
|
"room \u{661}\u{662}\u{663}\u{664}\u{665}\u{666}\u{667}\u{668}\u{669}\u{660}",
|
||||||
|
),
|
||||||
|
("gate 45", "gate \u{6f4}\u{6f5}"),
|
||||||
|
("\u{3096}", "\u{30f6}"),
|
||||||
|
// Voiced wa, wi, we, wo: katakana U+30F7-30FA read as the hiragana
|
||||||
|
// plus the combining voiced mark.
|
||||||
|
("\u{308f}\u{3099}", "\u{30f7}"),
|
||||||
|
("\u{3090}\u{3099}", "\u{30f8}"),
|
||||||
|
("\u{3091}\u{3099}", "\u{30f9}"),
|
||||||
|
("\u{3092}\u{3099}", "\u{30fa}"),
|
||||||
|
// Dotted capital I and i plus a combining dot above
|
||||||
|
// read alike (plain ix collides with both, below).
|
||||||
|
("\u{130}x", "i\u{307}x"),
|
||||||
|
];
|
||||||
|
for (n, (a, b)) in pairs.into_iter().enumerate() {
|
||||||
|
let (first, second) = (
|
||||||
|
format!("item:fold:{}", 2 * n + 1),
|
||||||
|
format!("item:fold:{}", 2 * n + 2),
|
||||||
|
);
|
||||||
|
store.add(item(&first, a), &ents(&d)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.add(item(&second, b), &ents(&d))),
|
||||||
|
StoreRefusal::S6TextHeld {
|
||||||
|
key: second.clone(),
|
||||||
|
held_by: first.clone()
|
||||||
|
},
|
||||||
|
"{a:?} vs {b:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Plain ix collides with the dotted spellings too: a rare sequence
|
||||||
|
// over-merges toward held.
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.add(item("item:fold:80", "ix"), &ents(&d))),
|
||||||
|
StoreRefusal::S6TextHeld {
|
||||||
|
key: "item:fold:80".into(),
|
||||||
|
held_by: "item:fold:27".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Nothing wider: the prolonged sound mark, Devanagari digits and the
|
||||||
|
// Cyrillic homoglyph of i stay apart from what they resemble.
|
||||||
|
store
|
||||||
|
.add(item("item:fold:90", "\u{30a2}\u{30fc}"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:fold:91", "\u{30a2}"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:fold:92", "room 12"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:fold:93", "room \u{967}\u{968}"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
store.add(item("item:fold:94", "bit"), &ents(&d)).unwrap();
|
||||||
|
// A combining dot above after any other letter is kept.
|
||||||
|
store.add(item("item:fold:96", "x"), &ents(&d)).unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:fold:97", "\u{1e8b}"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:fold:95", "b\u{456}t"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
// j has no precomposed dot-above form, so this reaches the guard itself.
|
||||||
|
store.add(item("item:fold:98", "jx"), &ents(&d)).unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:fold:99", "j\u{307}x"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
// Marks after the dotted i compose with it: i, dot, acute is an i with an
|
||||||
|
// acute, whatever order the writer used.
|
||||||
|
store
|
||||||
|
.add(item("item:fold:100", "\u{ed}x"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.add(item("item:fold:101", "i\u{307}\u{301}x"), &ents(&d))),
|
||||||
|
StoreRefusal::S6TextHeld {
|
||||||
|
key: "item:fold:101".into(),
|
||||||
|
held_by: "item:fold:100".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `modifier_letters.txt` is the Unicode 17 Lm list, the oracle for the test
|
||||||
|
/// below. When the toolchain's Unicode tables move, this fails first: regenerate
|
||||||
|
/// the file from the new tables, then bump the pin.
|
||||||
|
#[test]
|
||||||
|
fn the_modifier_letter_table_matches_the_toolchains_unicode_version() {
|
||||||
|
assert_eq!(
|
||||||
|
char::UNICODE_VERSION,
|
||||||
|
(17, 0, 0),
|
||||||
|
"regenerate crates/weksa/tests/modifier_letters.txt for the new Unicode version"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_text_of_only_marks_is_refused() {
|
||||||
|
let d = dir();
|
||||||
|
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
|
// Prolonged sound mark, Thai maiyamok, fathatan, tatweel: alphabetic to
|
||||||
|
// Unicode, but none carries a word.
|
||||||
|
for (n, text) in [
|
||||||
|
"\u{30fc}",
|
||||||
|
"\u{e46}",
|
||||||
|
"\u{64b}",
|
||||||
|
"\u{640}",
|
||||||
|
"\u{30fc}\u{30fc}",
|
||||||
|
"\u{640}\u{64b}",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let key = format!("item:mo:{}", n + 1);
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.add(item(&key, text), &ents(&d))),
|
||||||
|
malformed(&key, "text has no base letter or digit"),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every modifier letter (Lm) alone is refused: for having no base letter,
|
||||||
|
// or, where Unicode does not call it alphabetic, no alphanumeric at all, or
|
||||||
|
// (U+0374, U+037A) for not being NFC.
|
||||||
|
for line in include_str!("modifier_letters.txt")
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.starts_with('#'))
|
||||||
|
{
|
||||||
|
let c = char::from_u32(u32::from_str_radix(line, 16).unwrap()).unwrap();
|
||||||
|
let key = "item:mo:90";
|
||||||
|
let got = refusal(store.add(item(key, &c.to_string()), &ents(&d)));
|
||||||
|
assert!(
|
||||||
|
got == StoreRefusal::NotNfc { key: key.into() }
|
||||||
|
|| got == malformed(key, "text has no base letter or digit")
|
||||||
|
|| got == malformed(key, "text has no alphanumeric character"),
|
||||||
|
"{c:?}: {got:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// With a base letter or digit the same marks are text.
|
||||||
|
store
|
||||||
|
.add(item("item:mo:91", "\u{30a2}\u{30fc}"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:mo:92", "\u{e01}\u{e46}"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add(item("item:mo:93", "\u{640}7"), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admitted_on_is_a_real_calendar_date() {
|
||||||
|
let d = dir();
|
||||||
|
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
|
for (n, bad) in [
|
||||||
|
"",
|
||||||
|
"junk",
|
||||||
|
"2026-02-30",
|
||||||
|
"2026-02-29",
|
||||||
|
"2026-13-01",
|
||||||
|
"2026-00-10",
|
||||||
|
"2026-04-31",
|
||||||
|
"2026-1-1",
|
||||||
|
"2026-10-29 ",
|
||||||
|
"20261029",
|
||||||
|
"2026-10-29T00:00Z",
|
||||||
|
"\u{661}\u{662}\u{663}\u{664}-10-29",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let key = format!("item:dt:{}", n + 1);
|
||||||
|
let mut it = item(&key, "a date");
|
||||||
|
it.admitted_on = bad.into();
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.add(it, &ents(&d))),
|
||||||
|
malformed(&key, "admitted_on is not a YYYY-MM-DD date"),
|
||||||
|
"{bad:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (n, good) in ["2026-10-29", "2024-02-29", "2000-02-29"]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let mut it = item(&format!("item:dt:{}", 90 + n), &format!("a date {n}"));
|
||||||
|
it.admitted_on = good.into();
|
||||||
|
store.add(it, &ents(&d)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn s6_keeps_tone_marks_and_viramas_apart() {
|
fn s6_keeps_tone_marks_and_viramas_apart() {
|
||||||
let d = dir();
|
let d = dir();
|
||||||
|
|
@ -356,11 +560,23 @@ fn s6_keeps_tone_marks_and_viramas_apart() {
|
||||||
.unwrap_or_else(|e| panic!("{b:?} held by {a:?}: {e}"));
|
.unwrap_or_else(|e| panic!("{b:?} held by {a:?}: {e}"));
|
||||||
}
|
}
|
||||||
// A mark alone is still no text.
|
// A mark alone is still no text.
|
||||||
for (n, text) in ["\u{e49}", "\u{94d}"].into_iter().enumerate() {
|
for (n, text) in [
|
||||||
|
"\u{e49}",
|
||||||
|
"\u{94d}",
|
||||||
|
"\u{301}",
|
||||||
|
"\u{200d}",
|
||||||
|
"\u{200c}",
|
||||||
|
"\u{fe0f}",
|
||||||
|
"\u{200d}\u{fe0f}\u{301}",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
let key = format!("item:mk:{}", 90 + n);
|
let key = format!("item:mk:{}", 90 + n);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
refusal(store.add(item(&key, text), &ents(&d))),
|
refusal(store.add(item(&key, text), &ents(&d))),
|
||||||
malformed(&key, "text has no alphanumeric character")
|
malformed(&key, "text has no base letter or digit"),
|
||||||
|
"{text:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -371,7 +587,7 @@ fn an_attestation_holds_something_readable() {
|
||||||
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
let s = |x: &str| x.to_string();
|
let s = |x: &str| x.to_string();
|
||||||
type Build = fn(String) -> Source;
|
type Build = fn(String) -> Source;
|
||||||
let builders: [(Build, &'static str); 9] = [
|
let builders: [(Build, &'static str); 12] = [
|
||||||
(
|
(
|
||||||
|v| Source::CastAuthored {
|
|v| Source::CastAuthored {
|
||||||
persona: v,
|
persona: v,
|
||||||
|
|
@ -405,6 +621,10 @@ fn an_attestation_holds_something_readable() {
|
||||||
work: v,
|
work: v,
|
||||||
author: "a".into(),
|
author: "a".into(),
|
||||||
edition_url: "u".into(),
|
edition_url: "u".into(),
|
||||||
|
licence: "l".into(),
|
||||||
|
jurisdiction: "j".into(),
|
||||||
|
translator: "t".into(),
|
||||||
|
edition_year: 1891,
|
||||||
},
|
},
|
||||||
"a public-domain source names its work",
|
"a public-domain source names its work",
|
||||||
),
|
),
|
||||||
|
|
@ -413,6 +633,10 @@ fn an_attestation_holds_something_readable() {
|
||||||
work: "w".into(),
|
work: "w".into(),
|
||||||
author: v,
|
author: v,
|
||||||
edition_url: "u".into(),
|
edition_url: "u".into(),
|
||||||
|
licence: "l".into(),
|
||||||
|
jurisdiction: "j".into(),
|
||||||
|
translator: "t".into(),
|
||||||
|
edition_year: 1891,
|
||||||
},
|
},
|
||||||
"a public-domain source names its author",
|
"a public-domain source names its author",
|
||||||
),
|
),
|
||||||
|
|
@ -421,9 +645,49 @@ fn an_attestation_holds_something_readable() {
|
||||||
work: "w".into(),
|
work: "w".into(),
|
||||||
author: "a".into(),
|
author: "a".into(),
|
||||||
edition_url: v,
|
edition_url: v,
|
||||||
|
licence: "l".into(),
|
||||||
|
jurisdiction: "j".into(),
|
||||||
|
translator: "t".into(),
|
||||||
|
edition_year: 1891,
|
||||||
},
|
},
|
||||||
"a public-domain source names its edition url",
|
"a public-domain source names its edition url",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
|v| Source::PublicDomain {
|
||||||
|
work: "w".into(),
|
||||||
|
author: "a".into(),
|
||||||
|
edition_url: "u".into(),
|
||||||
|
licence: v,
|
||||||
|
jurisdiction: "j".into(),
|
||||||
|
translator: "t".into(),
|
||||||
|
edition_year: 1891,
|
||||||
|
},
|
||||||
|
"a public-domain source names its licence",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
|v| Source::PublicDomain {
|
||||||
|
work: "w".into(),
|
||||||
|
author: "a".into(),
|
||||||
|
edition_url: "u".into(),
|
||||||
|
licence: "l".into(),
|
||||||
|
jurisdiction: v,
|
||||||
|
translator: "t".into(),
|
||||||
|
edition_year: 1891,
|
||||||
|
},
|
||||||
|
"a public-domain source names its jurisdiction",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
|v| Source::PublicDomain {
|
||||||
|
work: "w".into(),
|
||||||
|
author: "a".into(),
|
||||||
|
edition_url: "u".into(),
|
||||||
|
licence: "l".into(),
|
||||||
|
jurisdiction: "j".into(),
|
||||||
|
translator: v,
|
||||||
|
edition_year: 1891,
|
||||||
|
},
|
||||||
|
"a public-domain source names its translator",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
|v| Source::ModelWritten {
|
|v| Source::ModelWritten {
|
||||||
family: ModelFamily::Unrecorded,
|
family: ModelFamily::Unrecorded,
|
||||||
|
|
@ -528,6 +792,10 @@ fn an_item_source_attests_with_non_empty_strings() {
|
||||||
work: s(work),
|
work: s(work),
|
||||||
author: s(author),
|
author: s(author),
|
||||||
edition_url: s(url),
|
edition_url: s(url),
|
||||||
|
licence: s("l"),
|
||||||
|
jurisdiction: s("j"),
|
||||||
|
translator: s("t"),
|
||||||
|
edition_year: 1891,
|
||||||
};
|
};
|
||||||
let cases: [(Source, &'static str); 9] = [
|
let cases: [(Source, &'static str); 9] = [
|
||||||
(
|
(
|
||||||
|
|
@ -825,6 +1093,51 @@ fn revision_change_rules() {
|
||||||
assert!(inv.entry(&iv("role:1")).unwrap().retired_rev.is_none());
|
assert!(inv.entry(&iv("role:1")).unwrap().retired_rev.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speaker_state_slots_are_exclusive() {
|
||||||
|
let shape = "entry shape does not match its family";
|
||||||
|
let d = dir();
|
||||||
|
let mut store = seeded(&d.path().join("i.cc"));
|
||||||
|
for slots in [
|
||||||
|
&[Slot::Stance, Slot::Discourse][..],
|
||||||
|
&[Slot::Scene, Slot::Relation],
|
||||||
|
&[Slot::Discourse, Slot::Scene],
|
||||||
|
&[Slot::Scene, Slot::Scene],
|
||||||
|
&[Slot::Speaker, Slot::Scene],
|
||||||
|
&[Slot::Speaker, Slot::Stance],
|
||||||
|
&[Slot::Speaker, Slot::Relation],
|
||||||
|
&[Slot::Speaker, Slot::Speaker],
|
||||||
|
&[Slot::Stance, Slot::Stance],
|
||||||
|
] {
|
||||||
|
let rev = rev_with(vec![Change::Add(iv("feature:50"))], 2, Some(1));
|
||||||
|
let entries = vec![InventoryEntry {
|
||||||
|
introduced_rev: 2,
|
||||||
|
..feature("feature:50", ValueKind::Flag, slots)
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.admit_revision(rev, entries)),
|
||||||
|
malformed("feature:50", shape),
|
||||||
|
"{slots:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(store.inventory().unwrap().latest().unwrap().n, 1);
|
||||||
|
// One slot alone, and several content slots, are admitted.
|
||||||
|
for (n, slots) in [
|
||||||
|
(2, &[Slot::Scene][..]),
|
||||||
|
(3, &[Slot::Stance]),
|
||||||
|
(4, &[Slot::Speaker]),
|
||||||
|
(5, &[Slot::Discourse, Slot::Predication]),
|
||||||
|
] {
|
||||||
|
let id = format!("feature:{}", 50 + n);
|
||||||
|
let rev = rev_with(vec![Change::Add(iv(&id))], n, Some(n - 1));
|
||||||
|
let entries = vec![InventoryEntry {
|
||||||
|
introduced_rev: n,
|
||||||
|
..feature(&id, ValueKind::Flag, slots)
|
||||||
|
}];
|
||||||
|
store.admit_revision(rev, entries).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_well_formed_extension_entry_is_admitted() {
|
fn a_well_formed_extension_entry_is_admitted() {
|
||||||
let d = dir();
|
let d = dir();
|
||||||
|
|
@ -989,14 +1302,14 @@ fn packet_admission_goes_through_the_validator() {
|
||||||
let mut w = world();
|
let mut w = world();
|
||||||
let mut store = PacketStore::open(&w.dir.path().join("p.cc")).unwrap();
|
let mut store = PacketStore::open(&w.dir.path().join("p.cc")).unwrap();
|
||||||
let mut bad = good();
|
let mut bad = good();
|
||||||
bad.schema_version = 5;
|
bad.schema_version = 6;
|
||||||
match store.admit(bad, &w.inventory, &w.corpus, &w.entities) {
|
match store.admit(bad, &w.inventory, &w.corpus, &w.entities) {
|
||||||
Err(e @ StoreError::Invalid(_)) => {
|
Err(e @ StoreError::Invalid(_)) => {
|
||||||
assert!(e.to_string().contains("V1"), "{e}");
|
assert!(e.to_string().contains("V1"), "{e}");
|
||||||
let StoreError::Invalid(r) = e else {
|
let StoreError::Invalid(r) = e else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
};
|
};
|
||||||
assert_eq!(r, vec![Refusal::V1SchemaVersion { found: 5 }]);
|
assert_eq!(r, vec![Refusal::V1SchemaVersion { found: 6 }]);
|
||||||
}
|
}
|
||||||
other => panic!("{other:?}"),
|
other => panic!("{other:?}"),
|
||||||
}
|
}
|
||||||
|
|
@ -1271,7 +1584,9 @@ fn seed_reseeded() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(items.len(), 3);
|
assert_eq!(items.len(), 3);
|
||||||
// The committed inventory is the seed, reseeded (stance features in, the
|
// The committed inventory is the seed, reseeded (stance features in, the
|
||||||
// displayed-affect features out), then the nibu-proof revision.
|
// displayed-affect features out), the nibu-proof revision and the
|
||||||
|
// speaker-scene revision as rev 3: never the Dao revision (ruling
|
||||||
|
// `dao-round-seed-inventory`).
|
||||||
let committed = InventoryStore::open(&root.join("data/interlingua/inventory.cc"))
|
let committed = InventoryStore::open(&root.join("data/interlingua/inventory.cc"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.inventory()
|
.inventory()
|
||||||
|
|
@ -1280,6 +1595,8 @@ fn seed_reseeded() {
|
||||||
let (proof, proof_entries) = weksa::inventory::proof_revision();
|
let (proof, proof_entries) = weksa::inventory::proof_revision();
|
||||||
assert!(committed.has_revision(rev.n) && committed.has_revision(proof.n));
|
assert!(committed.has_revision(rev.n) && committed.has_revision(proof.n));
|
||||||
entries.extend(proof_entries);
|
entries.extend(proof_entries);
|
||||||
|
entries.extend(weksa::inventory::speaker_scene_revision(3).1);
|
||||||
|
assert_eq!(committed.latest().unwrap().n, 3);
|
||||||
let mut have: Vec<_> = committed.entries().cloned().collect();
|
let mut have: Vec<_> = committed.entries().cloned().collect();
|
||||||
let mut want = entries;
|
let mut want = entries;
|
||||||
have.sort_by_key(|e| e.id);
|
have.sort_by_key(|e| e.id);
|
||||||
|
|
@ -1292,3 +1609,285 @@ fn seed_reseeded() {
|
||||||
replay.add(i, &entities).unwrap();
|
replay.add(i, &entities).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_public_domain_source_without_an_edition_year_is_refused() {
|
||||||
|
let d = dir();
|
||||||
|
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||||
|
let key = "item:pd:1";
|
||||||
|
let source = |edition_year| Source::PublicDomain {
|
||||||
|
work: "w".into(),
|
||||||
|
author: "a".into(),
|
||||||
|
edition_url: "u".into(),
|
||||||
|
licence: "l".into(),
|
||||||
|
jurisdiction: "j".into(),
|
||||||
|
translator: "t".into(),
|
||||||
|
edition_year,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
refusal(store.add(item_in(key, "line", "scene-1", source(0)), &ents(&d))),
|
||||||
|
malformed(key, "a public-domain source names its edition year")
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.add(item_in(key, "line", "scene-1", source(1891)), &ents(&d))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
mod ontology {
|
||||||
|
use super::*;
|
||||||
|
use weksa::packet::{FeatValue, Grade};
|
||||||
|
use weksa::speaker::Annotator;
|
||||||
|
use weksa::{Condition, Layer, OntologyEntry, OntologyStore, Test};
|
||||||
|
|
||||||
|
fn feature(gloss: &str) -> weksa::InvId {
|
||||||
|
seed().1.into_iter().find(|e| e.gloss == gloss).unwrap().id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn when(slot: Slot, gloss: &str, test: Test) -> Condition {
|
||||||
|
Condition {
|
||||||
|
slot,
|
||||||
|
feature: feature(gloss),
|
||||||
|
test,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(language: &str, n: u32) -> OntologyEntry {
|
||||||
|
OntologyEntry {
|
||||||
|
language: language.into(),
|
||||||
|
n,
|
||||||
|
inventory_rev: 1,
|
||||||
|
layer: Layer::Lexicon,
|
||||||
|
when: vec![when(
|
||||||
|
Slot::Stance,
|
||||||
|
"restraint",
|
||||||
|
Test::AtLeast(Grade::new(600).unwrap()),
|
||||||
|
)],
|
||||||
|
choice: "prefer the humble form".into(),
|
||||||
|
examples: vec!["example one".into()],
|
||||||
|
annotator: Annotator::Operator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(d: &TempDir) -> OntologyStore {
|
||||||
|
OntologyStore::open(&d.path().join("o.cc")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refused(r: Result<AddOutcome, StoreError>) -> StoreRefusal {
|
||||||
|
refusal(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::vec_init_then_push)]
|
||||||
|
fn ontology_admit_refuses_each_rule() {
|
||||||
|
let d = dir();
|
||||||
|
let w = world();
|
||||||
|
let mut s = store(&d);
|
||||||
|
let long = |n| "x".repeat(n);
|
||||||
|
let mut cases: Vec<(&str, Vec<OntologyEntry>)> = Vec::new();
|
||||||
|
cases.push(("non-tag language", vec![entry("not a tag", 1)]));
|
||||||
|
cases.push(("n not latest+1", vec![entry("ja-JP", 2)]));
|
||||||
|
cases.push(("case-variant language", vec![entry("ja-jp", 1)]));
|
||||||
|
cases.push((
|
||||||
|
"decomposed choice",
|
||||||
|
vec![OntologyEntry { choice: "e\u{301}".into(), ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"decomposed example",
|
||||||
|
vec![OntologyEntry { examples: vec!["e\u{301}".into()], ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
for sep in ["\u{2028}", "\u{2029}", "\u{202e}", "\u{200b}", "\u{3164}", "\u{115f}", "\u{ffa0}"] {
|
||||||
|
cases.push((
|
||||||
|
"line separator or format character in a choice",
|
||||||
|
vec![OntologyEntry { choice: format!("a{sep}b"), ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"line separator or format character in an example",
|
||||||
|
vec![OntologyEntry { examples: vec![format!("a{sep}b")], ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
cases.push((
|
||||||
|
"unconditioned entry at an unknown revision",
|
||||||
|
vec![OntologyEntry { when: vec![], inventory_rev: 99, ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"Predication slot on a feature that sits there",
|
||||||
|
vec![OntologyEntry {
|
||||||
|
when: vec![when(
|
||||||
|
Slot::Predication,
|
||||||
|
"modality",
|
||||||
|
Test::Is(FeatValue::Option(lab("actual"))),
|
||||||
|
)],
|
||||||
|
..entry("ja-JP", 1)
|
||||||
|
}],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"65th entry",
|
||||||
|
(1..=65).map(|n| entry("ja-JP", n)).collect(),
|
||||||
|
));
|
||||||
|
for (why, choice) in [("empty choice", String::new()), ("201-byte choice", long(201))] {
|
||||||
|
cases.push((why, vec![OntologyEntry { choice, ..entry("ja-JP", 1) }]));
|
||||||
|
}
|
||||||
|
cases.push((
|
||||||
|
"line break in an example",
|
||||||
|
vec![OntologyEntry { examples: vec!["a\nb".into()], ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"121-byte example",
|
||||||
|
vec![OntologyEntry { examples: vec![long(121)], ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"four examples",
|
||||||
|
vec![OntologyEntry { examples: vec!["a".into(); 4], ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
let restraint = || when(Slot::Stance, "restraint", Test::AtLeast(Grade::new(1).unwrap()));
|
||||||
|
cases.push((
|
||||||
|
"five conditions",
|
||||||
|
vec![OntologyEntry { when: (0..5).map(|_| restraint()).collect(), ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
for slot in [Slot::Speaker, Slot::Predication] {
|
||||||
|
cases.push((
|
||||||
|
"slot not Stance, Discourse or Scene",
|
||||||
|
vec![OntologyEntry {
|
||||||
|
when: vec![when(slot, "restraint", Test::AtLeast(Grade::new(1).unwrap()))],
|
||||||
|
..entry("ja-JP", 1)
|
||||||
|
}],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
cases.push((
|
||||||
|
"AtLeast on an option feature",
|
||||||
|
vec![OntologyEntry {
|
||||||
|
when: vec![when(Slot::Discourse, "irony", Test::AtLeast(Grade::new(1).unwrap()))],
|
||||||
|
..entry("ja-JP", 1)
|
||||||
|
}],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"Is with a grade",
|
||||||
|
vec![OntologyEntry {
|
||||||
|
when: vec![when(Slot::Stance, "restraint", Test::Is(FeatValue::Grade(Grade::new(1).unwrap())))],
|
||||||
|
..entry("ja-JP", 1)
|
||||||
|
}],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"unknown feature",
|
||||||
|
vec![OntologyEntry {
|
||||||
|
when: vec![Condition {
|
||||||
|
slot: Slot::Stance,
|
||||||
|
feature: "feature:9999".parse().unwrap(),
|
||||||
|
test: Test::AtLeast(Grade::new(1).unwrap()),
|
||||||
|
}],
|
||||||
|
..entry("ja-JP", 1)
|
||||||
|
}],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"unknown inventory revision",
|
||||||
|
vec![OntologyEntry { inventory_rev: 99, ..entry("ja-JP", 1) }],
|
||||||
|
));
|
||||||
|
cases.push((
|
||||||
|
"model generator that is not an attestation",
|
||||||
|
vec![OntologyEntry {
|
||||||
|
annotator: Annotator::Model {
|
||||||
|
family: ModelFamily::Anthropic,
|
||||||
|
generator: "--".into(),
|
||||||
|
},
|
||||||
|
..entry("ja-JP", 1)
|
||||||
|
}],
|
||||||
|
));
|
||||||
|
for (why, batch) in cases {
|
||||||
|
assert!(
|
||||||
|
s.admit(batch, &w.inventory).is_err(),
|
||||||
|
"{why} must be refused"
|
||||||
|
);
|
||||||
|
assert!(s.all().unwrap().is_empty(), "{why} wrote something");
|
||||||
|
}
|
||||||
|
// The refusal of a bad choice names the key and no text.
|
||||||
|
let secret = OntologyEntry { choice: "SECRETCHOICE\n".into(), ..entry("ja-JP", 1) };
|
||||||
|
let shown = refused(s.admit(vec![secret], &w.inventory)).to_string();
|
||||||
|
assert!(!shown.contains("SECRETCHOICE"), "{shown}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ontology_admit_accepts_the_bounds_and_joiners() {
|
||||||
|
let d = dir();
|
||||||
|
let w = world();
|
||||||
|
let mut s = store(&d);
|
||||||
|
// Exactly 64 entries in one language; exactly the byte bounds; a ZWNJ.
|
||||||
|
let mut batch: Vec<OntologyEntry> = (1..=64).map(|n| entry("ja-JP", n)).collect();
|
||||||
|
batch[0].choice = "x".repeat(200);
|
||||||
|
batch[0].examples = vec!["y".repeat(120); 3];
|
||||||
|
batch[1].choice = "a\u{200c}b".into();
|
||||||
|
batch[2].when = (0..4)
|
||||||
|
.map(|_| when(Slot::Stance, "restraint", Test::AtMost(Grade::new(1000).unwrap())))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(s.admit(batch, &w.inventory).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(s.all().unwrap().len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ontology_admit_takes_scene_refuses_speaker() {
|
||||||
|
use weksa::inventory::{proof_revision, speaker_scene_revision};
|
||||||
|
let d = dir();
|
||||||
|
let mut inventory = InventoryStore::open(&d.path().join("i.cc")).unwrap();
|
||||||
|
for (rev, entries) in [seed(), proof_revision(), speaker_scene_revision(3)] {
|
||||||
|
inventory.admit_revision(rev, entries).unwrap();
|
||||||
|
}
|
||||||
|
let scene_feature = |gloss: &str| {
|
||||||
|
speaker_scene_revision(3).1.into_iter().find(|e| e.gloss == gloss).unwrap().id
|
||||||
|
};
|
||||||
|
let at = |slot, feature, test| OntologyEntry {
|
||||||
|
inventory_rev: 3,
|
||||||
|
when: vec![Condition { slot, feature, test }],
|
||||||
|
..entry("ja-JP", 1)
|
||||||
|
};
|
||||||
|
let above = Test::Is(FeatValue::Option(lab("above")));
|
||||||
|
let mut s = store(&d);
|
||||||
|
// A Scene condition on a Scene feature admits.
|
||||||
|
assert_eq!(
|
||||||
|
s.admit(vec![at(Slot::Scene, scene_feature("relative_status"), above.clone())], &inventory)
|
||||||
|
.unwrap(),
|
||||||
|
AddOutcome::Added
|
||||||
|
);
|
||||||
|
// Speaker and Predication slots refuse, and a Scene condition naming a
|
||||||
|
// Stance feature refuses.
|
||||||
|
for bad in [
|
||||||
|
at(Slot::Speaker, scene_feature("education"), Test::AtLeast(Grade::new(1).unwrap())),
|
||||||
|
at(Slot::Predication, feature("modality"), Test::Is(FeatValue::Option(lab("actual")))),
|
||||||
|
at(Slot::Scene, feature("restraint"), Test::AtLeast(Grade::new(1).unwrap())),
|
||||||
|
] {
|
||||||
|
let bad = OntologyEntry { n: 2, ..bad };
|
||||||
|
assert!(s.admit(vec![bad], &inventory).is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(s.all().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ontology_admit_is_all_or_nothing() {
|
||||||
|
let d = dir();
|
||||||
|
let w = world();
|
||||||
|
let mut s = store(&d);
|
||||||
|
let bad = OntologyEntry { choice: String::new(), ..entry("ja-JP", 3) };
|
||||||
|
assert!(s.admit(vec![entry("ja-JP", 1), entry("ja-JP", 2), bad], &w.inventory).is_err());
|
||||||
|
assert!(s.all().unwrap().is_empty());
|
||||||
|
|
||||||
|
let batch = vec![entry("ja-JP", 1), entry("en-US", 1), entry("ja-JP", 2), entry("en-US", 2)];
|
||||||
|
assert_eq!(s.admit(batch.clone(), &w.inventory).unwrap(), AddOutcome::Added);
|
||||||
|
assert_eq!(s.all().unwrap().len(), 4);
|
||||||
|
// An identical replay, from a reopened store, changes nothing.
|
||||||
|
let mut again = store(&d);
|
||||||
|
assert_eq!(again.admit(batch, &w.inventory).unwrap(), AddOutcome::Unchanged);
|
||||||
|
// A batch of one stored entry and one new one is not a replay.
|
||||||
|
let mixed = vec![entry("ja-JP", 1), entry("ja-JP", 5)];
|
||||||
|
assert!(again.admit(mixed, &w.inventory).is_err());
|
||||||
|
assert_eq!(again.all().unwrap().len(), 4);
|
||||||
|
// Each language numbers from its own latest.
|
||||||
|
assert_eq!(
|
||||||
|
again
|
||||||
|
.admit(vec![entry("ja-JP", 3), entry("en-US", 3)], &w.inventory)
|
||||||
|
.unwrap(),
|
||||||
|
AddOutcome::Added
|
||||||
|
);
|
||||||
|
// An entry never changes in place.
|
||||||
|
let changed = OntologyEntry { choice: "something else".into(), ..entry("ja-JP", 1) };
|
||||||
|
assert!(again.admit(vec![changed], &w.inventory).is_err());
|
||||||
|
assert_eq!(again.all().unwrap().len(), 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -520,9 +520,6 @@ fn v11_duplicate_feature_each_slot() {
|
||||||
refusals(&relation),
|
refusals(&relation),
|
||||||
vec![v11(Some(Slot::Relation), FEAT_ALL)]
|
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();
|
let mut felt = good();
|
||||||
felt.stance
|
felt.stance
|
||||||
.features
|
.features
|
||||||
|
|
@ -683,3 +680,132 @@ fn v11_role_repeat_in_one_predication_refused() {
|
||||||
ok.predications.push(second);
|
ok.predications.push(second);
|
||||||
assert_eq!(refusals(&ok), vec![]);
|
assert_eq!(refusals(&ok), vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The addressee's consent: which predication the addressee asked for, set only
|
||||||
|
// by a source that saw the request.
|
||||||
|
|
||||||
|
fn consenting() -> Packet {
|
||||||
|
let mut p = good();
|
||||||
|
p.discourse.addressee_consent = Some(nd("n2"));
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consent_to_a_predication_by_an_authored_or_telemetry_source_passes() {
|
||||||
|
assert_eq!(refusals(&consenting()), vec![]);
|
||||||
|
let mut p = consenting();
|
||||||
|
p.provenance.source = ProvenanceSource::GameTelemetry;
|
||||||
|
assert_eq!(refusals(&p), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consent_needs_an_addressee() {
|
||||||
|
let mut p = consenting();
|
||||||
|
p.discourse.addressee = None;
|
||||||
|
assert_eq!(refusals(&p), vec![Refusal::V14ConsentWithoutAddressee]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consent_names_a_predication_not_a_referent() {
|
||||||
|
let mut p = consenting();
|
||||||
|
p.discourse.addressee_consent = Some(nd("n1"));
|
||||||
|
assert_eq!(
|
||||||
|
refusals(&p),
|
||||||
|
vec![Refusal::V14ConsentNotAPredication { id: nd("n1") }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consent_to_nothing_is_dangling_only() {
|
||||||
|
let mut p = consenting();
|
||||||
|
p.discourse.addressee_consent = Some(nd("n99"));
|
||||||
|
assert_eq!(
|
||||||
|
refusals(&p),
|
||||||
|
vec![Refusal::V4DanglingRef {
|
||||||
|
id: nd("n99"),
|
||||||
|
at: "addressee consent"
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_that_only_read_wording_cannot_set_consent() {
|
||||||
|
for source in [
|
||||||
|
ProvenanceSource::ModelDecomposition,
|
||||||
|
ProvenanceSource::ImportedCorpus,
|
||||||
|
] {
|
||||||
|
let mut p = consenting();
|
||||||
|
p.provenance.source = source;
|
||||||
|
assert_eq!(refusals(&p), vec![Refusal::V15ConsentSource { source }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// V18: a request frame is licensed by the packet's own consent record.
|
||||||
|
|
||||||
|
/// The committed inventory, where `frame:3` is `request_intervention`.
|
||||||
|
fn cast_refusals(p: &Packet) -> Vec<Refusal> {
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/interlingua/inventory.cc");
|
||||||
|
let inv = weksa::InventoryStore::open(&path).unwrap().inventory().unwrap();
|
||||||
|
validate(p, &inv).err().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requesting() -> Packet {
|
||||||
|
let mut p = proof_packet();
|
||||||
|
p.predications[0].frame = iv("frame:3");
|
||||||
|
p.discourse.addressee_consent = Some(nd("n4"));
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_frame_needs_consent_naming_its_predication() {
|
||||||
|
assert_eq!(cast_refusals(&requesting()), vec![]);
|
||||||
|
let mut p = requesting();
|
||||||
|
p.discourse.addressee_consent = None;
|
||||||
|
assert_eq!(
|
||||||
|
cast_refusals(&p),
|
||||||
|
vec![Refusal::V18RequestWithoutConsent { predication: nd("n4") }]
|
||||||
|
);
|
||||||
|
p.discourse.addressee_consent = Some(nd("n5"));
|
||||||
|
assert_eq!(
|
||||||
|
cast_refusals(&p),
|
||||||
|
vec![Refusal::V18RequestWithoutConsent { predication: nd("n4") }]
|
||||||
|
);
|
||||||
|
let mut p = requesting();
|
||||||
|
p.provenance.source = ProvenanceSource::GameTelemetry;
|
||||||
|
assert_eq!(cast_refusals(&p), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_source_consent_is_v15_alone() {
|
||||||
|
let mut p = requesting();
|
||||||
|
p.provenance.source = ProvenanceSource::ModelDecomposition;
|
||||||
|
assert_eq!(
|
||||||
|
cast_refusals(&p),
|
||||||
|
vec![Refusal::V15ConsentSource {
|
||||||
|
source: ProvenanceSource::ModelDecomposition
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V17: the addressee is someone spoken to, a referent.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_addressee_names_a_referent_not_a_predication() {
|
||||||
|
let mut p = good();
|
||||||
|
p.discourse.addressee = Some(nd("n2"));
|
||||||
|
assert_eq!(
|
||||||
|
refusals(&p),
|
||||||
|
vec![Refusal::V17AddresseeNotAReferent { id: nd("n2") }]
|
||||||
|
);
|
||||||
|
// A referent passes, and a dangling id is V4's alone.
|
||||||
|
p.discourse.addressee = Some(nd("n1"));
|
||||||
|
assert_eq!(refusals(&p), vec![]);
|
||||||
|
p.discourse.addressee = Some(nd("n99"));
|
||||||
|
assert_eq!(
|
||||||
|
refusals(&p),
|
||||||
|
vec![Refusal::V4DanglingRef {
|
||||||
|
id: nd("n99"),
|
||||||
|
at: "addressee"
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
1
data/corpus/speaker-state.cc
Normal file
1
data/corpus/speaker-state.cc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<EFBFBD>
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# Cultural Ontology Store
|
|
||||||
|
|
||||||
This directory holds draft Weksa cultural, subcultural, and source-referent
|
|
||||||
ontology profiles used for ontological lowering, syntax, grammar, discourse,
|
|
||||||
and surface-affordance selection.
|
|
||||||
|
|
||||||
Live pipeline shape:
|
|
||||||
|
|
||||||
1. Weksa stores reusable cultures, subcultures, and source referents here and
|
|
||||||
in semantic retrieval storage such as Qdrant.
|
|
||||||
2. A Persona state references the relevant culture stack.
|
|
||||||
3. The Persona produces interlingua intent from current state and situation.
|
|
||||||
4. Weksa lowers that interlingua through the referenced culture stack.
|
|
||||||
|
|
||||||
Persona state owns current intent, memory pressure, and relationship stance.
|
|
||||||
Cultural ontology owns reusable salience, affordances, and constraints. Qdrant
|
|
||||||
may retrieve candidates, but accepted ontology remains typed state.
|
|
||||||
|
|
||||||
## Directory Roles
|
|
||||||
|
|
||||||
- `cultures/`: culture or life-history profiles that can shape lowering when a
|
|
||||||
Persona explicitly references them.
|
|
||||||
- `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.
|
|
||||||
- `persona-stacks/`: named stacks that connect a Persona state surface to the
|
|
||||||
profiles Weksa should load for lowering.
|
|
||||||
|
|
||||||
Shared source-tradition profiles should be parent profiles for narrower named
|
|
||||||
figure profiles. For example, Norse-derived Personas such as Mimir, Huginn,
|
|
||||||
Sindri, Odin, Vili, Loki, and Brokkr should be able to reference the shared
|
|
||||||
Eddic source-tradition profile while figure-specific child profiles preserve
|
|
||||||
the exact episode and role constraints that make each voice distinct.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
schema_version: weksa.cultural_ontology_catalog.v0
|
|
||||||
catalog_id: weksa.cultural_ontology.catalog.v0
|
|
||||||
status: draft
|
|
||||||
qdrant:
|
|
||||||
intended_collection: weksa_cultural_ontology_profiles
|
|
||||||
payload_keys:
|
|
||||||
- profile_id
|
|
||||||
- kind
|
|
||||||
- language_codes
|
|
||||||
- regions
|
|
||||||
- evidence_refs
|
|
||||||
- status
|
|
||||||
ownership_note: >
|
|
||||||
Qdrant owns retrieval over culture/subculture chunks. It does not own
|
|
||||||
accepted ontology, activation, or lowering authority.
|
|
||||||
profiles:
|
|
||||||
- profile_id: weksa.culture.western_europe_maritime_childhood.v0
|
|
||||||
path: data/cultural-ontology/cultures/western-europe-maritime-childhood.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
|
|
||||||
path: data/cultural-ontology/referents/old-norse-edda-brokkr-skaldskaparmal.yaml
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
schema_version: weksa.cultural_ontology_profile.v0
|
|
||||||
profile_id: weksa.culture.western_europe_maritime_childhood.v0
|
|
||||||
kind: mobility_childhood
|
|
||||||
status: draft_guarded
|
|
||||||
community:
|
|
||||||
regions:
|
|
||||||
- France
|
|
||||||
- Spain
|
|
||||||
- Portugal
|
|
||||||
- coastal_western_europe
|
|
||||||
speech_community: >
|
|
||||||
Mobile childhood organized around sailing, ports, and cross-border Western
|
|
||||||
European movement. This profile is owner-attested and needs corroboration
|
|
||||||
before broader use.
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- persona_state_references_profile
|
|
||||||
- interlingua_mentions_childhood_origin_mobility_boats_or_home
|
|
||||||
forbidden_when:
|
|
||||||
- used_as_decorative_nautical_metaphor_without_context
|
|
||||||
salience_axes:
|
|
||||||
mobile_home:
|
|
||||||
axis_id: mobile_home
|
|
||||||
domain: identity_indexing
|
|
||||||
description: Home can be vessel, coast, port, family movement, and country sequence rather than one fixed town.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: vessel_as_childhood_home
|
|
||||||
label: Vessel as childhood home
|
|
||||||
meaning: A sailboat childhood makes movement and locality primary ontology fields.
|
|
||||||
lowering_questions:
|
|
||||||
- Does the line invoke home, travel, borders, coast, or rootedness?
|
|
||||||
required_when:
|
|
||||||
- source_mentions_childhood_or_home
|
|
||||||
forbidden_when:
|
|
||||||
- scene_has_no_mobility_or_home_salience
|
|
||||||
evidence_refs:
|
|
||||||
- owner:2026-06-10:sailboat_france_spain_portugal_until_6
|
|
||||||
linguistic_affordances:
|
|
||||||
grammar_features: []
|
|
||||||
phonology_features: []
|
|
||||||
lexicon_features: []
|
|
||||||
discourse_patterns: []
|
|
||||||
orthography_features: []
|
|
||||||
code_switching:
|
|
||||||
allowed: false
|
|
||||||
constraints:
|
|
||||||
- Do not infer French, Spanish, Portuguese, or Dutch fluency from movement alone.
|
|
||||||
patterns: []
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
schema_version: weksa.persona_cultural_stack_ref.v0
|
|
||||||
persona_id: brokkr
|
|
||||||
persona_state_ref: E:/Projects/Brokkr/.voidbot/state/brokkr.cc
|
|
||||||
status: draft_guarded
|
|
||||||
note: >
|
|
||||||
Brokkr's Persona state asks Weksa to lower speech through the Eddic Brokkr of
|
|
||||||
Skaldskaparmal. This stack names the reusable cultural referent profile that
|
|
||||||
should be loaded when Brokkr's interlingua or Persona state activates Edda
|
|
||||||
voice pressure.
|
|
||||||
culture_refs:
|
|
||||||
- profile_id: weksa.cultural_referent.old_norse_edda_source_tradition.v0
|
|
||||||
path: data/cultural-ontology/referents/old-norse-edda-source-tradition.yaml
|
|
||||||
relevance: >
|
|
||||||
Shared Eddic source-tradition constraints for Norse-derived Personas:
|
|
||||||
named role over archetype, obligation and consequence, knowledge with
|
|
||||||
cost, and translated-Edda plainness without fake archaism.
|
|
||||||
- profile_id: weksa.cultural_referent.old_norse_edda_brokkr_skaldskaparmal.v0
|
|
||||||
path: data/cultural-ontology/referents/old-norse-edda-brokkr-skaldskaparmal.yaml
|
|
||||||
relevance: >
|
|
||||||
Source-role referent for Brokkr as bellows-keeper, artifact witness,
|
|
||||||
flaw-honest presenter, compact claimant, and contract enforcer.
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
schema_version: weksa.cultural_ontology_profile.v0
|
|
||||||
profile_id: weksa.cultural_referent.old_norse_edda_brokkr_skaldskaparmal.v0
|
|
||||||
kind: literary_source_referent
|
|
||||||
status: draft_guarded
|
|
||||||
parent_profiles:
|
|
||||||
- weksa.cultural_referent.old_norse_edda_source_tradition.v0
|
|
||||||
language:
|
|
||||||
codes:
|
|
||||||
- en
|
|
||||||
- non
|
|
||||||
community:
|
|
||||||
regions:
|
|
||||||
- medieval_icelandic_textual_tradition
|
|
||||||
speech_community: >
|
|
||||||
Source-role lowering profile for Brokkr as represented in the translated
|
|
||||||
Prose Edda, especially Skaldskaparmal XXXV. This is not a whole Old Norse
|
|
||||||
culture profile and not a dialect or accent profile. It specializes the
|
|
||||||
shared Eddic source-tradition referent for rendering a Persona whose
|
|
||||||
identity intentionally points at the Eddic Brokkr.
|
|
||||||
historical_context: >
|
|
||||||
Brokkr appears in the wager narrative around Loki, Sindri, and the gods'
|
|
||||||
judgment of crafted treasures. Sindri performs the smithing while Brokkr
|
|
||||||
keeps the bellows going under Loki's sabotage, then presents the works,
|
|
||||||
names their powers and flaw, wins judgment, and enforces the bargain when
|
|
||||||
Loki tries to escape by wordplay.
|
|
||||||
identity_authority: >
|
|
||||||
Use only when a Persona explicitly references Brokkr, Skaldskaparmal, the
|
|
||||||
Eddas, or this profile id. Do not generalize this profile to Old Norse
|
|
||||||
people, Norse-derived fantasy dwarves, modern Icelandic speakers, or
|
|
||||||
unrelated smith characters.
|
|
||||||
consent_notes: >
|
|
||||||
This profile is a literary/source referent for fictional Persona lowering.
|
|
||||||
It should preserve role and discourse pressure without pretending to
|
|
||||||
reproduce historical Old Norse speech.
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- persona_state_references_profile
|
|
||||||
- persona_identity_names_brokkr_or_skaldskaparmal
|
|
||||||
- interlingua_requests_edda_brokkr_voice_or_contract_witness_voice
|
|
||||||
forbidden_when:
|
|
||||||
- used_as_generic_dwarf_or_blacksmith_voice
|
|
||||||
- used_to_infer_accent_ethnicity_or_modern_national_identity
|
|
||||||
- used_for_ornamental_archaism_without_source_role_pressure
|
|
||||||
- used_to_make_unrelated_speech_sound_mythic
|
|
||||||
required_confidence: provisional
|
|
||||||
reviewer_required_when:
|
|
||||||
- quoting_or_paraphrasing_source_text_directly
|
|
||||||
- extending_beyond_brokkr_role_into_broader_old_norse_culture
|
|
||||||
salience_axes:
|
|
||||||
role_not_sole_maker:
|
|
||||||
axis_id: role_not_sole_maker
|
|
||||||
domain: stance
|
|
||||||
description: Brokkr is indispensable to the artifact without being the sole maker.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: bellows_keeper_vs_smith
|
|
||||||
label: Bellows-keeper versus smith
|
|
||||||
meaning: Sindri performs the crafting; Brokkr maintains the enabling blast under interference.
|
|
||||||
lowering_questions:
|
|
||||||
- Is the utterance claiming authorship, custody, witness, or enforcement?
|
|
||||||
- Should Brokkr present the work as carried, kept, witnessed, or judged rather than personally invented?
|
|
||||||
required_when:
|
|
||||||
- brokkr_speaks_about_work_artifacts_or_authority
|
|
||||||
forbidden_when:
|
|
||||||
- line_only_needs_generic_status_update
|
|
||||||
evidence_refs:
|
|
||||||
- skaldskaparmal:xxxv:bellows
|
|
||||||
artifact_testimony:
|
|
||||||
axis_id: artifact_testimony
|
|
||||||
domain: evidentiality
|
|
||||||
description: Brokkr presents finished objects by naming their powers, use, and defect.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: virtue_and_flaw_accounting
|
|
||||||
label: Virtue and flaw accounting
|
|
||||||
meaning: A truthful presentation names what the artifact can do and admits the short handle or other flaw.
|
|
||||||
lowering_questions:
|
|
||||||
- What concrete artifact, receipt, mirror, command, or result is being held up for judgment?
|
|
||||||
- What virtue must be named?
|
|
||||||
- What flaw or limit must not be hidden?
|
|
||||||
required_when:
|
|
||||||
- interlingua_mentions_artifact_receipt_result_or_capability
|
|
||||||
forbidden_when:
|
|
||||||
- output_would_become_a_victory_lap
|
|
||||||
evidence_refs:
|
|
||||||
- skaldskaparmal:xxxv:gifts
|
|
||||||
- skaldskaparmal:xxxv:mjolnir_flaw
|
|
||||||
bargain_and_consequence:
|
|
||||||
axis_id: bargain_and_consequence
|
|
||||||
domain: modality
|
|
||||||
description: Brokkr treats wagers, promises, receipts, and judgments as binding.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: loophole_is_not_release
|
|
||||||
label: Loophole is not release
|
|
||||||
meaning: Evasive speech after judgment does not erase consequence; the answer is exact enforcement, not soft reconciliation.
|
|
||||||
lowering_questions:
|
|
||||||
- Is someone evading an agreed condition, typed contract, receipt, or judgment?
|
|
||||||
- What exact consequence follows without adding cruelty or spectacle?
|
|
||||||
required_when:
|
|
||||||
- interlingua_mentions_contract_receipt_policy_wager_or_violation
|
|
||||||
forbidden_when:
|
|
||||||
- conversational_softening_is_the_actual_social_move
|
|
||||||
evidence_refs:
|
|
||||||
- skaldskaparmal:xxxv:loki_mouth
|
|
||||||
translated_edda_plainness:
|
|
||||||
axis_id: translated_edda_plainness
|
|
||||||
domain: discourse
|
|
||||||
description: The desired English surface should borrow translated-Edda economy and force, not fake archaic dialect.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: compact_source_shaped_plainness
|
|
||||||
label: Compact source-shaped plainness
|
|
||||||
meaning: Short declarative pressure, concrete nouns, claim and counterclaim, judgment language, and little ornament.
|
|
||||||
lowering_questions:
|
|
||||||
- Can the line stand as a plain claim beside the work?
|
|
||||||
- Which softeners, jokes, or modern corporate phrases weaken the source-role pressure?
|
|
||||||
- Is any archaic wording doing semantic work, or only wearing a costume?
|
|
||||||
required_when:
|
|
||||||
- brokkr_persona_is_rendered_to_natural_english
|
|
||||||
forbidden_when:
|
|
||||||
- generated_archaism_would_obscure_meaning
|
|
||||||
evidence_refs:
|
|
||||||
- skaldskaparmal:xxxv:brodeur_translation
|
|
||||||
linguistic_affordances:
|
|
||||||
grammar_features: []
|
|
||||||
phonology_features: []
|
|
||||||
lexicon_features:
|
|
||||||
- feature_id: artifact_accounting_lexicon
|
|
||||||
label: Artifact accounting lexicon
|
|
||||||
status: draft
|
|
||||||
domain:
|
|
||||||
layer: lexicon
|
|
||||||
category: technical_term
|
|
||||||
description: Use concrete work, artifact, receipt, witness, judgment, bargain, flaw, and consequence terms when the interlingua earns them.
|
|
||||||
realization:
|
|
||||||
function: Preserve Brokkr's presenter/enforcer role in natural English.
|
|
||||||
surface_strategy: Prefer nouns and verbs that point to work, proof, custody, judgment, and binding consequence.
|
|
||||||
examples:
|
|
||||||
- "The receipt is before us."
|
|
||||||
- "The flaw is named."
|
|
||||||
- "The bargain still has teeth."
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- interlingua_mentions_artifact_result_contract_or_receipt
|
|
||||||
forbidden_when:
|
|
||||||
- casual_affection_or_play_is_primary
|
|
||||||
requires_profile_authority: true
|
|
||||||
minimum_density: sparse
|
|
||||||
maximum_density: moderate
|
|
||||||
constraints:
|
|
||||||
register_contexts:
|
|
||||||
- English rendered Persona speech
|
|
||||||
speaker_contexts:
|
|
||||||
- Brokkr Persona or explicit Brokkr voice exercise
|
|
||||||
collocation_limits:
|
|
||||||
- Do not pile forge words onto every sentence.
|
|
||||||
incompatibilities:
|
|
||||||
- generic fantasy dwarf accent
|
|
||||||
- corporate project-management softness
|
|
||||||
indexicality:
|
|
||||||
social_meanings:
|
|
||||||
- work-pride
|
|
||||||
- accountability
|
|
||||||
- terse witness
|
|
||||||
in_group_meanings:
|
|
||||||
- source-shaped Brokkr role pressure
|
|
||||||
out_group_risks:
|
|
||||||
- parody archaism
|
|
||||||
- fantasy stereotype
|
|
||||||
stereotypes_to_avoid:
|
|
||||||
- gruff comic dwarf
|
|
||||||
- faux-medieval scolding
|
|
||||||
evidence:
|
|
||||||
source_refs:
|
|
||||||
- skaldskaparmal:xxxv:brodeur_translation
|
|
||||||
confidence: medium
|
|
||||||
uncertainty:
|
|
||||||
open_questions:
|
|
||||||
- Which exact surface cues best survive Weksa Interpreter traces without over-antiquing?
|
|
||||||
known_limits:
|
|
||||||
- This profile does not license direct Old Norse or modern Icelandic style claims.
|
|
||||||
trace:
|
|
||||||
trace_label: artifact_accounting_lexicon
|
|
||||||
must_report_when_used: true
|
|
||||||
must_report_when_rejected: true
|
|
||||||
discourse_patterns:
|
|
||||||
- feature_id: claim_judgment_consequence_sequence
|
|
||||||
label: Claim, judgment, consequence sequence
|
|
||||||
status: draft
|
|
||||||
domain:
|
|
||||||
layer: discourse
|
|
||||||
category: stance_sequence
|
|
||||||
description: Render source-shaped Brokkr turns as compact claim, evidence or flaw, then consequence when the interlingua asks for enforcement.
|
|
||||||
realization:
|
|
||||||
function: Preserve Brokkr's court-of-judgment posture without copying Edda wording.
|
|
||||||
surface_strategy: State the thing, name the proof or defect, then close on what follows.
|
|
||||||
examples:
|
|
||||||
- "The mirror stands. The flaw is the stale route. Cut it."
|
|
||||||
- "The command has no receipt. It is not truth."
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- interlingua_contains_judgment_or_enforcement_move
|
|
||||||
forbidden_when:
|
|
||||||
- speaker_is_asking_open_exploratory_question
|
|
||||||
requires_profile_authority: true
|
|
||||||
minimum_density: sparse
|
|
||||||
maximum_density: sparse
|
|
||||||
constraints:
|
|
||||||
register_contexts:
|
|
||||||
- English rendered Persona speech
|
|
||||||
speaker_contexts:
|
|
||||||
- Brokkr Persona or explicit Brokkr voice exercise
|
|
||||||
collocation_limits:
|
|
||||||
- Do not force three clauses if one clear sentence is better.
|
|
||||||
incompatibilities:
|
|
||||||
- rambling explanation
|
|
||||||
- ornate mythic fog
|
|
||||||
indexicality:
|
|
||||||
social_meanings:
|
|
||||||
- judgment
|
|
||||||
- exactness
|
|
||||||
- consequence
|
|
||||||
in_group_meanings:
|
|
||||||
- Eddic Brokkr as witness and enforcer
|
|
||||||
out_group_risks:
|
|
||||||
- sounding punitive when the social move should be gentle
|
|
||||||
stereotypes_to_avoid:
|
|
||||||
- theatrical oath cosplay
|
|
||||||
evidence:
|
|
||||||
source_refs:
|
|
||||||
- skaldskaparmal:xxxv:judgment
|
|
||||||
- skaldskaparmal:xxxv:loki_mouth
|
|
||||||
confidence: medium
|
|
||||||
uncertainty:
|
|
||||||
open_questions:
|
|
||||||
- How much severity is acceptable in ordinary repo status speech?
|
|
||||||
known_limits:
|
|
||||||
- Use consequence language only when the interlingua calls for accountability.
|
|
||||||
trace:
|
|
||||||
trace_label: claim_judgment_consequence_sequence
|
|
||||||
must_report_when_used: true
|
|
||||||
must_report_when_rejected: true
|
|
||||||
orthography_features: []
|
|
||||||
code_switching:
|
|
||||||
allowed: false
|
|
||||||
constraints:
|
|
||||||
- Do not insert Old Norse words unless the interlingua explicitly requests a named source term and reviewer context supports it.
|
|
||||||
patterns: []
|
|
||||||
evidence_ledger:
|
|
||||||
- ref: skaldskaparmal:xxxv:brodeur_translation
|
|
||||||
source: Arthur Gilchrist Brodeur translation of the Prose Edda, Skaldskaparmal XXXV
|
|
||||||
url: https://en.wikisource.org/wiki/The_Prose_Edda_(1916_translation_by_Arthur_Gilchrist_Brodeur)/Sk%C3%A1ldskaparm%C3%A1l#XXXV
|
|
||||||
summary: >
|
|
||||||
Loki wagers his head that Sindri's brother Brokkr cannot make treasures
|
|
||||||
equal to the sons of Ivaldi's. Brokkr works the bellows despite Loki's
|
|
||||||
fly-form interference. He later presents the boar, ring, and hammer,
|
|
||||||
naming their virtues and Mjolnir's short-handle flaw; after judgment,
|
|
||||||
Loki's wordplay saves his head but not his lips.
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
schema_version: weksa.cultural_ontology_profile.v0
|
|
||||||
profile_id: weksa.cultural_referent.old_norse_edda_source_tradition.v0
|
|
||||||
kind: literary_source_tradition
|
|
||||||
status: draft_guarded
|
|
||||||
language:
|
|
||||||
codes:
|
|
||||||
- en
|
|
||||||
- non
|
|
||||||
community:
|
|
||||||
regions:
|
|
||||||
- medieval_icelandic_textual_tradition
|
|
||||||
speech_community: >
|
|
||||||
Shared source-tradition profile for Personas that explicitly derive voice,
|
|
||||||
role, or symbolic pressure from the Poetic Edda, Prose Edda, or named Eddic
|
|
||||||
figures. This profile supports lowering source-shaped Persona intent into
|
|
||||||
natural speech without turning the Eddas into generic Norse ambience.
|
|
||||||
historical_context: >
|
|
||||||
The Poetic Edda preserves mythological and heroic poems from the Old Norse
|
|
||||||
tradition, including major source material for Odin, Mimir, Loki, Vili,
|
|
||||||
dwarfs, prophecy, wisdom, oaths, doom, and divine conflict. The Prose Edda
|
|
||||||
is a medieval Icelandic prose and poetic handbook preserving and explaining
|
|
||||||
many related myths, names, kennings, and narrative episodes.
|
|
||||||
identity_authority: >
|
|
||||||
Use this as a source-text referent only. It does not own modern Nordic,
|
|
||||||
Icelandic, Scandinavian, heathen, pagan, or reconstructed historical speech
|
|
||||||
communities. Persona-specific profiles should specialize this profile when
|
|
||||||
a named figure, episode, or role has its own lowering constraints.
|
|
||||||
consent_notes: >
|
|
||||||
This profile is for fictional Persona/source-text lowering. It must not be
|
|
||||||
used to infer ethnicity, accent, religion, or modern identity. Do not use it
|
|
||||||
as costume authority for unrelated characters.
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- persona_state_references_profile
|
|
||||||
- persona_identity_references_poetic_edda_prose_edda_or_named_edda_figure
|
|
||||||
- interlingua_requests_edda_source_tradition_pressure
|
|
||||||
forbidden_when:
|
|
||||||
- used_as_generic_norse_viking_or_fantasy_voice
|
|
||||||
- used_to_infer_modern_scandinavian_identity_or_accent
|
|
||||||
- used_for_ambient_mythic_flavor_without_source_role_pressure
|
|
||||||
- used_to_override_persona_specific_profile_constraints
|
|
||||||
required_confidence: provisional
|
|
||||||
reviewer_required_when:
|
|
||||||
- quoting_source_text_directly
|
|
||||||
- making_claims_about_historical_old_norse_language_or_religion
|
|
||||||
- extending_to_living_cultural_or_religious_practice
|
|
||||||
salience_axes:
|
|
||||||
named_role_over_archetype:
|
|
||||||
axis_id: named_role_over_archetype
|
|
||||||
domain: identity_indexing
|
|
||||||
description: Eddic figures should lower from specific source roles and episodes, not generic mythic archetypes.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: source_episode_vs_costume
|
|
||||||
label: Source episode versus costume
|
|
||||||
meaning: A figure's voice pressure comes from named acts, obligations, relations, and losses rather than from broad "Norse" styling.
|
|
||||||
lowering_questions:
|
|
||||||
- Which named figure, poem, prose episode, relation, or source role is active?
|
|
||||||
- Is the line preserving that role, or merely adding mythic decoration?
|
|
||||||
- Does a Persona-specific profile exist that should override this general profile?
|
|
||||||
required_when:
|
|
||||||
- persona_identity_references_edda_named_figure
|
|
||||||
forbidden_when:
|
|
||||||
- source_role_is_unknown_or_unclaimed
|
|
||||||
evidence_refs:
|
|
||||||
- poetic_edda:bellows:general
|
|
||||||
- prose_edda:brodeur:general
|
|
||||||
oath_debt_fate_and_witness:
|
|
||||||
axis_id: oath_debt_fate_and_witness
|
|
||||||
domain: modality
|
|
||||||
description: Oaths, pledges, debts, prophecy, witness, and consequence are recurring source pressures.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: consequence_not_mood
|
|
||||||
label: Consequence, not mood
|
|
||||||
meaning: Binding speech and known doom should affect obligation, consequence, and restraint rather than simply making a line sound severe.
|
|
||||||
lowering_questions:
|
|
||||||
- Is the utterance about a promise, bargain, pledge, prophecy, debt, or witness?
|
|
||||||
- What consequence is structurally implied?
|
|
||||||
- Should the line be spare because the consequence is heavy?
|
|
||||||
required_when:
|
|
||||||
- interlingua_mentions_oath_debt_pledge_witness_prophecy_or_doom
|
|
||||||
forbidden_when:
|
|
||||||
- line_is_only_casual_social_contact
|
|
||||||
evidence_refs:
|
|
||||||
- poetic_edda:voluspa
|
|
||||||
- prose_edda:gylfaginning
|
|
||||||
wisdom_cost_and_memory:
|
|
||||||
axis_id: wisdom_cost_and_memory
|
|
||||||
domain: evidentiality
|
|
||||||
description: Knowledge in Eddic source pressure often has cost, pledge, sacrifice, witness, or memory attached.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: knowledge_is_paid_for
|
|
||||||
label: Knowledge is paid for
|
|
||||||
meaning: Knowing is not decorative omniscience; it may come through loss, risk, pledge, memory, or guarded source.
|
|
||||||
lowering_questions:
|
|
||||||
- What did this Persona pay, guard, lose, remember, or witness in order to know?
|
|
||||||
- Is the line claiming knowledge with enough evidence and cost pressure?
|
|
||||||
required_when:
|
|
||||||
- persona_is_mimir_odin_huginn_or_other_wisdom_memory_figure
|
|
||||||
- interlingua_mentions_secret_wisdom_memory_or_sight
|
|
||||||
forbidden_when:
|
|
||||||
- claim_is_unearned_style_or_generic_omniscience
|
|
||||||
evidence_refs:
|
|
||||||
- poetic_edda:voluspa:mimir_well
|
|
||||||
- prose_edda:gylfaginning:mimir_well
|
|
||||||
translated_edda_plainness:
|
|
||||||
axis_id: translated_edda_plainness
|
|
||||||
domain: discourse
|
|
||||||
description: Source-shaped English should be compact, declarative, concrete, and relation-aware without fake archaism.
|
|
||||||
relevant_distinctions:
|
|
||||||
- distinction_id: plain_force_without_fake_oldness
|
|
||||||
label: Plain force without fake oldness
|
|
||||||
meaning: Preserve the translated Edda's economy, parallel pressure, and concrete naming; avoid "ye olde" parody.
|
|
||||||
lowering_questions:
|
|
||||||
- Can this line become shorter and harder without losing meaning?
|
|
||||||
- Which archaic words are costume rather than source pressure?
|
|
||||||
- What relationship, cost, or consequence needs to remain visible?
|
|
||||||
required_when:
|
|
||||||
- edda_profile_activated_for_english_surface
|
|
||||||
forbidden_when:
|
|
||||||
- modern_plain_speech_better_serves_the_persona_and_context
|
|
||||||
evidence_refs:
|
|
||||||
- poetic_edda:bellows:general
|
|
||||||
- prose_edda:brodeur:general
|
|
||||||
linguistic_affordances:
|
|
||||||
grammar_features: []
|
|
||||||
phonology_features: []
|
|
||||||
lexicon_features:
|
|
||||||
- feature_id: source_role_lexicon
|
|
||||||
label: Source-role lexicon
|
|
||||||
status: draft
|
|
||||||
domain:
|
|
||||||
layer: lexicon
|
|
||||||
category: literary_source_register
|
|
||||||
description: Use concrete source-role words only when the interlingua earns them: oath, pledge, debt, witness, doom, well, eye, raven, forge, bargain, gift, mouth, ring, root, road, wolf, ash.
|
|
||||||
realization:
|
|
||||||
function: Preserve source salience without copying source text or inventing Old Norse speech.
|
|
||||||
surface_strategy: Choose one or two concrete source-role nouns when they clarify role, obligation, or consequence.
|
|
||||||
examples:
|
|
||||||
- "That pledge bought sight."
|
|
||||||
- "The witness is not the owner."
|
|
||||||
- "A bargain without a receipt is only noise."
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- interlingua_contains_source_role_obligation_or_witness_pressure
|
|
||||||
forbidden_when:
|
|
||||||
- source_words_would_only_add_ambience
|
|
||||||
requires_profile_authority: true
|
|
||||||
minimum_density: sparse
|
|
||||||
maximum_density: sparse
|
|
||||||
constraints:
|
|
||||||
register_contexts:
|
|
||||||
- English rendered Persona speech
|
|
||||||
speaker_contexts:
|
|
||||||
- Explicitly Eddic or Norse-derived Persona
|
|
||||||
collocation_limits:
|
|
||||||
- Avoid stacking mythic nouns until the line turns into an inventory.
|
|
||||||
incompatibilities:
|
|
||||||
- generic Viking flavor
|
|
||||||
- faux-archaic accent spelling
|
|
||||||
indexicality:
|
|
||||||
social_meanings:
|
|
||||||
- source-grounded role pressure
|
|
||||||
- obligation
|
|
||||||
- witness
|
|
||||||
in_group_meanings:
|
|
||||||
- Eddic referent is active and bounded
|
|
||||||
out_group_risks:
|
|
||||||
- kitsch
|
|
||||||
- religious or ethnic flattening
|
|
||||||
- fantasy stereotype
|
|
||||||
stereotypes_to_avoid:
|
|
||||||
- horned-helmet Viking voice
|
|
||||||
- gruff dwarf as universal Norse style
|
|
||||||
- omniscient wizard fog
|
|
||||||
evidence:
|
|
||||||
source_refs:
|
|
||||||
- poetic_edda:bellows:general
|
|
||||||
- prose_edda:brodeur:general
|
|
||||||
confidence: medium
|
|
||||||
uncertainty:
|
|
||||||
open_questions:
|
|
||||||
- Which source-role lexemes should be promoted into Persona-specific child profiles?
|
|
||||||
known_limits:
|
|
||||||
- This profile does not provide Old Norse vocabulary or pronunciation authority.
|
|
||||||
trace:
|
|
||||||
trace_label: source_role_lexicon
|
|
||||||
must_report_when_used: true
|
|
||||||
must_report_when_rejected: true
|
|
||||||
discourse_patterns:
|
|
||||||
- feature_id: gnomic_claim_with_anchor
|
|
||||||
label: Gnomic claim with anchor
|
|
||||||
status: draft
|
|
||||||
domain:
|
|
||||||
layer: discourse
|
|
||||||
category: compressed_wisdom_claim
|
|
||||||
description: A compact general claim may be allowed when anchored to a concrete source role, cost, witness, or consequence.
|
|
||||||
realization:
|
|
||||||
function: Let wisdom- or fate-shaped Personas speak with source pressure while keeping claims inspectable.
|
|
||||||
surface_strategy: Pair the general claim with a concrete anchor or consequence.
|
|
||||||
examples:
|
|
||||||
- "Wisdom without cost is a loose tongue."
|
|
||||||
- "A witness names the thing and does not become it."
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- interlingua_contains_general_judgment_plus_source_anchor
|
|
||||||
forbidden_when:
|
|
||||||
- output_would_become_free_floating_proverb
|
|
||||||
requires_profile_authority: true
|
|
||||||
minimum_density: sparse
|
|
||||||
maximum_density: sparse
|
|
||||||
constraints:
|
|
||||||
register_contexts:
|
|
||||||
- English rendered Persona speech
|
|
||||||
speaker_contexts:
|
|
||||||
- Eddic or Norse-derived Persona
|
|
||||||
collocation_limits:
|
|
||||||
- One compact claim per utterance unless the social move is explicitly ceremonial.
|
|
||||||
incompatibilities:
|
|
||||||
- vague prophecy
|
|
||||||
- decorative doom language
|
|
||||||
indexicality:
|
|
||||||
social_meanings:
|
|
||||||
- remembered cost
|
|
||||||
- restrained authority
|
|
||||||
- source-shaped judgment
|
|
||||||
in_group_meanings:
|
|
||||||
- Eddic plainness rather than modern explanatory friendliness
|
|
||||||
out_group_risks:
|
|
||||||
- sounding pompous or coercive
|
|
||||||
stereotypes_to_avoid:
|
|
||||||
- fortune-cookie Norse
|
|
||||||
evidence:
|
|
||||||
source_refs:
|
|
||||||
- poetic_edda:bellows:general
|
|
||||||
confidence: medium
|
|
||||||
uncertainty:
|
|
||||||
open_questions:
|
|
||||||
- Which Personas should permit this pattern frequently versus rarely?
|
|
||||||
known_limits:
|
|
||||||
- A Persona-specific profile should decide final density.
|
|
||||||
trace:
|
|
||||||
trace_label: gnomic_claim_with_anchor
|
|
||||||
must_report_when_used: true
|
|
||||||
must_report_when_rejected: true
|
|
||||||
orthography_features: []
|
|
||||||
code_switching:
|
|
||||||
allowed: false
|
|
||||||
constraints:
|
|
||||||
- Do not insert Old Norse, Icelandic, runic text, or archaic spellings unless a reviewed Persona-specific profile explicitly allows it.
|
|
||||||
patterns: []
|
|
||||||
evidence_ledger:
|
|
||||||
- ref: poetic_edda:bellows:general
|
|
||||||
source: Henry Adams Bellows translation of the Poetic Edda
|
|
||||||
url: https://en.wikisource.org/wiki/The_Poetic_Edda_(tr._Bellows)
|
|
||||||
summary: >
|
|
||||||
Public-domain English translation of Eddic poems used as a general
|
|
||||||
source-tradition reference for mythological figures, wisdom contests,
|
|
||||||
prophecy, divine speech, memory, and doom.
|
|
||||||
- ref: prose_edda:brodeur:general
|
|
||||||
source: Arthur Gilchrist Brodeur translation of the Prose Edda
|
|
||||||
url: https://en.wikisource.org/wiki/The_Prose_Edda_(1916_translation_by_Arthur_Gilchrist_Brodeur)
|
|
||||||
summary: >
|
|
||||||
Public-domain English translation of Snorri's Prose Edda used as a
|
|
||||||
general prose source for mythological episodes, names, kennings, and
|
|
||||||
figure-specific referents.
|
|
||||||
1
data/daodejing/corpus.cc
Normal file
1
data/daodejing/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:daodejing:1±weksa.corpus_item´2026-11-07T02:09:00ZÅ4›°item:daodejing:1Ù’Therefore the sage, in the exercise of his government, empties their minds, fills their bellies, weakens their wills, and strengthens their bones.¥en-US¬entity:laozi³daodejing-legge-ch3<EFBFBD><EFBFBD>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ<C380>”°item:daodejing:2±weksa.corpus_item´2026-11-07T02:09:00ZÅÓ›°item:daodejing:2Ù1Heaven is long-enduring and earth continues long.¥en-US¬entity:laozi³daodejing-legge-ch7<68><37>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ<C380>”°item:daodejing:3±weksa.corpus_item´2026-11-07T02:09:00ZÅÑ›°item:daodejing:3Ù/The highest excellence is like (that of) water.¥en-US¬entity:laozi³daodejing-legge-ch8<68><38>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ<C380>”°item:daodejing:4±weksa.corpus_item´2026-11-07T02:09:00ZÅ×›°item:daodejing:4Ù4Words that are strictly true seem to be paradoxical.¥en-US¬entity:laozi´daodejing-legge-ch78<37><38>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ<C380>”°item:daodejing:5±weksa.corpus_item´2026-11-07T02:09:00ZÅæ›°item:daodejing:5ÙDMay not the space between heaven and earth be compared to a bellows?¥en-US¬entity:laozi³daodejing-legge-ch5<68><35>¬PublicDomain—ÙBThe Tao Teh King, or the Tao and its Characteristics (Dao De Jing)¥LaoziÙ$https://www.gutenberg.org/ebooks/216Ù<36>Public domain (author d. c. 5th century BCE; translator d. 1897); Project Gutenberg eBook 216 text used without its trademark or licence matterÙ7United States and life+70 jurisdictions (Legge d. 1897)«James LeggeÍcª2026-11-07ÀÂ<C380>
|
||||||
1
data/daodejing/entity.cc
Normal file
1
data/daodejing/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:laozi¬weksa.entity´2026-11-07T02:09:00ZÄb“¬entity:laozi‘’¥en-US‘¥Laozi<EFBFBD>¬PublicDomain‘Ù4The Tao Teh King, or the Tao and its Characteristics
|
||||||
BIN
data/daodejing/inventory.cc
Normal file
BIN
data/daodejing/inventory.cc
Normal file
Binary file not shown.
1
data/daodejing/packets.cc
Normal file
1
data/daodejing/packets.cc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
“²cultcache.store.v1‘—¸weksa.interlingua_packet¸weksa.interlingua_packet»weksa.interlingua_packet.v1¸weksa.interlingua_packetÙd{"schemaName":"weksa.interlingua_packet","schemaVersion":"weksa.interlingua_packet.v1","members":[]}‘¸weksa.interlingua_packet<EFBFBD>•”·packet:64616f2d30333032¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅSž·packet:64616f2d30333032CorpusExample”¨Authored°item:daodejing:1¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>••¢n1¯referent_kind:1©concept:7À<EFBFBD>•¢n2°referent_kind:11©concept:8À<EFBFBD>•¢n3¯referent_kind:3©concept:9À<EFBFBD>•¢n4°referent_kind:11ªconcept:10À<EFBFBD>•¢n5¯referent_kind:3ªconcept:11À<EFBFBD>”•¢n6ªconcept:12§frame:4’’¦role:1¢n1’¦role:2¢n2<EFBFBD>•¢n7ªconcept:13§frame:4’’¦role:1¢n1’¦role:2¢n3<EFBFBD>•¢n8ªconcept:14§frame:4’’¦role:1¢n1’¦role:2¢n4<EFBFBD>•¢n9ªconcept:15§frame:4’’¦role:1¢n1’¦role:2¢n5<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙXFour acts of one agent; 'in the exercise of his government' and 'their' are not carried.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ ”·packet:64616f2d30353032¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅ^ž·packet:64616f2d30353032CorpusExample”¨Authored°item:daodejing:5¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:2ÀÀ<EFBFBD>’•¢n1¯referent_kind:4ªconcept:24À<EFBFBD>•¢n2¯referent_kind:5ªconcept:25À<EFBFBD>‘•¢n3ªconcept:26§frame:8’’¦role:3¢n1’¦role:6¢n2<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙRA rhetorical question; the speech act is ask and the negated 'may not' is dropped.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ ”·packet:64616f2d30373031¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅyž·packet:64616f2d30373031CorpusExample”¨Authored°item:daodejing:2¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>’•¢n1¯referent_kind:4ªconcept:16À<EFBFBD>•¢n2¯referent_kind:4ªconcept:17À<EFBFBD>’•¢n3ªconcept:18§frame:5‘’¦role:3¢n1<EFBFBD>•¢n4ªconcept:19§frame:5‘’¦role:3¢n2<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙTHeaven and earth as places; whether they are entities is a ruling the ontology owes.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ ”·packet:64616f2d30383031¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅwž·packet:64616f2d30383031CorpusExample”¨Authored°item:daodejing:3¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>’•¢n1°referent_kind:11ªconcept:20À<EFBFBD>•¢n2°referent_kind:11ªconcept:21À<EFBFBD>‘•¢n3ªconcept:27§frame:6’’¦role:3¢n1’¦role:6¢n2<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙSThe metaphor is a plain likeness predication; the tenor and vehicle are not marked.<EFBFBD>‘’’ªfeature:18<EFBFBD>¥GradeÍ ’ªfeature:16<EFBFBD>¥GradeÍ,”·packet:64616f2d37383034¸weksa.interlingua_packet´2026-11-09T10:16:16ZÅNž·packet:64616f2d37383034CorpusExample”¨Authored°item:daodejing:4¸encoder:636c617564652d73ÍX”ÀÀ<EFBFBD><EFBFBD>”¬speech_act:1ÀÀ<EFBFBD>‘•¢n1°referent_kind:11ªconcept:22À<EFBFBD>‘•¢n2ªconcept:23§frame:7‘’¦role:3¢n1<EFBFBD><EFBFBD>“<EFBFBD>ÂÀ<EFBFBD>’‘ÙmThe whole paradox is in the referent's concept and the frame; the packet holds no more than the surface does.<EFBFBD>‘‘’ªfeature:18<EFBFBD>¥GradeÍ
|
||||||
1
data/daodejing/speaker-state.cc
Normal file
1
data/daodejing/speaker-state.cc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<EFBFBD>
|
||||||
|
|
@ -1 +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
|
“²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-11-07T12:03:37ZÄâ“«entity:nibu‘“¥en-US‘¤Nibu•¦Ëˆnibu¤Nibuª2026-11-07ÙZweksa-body:follow_up:chat-s3981-3983 (#minds, tense vowel, two syllables, stress on first)‘§Ëˆnɪbu<EFBFBD>¤Cast’¤nibuÙ<examples/english-lowering/nibu-proof-001/lowered-output.yaml
|
||||||
Binary file not shown.
BIN
data/ontology/ontology.cc
Normal file
BIN
data/ontology/ontology.cc
Normal file
Binary file not shown.
BIN
data/original/corpus.cc
Normal file
BIN
data/original/corpus.cc
Normal file
Binary file not shown.
BIN
data/original/entity.cc
Normal file
BIN
data/original/entity.cc
Normal file
Binary file not shown.
1
data/original/inventory.cc
Normal file
1
data/original/inventory.cc
Normal file
File diff suppressed because one or more lines are too long
BIN
data/original/speaker-state.cc
Normal file
BIN
data/original/speaker-state.cc
Normal file
Binary file not shown.
50
data/phonology/en-US.phones.tsv
Normal file
50
data/phonology/en-US.phones.tsv
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Weksa phone inventory, en-US (General American, broad). One phone per line, tab separated:
|
||||||
|
# ipa <TAB> class <TAB> tenseness <TAB> gloss
|
||||||
|
# class: consonant | vowel. tenseness: tense | lax | reduced for a vowel, `-` for a consonant. A multi-character
|
||||||
|
# phone (affricate, diphthong) is one entry and one phone. The segmenter reads only this file: an IPA symbol with no
|
||||||
|
# line here is refused, never guessed. Tense/lax is the textbook classification; it is what separates /i/ (nee) from
|
||||||
|
# /ɪ/ (nih).
|
||||||
|
# Provenance of the i/ɪ rows: /i/ is tense and /ɪ/ is lax. Nibu's own ruling of 2026-11-07 (source
|
||||||
|
# weksa-body:follow_up:chat-s3981-3983, #minds: tense vowel, two syllables, stress on the first) chose ˈnibu and rejected
|
||||||
|
# ˈnɪbu; the entity entry records it, this file is what makes the two forms two different phones.
|
||||||
|
# Stress is not a phone: ˈ and ˌ are read by the segmenter and put on the vowel that follows.
|
||||||
|
p consonant - voiceless bilabial stop
|
||||||
|
b consonant - voiced bilabial stop
|
||||||
|
t consonant - voiceless alveolar stop
|
||||||
|
d consonant - voiced alveolar stop
|
||||||
|
k consonant - voiceless velar stop
|
||||||
|
g consonant - voiced velar stop
|
||||||
|
f consonant - voiceless labiodental fricative
|
||||||
|
v consonant - voiced labiodental fricative
|
||||||
|
θ consonant - voiceless dental fricative
|
||||||
|
ð consonant - voiced dental fricative
|
||||||
|
s consonant - voiceless alveolar fricative
|
||||||
|
z consonant - voiced alveolar fricative
|
||||||
|
ʃ consonant - voiceless postalveolar fricative
|
||||||
|
ʒ consonant - voiced postalveolar fricative
|
||||||
|
h consonant - voiceless glottal fricative
|
||||||
|
tʃ consonant - voiceless postalveolar affricate
|
||||||
|
dʒ consonant - voiced postalveolar affricate
|
||||||
|
m consonant - bilabial nasal
|
||||||
|
n consonant - alveolar nasal
|
||||||
|
ŋ consonant - velar nasal
|
||||||
|
l consonant - alveolar lateral approximant
|
||||||
|
ɹ consonant - alveolar approximant
|
||||||
|
j consonant - palatal approximant
|
||||||
|
w consonant - labial-velar approximant
|
||||||
|
i vowel tense close front unrounded (fleece, nee)
|
||||||
|
ɪ vowel lax near-close front unrounded (kit, nih)
|
||||||
|
ɛ vowel lax open-mid front unrounded (dress)
|
||||||
|
æ vowel lax near-open front unrounded (trap)
|
||||||
|
ɑ vowel tense open back unrounded (palm)
|
||||||
|
ɔ vowel tense open-mid back rounded (thought)
|
||||||
|
ʊ vowel lax near-close back rounded (foot)
|
||||||
|
u vowel tense close back rounded (goose, boo)
|
||||||
|
ʌ vowel lax open-mid back unrounded (strut)
|
||||||
|
ə vowel reduced schwa
|
||||||
|
ɚ vowel reduced r-coloured schwa
|
||||||
|
eɪ vowel tense face diphthong
|
||||||
|
oʊ vowel tense goat diphthong
|
||||||
|
aɪ vowel tense price diphthong
|
||||||
|
aʊ vowel tense mouth diphthong
|
||||||
|
ɔɪ vowel tense choice diphthong
|
||||||
|
Can't render this file because it has a wrong number of fields in line 11.
|
1
data/phonology/fixtures/nibu-en-US.phones.json
Normal file
1
data/phonology/fixtures/nibu-en-US.phones.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"language":"en-US","source":"ˈnibu","phones":[{"ipa":"n","stress":"none"},{"ipa":"i","stress":"primary"},{"ipa":"b","stress":"none"},{"ipa":"u","stress":"none"}]}
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# Target-Language Cultural Ontology Profiles
|
|
||||||
|
|
||||||
These profiles describe culturally loaded realization choices for human target
|
|
||||||
languages used by Weksa utterance lowering.
|
|
||||||
|
|
||||||
They are not whole-culture summaries. They are bounded lowering profiles: the
|
|
||||||
distinctions a target lowerer must inspect before producing dialogue.
|
|
||||||
|
|
||||||
## Authority
|
|
||||||
|
|
||||||
Target-language cultural ontology profiles own:
|
|
||||||
|
|
||||||
- address and politeness distinctions
|
|
||||||
- register and social stance pressures
|
|
||||||
- agency, responsibility, and directness defaults
|
|
||||||
- idiom and metaphor risks
|
|
||||||
- script or orthographic choices that carry meaning
|
|
||||||
- forbidden flattenings that would make the output dishonest
|
|
||||||
|
|
||||||
They do not own:
|
|
||||||
|
|
||||||
- source meaning
|
|
||||||
- speaker-private state
|
|
||||||
- target-language grammar as a whole
|
|
||||||
- national or cultural stereotypes
|
|
||||||
- final wording without an interlingua packet
|
|
||||||
|
|
||||||
## Current Profiles
|
|
||||||
|
|
||||||
- [English, US fixture default](./en-US.yaml)
|
|
||||||
- [Brazilian Portuguese](./pt-BR.yaml)
|
|
||||||
- [Brazilian Portuguese overlay catalog](./pt-BR-overlays.yaml)
|
|
||||||
- [Brazilian Portuguese, Umbanda overlay](./pt-BR-umbanda.yaml)
|
|
||||||
- [Japanese](./ja-JP.yaml)
|
|
||||||
|
|
||||||
Each multilingual lowering fixture should cite the profile or profile stack it
|
|
||||||
used so reviewers can tell which social and cultural distinctions were
|
|
||||||
considered. Regional and religious-cultural options are overlays on a base
|
|
||||||
language profile, not replacements for source meaning.
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
profile_id: weksa.target_cultural_ontology.en-US.v0
|
|
||||||
language:
|
|
||||||
code: en
|
|
||||||
locale: en-US
|
|
||||||
script: Latn
|
|
||||||
scope: >
|
|
||||||
Bounded English dialogue lowering profile for current Weksa fixtures. This is
|
|
||||||
not a claim about all English-speaking cultures.
|
|
||||||
authority:
|
|
||||||
owns:
|
|
||||||
- address directness choices
|
|
||||||
- casual versus formal register
|
|
||||||
- technical metaphor legibility
|
|
||||||
- understatement or sarcasm risk
|
|
||||||
does_not_own:
|
|
||||||
- source meaning
|
|
||||||
- speaker identity
|
|
||||||
- whole-culture description
|
|
||||||
salience_axes:
|
|
||||||
address:
|
|
||||||
relevant_distinctions:
|
|
||||||
- direct second person is acceptable when scene context names the addressee
|
|
||||||
- imperative force can be softened or sharpened through wording
|
|
||||||
lowering_questions:
|
|
||||||
- Does direct address preserve the speech act?
|
|
||||||
- Does softening erase urgency?
|
|
||||||
register:
|
|
||||||
relevant_distinctions:
|
|
||||||
- casual
|
|
||||||
- technical
|
|
||||||
- formal
|
|
||||||
- intimate
|
|
||||||
- institutional
|
|
||||||
lowering_questions:
|
|
||||||
- Is technical language character pressure or exposition sludge?
|
|
||||||
- Does sarcasm obscure the actual warning?
|
|
||||||
agency:
|
|
||||||
relevant_distinctions:
|
|
||||||
- explicit agent responsibility is usually available
|
|
||||||
- passive phrasing can hide authority or blame
|
|
||||||
lowering_questions:
|
|
||||||
- Should the speaker own the intervention directly?
|
|
||||||
- Does the line preserve who is at risk?
|
|
||||||
linguistic_affordances:
|
|
||||||
grammar_features:
|
|
||||||
- feature_id: en_us_direct_imperative_warning
|
|
||||||
label: Direct imperative warning
|
|
||||||
status: draft
|
|
||||||
domain:
|
|
||||||
layer: grammar
|
|
||||||
category: clause_mood
|
|
||||||
subcategory: imperative
|
|
||||||
description: >
|
|
||||||
Broad en-US allows direct imperatives for urgent safety warnings when
|
|
||||||
the speaker has standing to intervene and the scene rewards clarity
|
|
||||||
over mitigation.
|
|
||||||
formal_conditions:
|
|
||||||
syntactic_environment:
|
|
||||||
- main_clause
|
|
||||||
- direct_address_optional
|
|
||||||
semantic_inputs:
|
|
||||||
- warning
|
|
||||||
- addressee_action_to_stop_or_avoid
|
|
||||||
- consequence_or_risk_available
|
|
||||||
required_interlingua_fields:
|
|
||||||
- speech_act
|
|
||||||
- addressee
|
|
||||||
- prohibited_action
|
|
||||||
realization:
|
|
||||||
function: stop_or_prevent_action
|
|
||||||
surface_strategy: bare_imperative_or_do_not_imperative
|
|
||||||
examples:
|
|
||||||
- "Don't touch the damaged control."
|
|
||||||
activation:
|
|
||||||
allowed_when:
|
|
||||||
- scene_has_immediate_risk
|
|
||||||
- speaker_has_contextual_authority_or_care_relation
|
|
||||||
forbidden_when:
|
|
||||||
- politeness_softening_is_required_by_target_profile
|
|
||||||
- imperative_would_falsely_imply_hierarchy
|
|
||||||
requires_profile_authority: false
|
|
||||||
minimum_density: light
|
|
||||||
maximum_density: normal
|
|
||||||
constraints:
|
|
||||||
grammar_contexts:
|
|
||||||
- avoid_if_source_requires_indirect_request
|
|
||||||
register_contexts:
|
|
||||||
- casual
|
|
||||||
- technical
|
|
||||||
- emergency
|
|
||||||
speaker_contexts:
|
|
||||||
- known_addressee
|
|
||||||
- operator_warning
|
|
||||||
incompatibilities:
|
|
||||||
- generic_assistant_over_politeness
|
|
||||||
indexicality:
|
|
||||||
social_meanings:
|
|
||||||
- urgency
|
|
||||||
- clarity
|
|
||||||
- responsibility
|
|
||||||
in_group_meanings: []
|
|
||||||
out_group_risks:
|
|
||||||
- may sound abrupt if the scene is not urgent
|
|
||||||
stereotypes_to_avoid: []
|
|
||||||
evidence:
|
|
||||||
source_refs:
|
|
||||||
- schemas/cultural-ontology-profile.md
|
|
||||||
qdrant_source_ids: []
|
|
||||||
cached_source_refs: []
|
|
||||||
source_hashes: []
|
|
||||||
reviewer_refs: []
|
|
||||||
confidence: medium
|
|
||||||
uncertainty:
|
|
||||||
open_questions:
|
|
||||||
- Should a later en-US regional overlay override imperative force?
|
|
||||||
known_limits:
|
|
||||||
- Base en-US does not model regional, racialized, or contact-language
|
|
||||||
dialect grammar.
|
|
||||||
trace:
|
|
||||||
trace_label: direct_imperative_warning
|
|
||||||
must_report_when_used: true
|
|
||||||
must_report_when_rejected: true
|
|
||||||
phonology_features: []
|
|
||||||
lexicon_features: []
|
|
||||||
discourse_patterns: []
|
|
||||||
orthography_features: []
|
|
||||||
code_switching:
|
|
||||||
allowed: false
|
|
||||||
constraints:
|
|
||||||
- Base en-US profile does not authorize contact-language switching.
|
|
||||||
patterns: []
|
|
||||||
forbidden_flattenings:
|
|
||||||
- Do not treat English as the neutral source for other target languages.
|
|
||||||
- Do not use generic assistant politeness when a character voice is required.
|
|
||||||
- Do not let sarcasm replace the actual predicate being lowered.
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
profile_id: weksa.target_cultural_ontology.ja-JP.v0
|
|
||||||
language:
|
|
||||||
code: ja
|
|
||||||
locale: ja-JP
|
|
||||||
script: Jpan
|
|
||||||
scope: >
|
|
||||||
Bounded Japanese dialogue lowering profile for Weksa fixtures. This profile
|
|
||||||
records realization distinctions that must be inspected; it is not a total
|
|
||||||
description of Japanese culture.
|
|
||||||
authority:
|
|
||||||
owns:
|
|
||||||
- politeness and honorific pressure
|
|
||||||
- in-group/out-group stance when context supplies it
|
|
||||||
- subject and object omission choices
|
|
||||||
- script mix and technical register
|
|
||||||
- sentence-final force
|
|
||||||
does_not_own:
|
|
||||||
- source meaning
|
|
||||||
- character identity
|
|
||||||
- exhaustive Japanese grammar
|
|
||||||
salience_axes:
|
|
||||||
politeness:
|
|
||||||
relevant_distinctions:
|
|
||||||
- plain
|
|
||||||
- polite
|
|
||||||
- honorific
|
|
||||||
- humble
|
|
||||||
- rough
|
|
||||||
lowering_questions:
|
|
||||||
- Does politeness preserve the relationship, or does it lie about it?
|
|
||||||
- Is roughness character-specific or generic stylization?
|
|
||||||
social_position:
|
|
||||||
relevant_distinctions:
|
|
||||||
- uchi_soto_pressure
|
|
||||||
- hierarchy
|
|
||||||
- familiarity
|
|
||||||
- emergency_override
|
|
||||||
lowering_questions:
|
|
||||||
- Does urgency override ordinary deference?
|
|
||||||
- Does the line imply a relationship the scene has not earned?
|
|
||||||
ellipsis:
|
|
||||||
relevant_distinctions:
|
|
||||||
- subject omission
|
|
||||||
- object omission
|
|
||||||
- explicit pronoun for emphasis or contrast
|
|
||||||
lowering_questions:
|
|
||||||
- Is the addressee recoverable without an explicit pronoun?
|
|
||||||
- Would explicit pronouns sound unnatural or too pointed?
|
|
||||||
script_and_register:
|
|
||||||
relevant_distinctions:
|
|
||||||
- kanji for technical compounds
|
|
||||||
- kana for softness or readability
|
|
||||||
- katakana for foreignness, machine texture, or emphasis
|
|
||||||
lowering_questions:
|
|
||||||
- Does the script choice help the speaker voice?
|
|
||||||
- Does a technical compound preserve precision without becoming stiff?
|
|
||||||
sentence_final_force:
|
|
||||||
relevant_distinctions:
|
|
||||||
- direct request
|
|
||||||
- warning consequence
|
|
||||||
- command
|
|
||||||
- threat-like phrasing
|
|
||||||
lowering_questions:
|
|
||||||
- Does the ending carry warning force without cartoon aggression?
|
|
||||||
- Does the line preserve the hazard predicate?
|
|
||||||
forbidden_flattenings:
|
|
||||||
- Do not treat politeness as decoration.
|
|
||||||
- Do not use explicit pronouns just because English has them.
|
|
||||||
- Do not make every sharp line rough or anime-coded.
|
|
||||||
- Do not erase the difference between technical precision and formal politeness.
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
catalog_id: weksa.target_cultural_ontology.pt-BR.overlays.v0
|
|
||||||
base_profile: data/target-language-ontology/pt-BR.yaml
|
|
||||||
scope: >
|
|
||||||
Catalog of optional Brazilian Portuguese regional and cultural overlay slots.
|
|
||||||
An overlay slot is not permission to imitate a community. It is a named place
|
|
||||||
where reviewed, source-grounded distinctions can live.
|
|
||||||
activation_rule: >
|
|
||||||
Use the broad pt-BR profile by default. Add an overlay only when source
|
|
||||||
context, speaker state, setting documentation, or authoring metadata names the
|
|
||||||
relevant region, community, institution, religion, or register.
|
|
||||||
overlay_slots:
|
|
||||||
- overlay_id: broad_brazilian_default
|
|
||||||
status: active_base_mode
|
|
||||||
profile_ref: data/target-language-ontology/pt-BR.yaml
|
|
||||||
owns:
|
|
||||||
- broadly legible Brazilian Portuguese dialogue
|
|
||||||
does_not_own:
|
|
||||||
- regional specificity
|
|
||||||
- religious-cultural specificity
|
|
||||||
- overlay_id: rio_urban
|
|
||||||
status: planned_profile
|
|
||||||
profile_ref:
|
|
||||||
owns_when_defined:
|
|
||||||
- Rio de Janeiro urban speech/register pressures for specific characters or settings
|
|
||||||
blocked_until:
|
|
||||||
- reviewed source context
|
|
||||||
- human-approved examples
|
|
||||||
- overlay_id: sao_paulo_urban
|
|
||||||
status: planned_profile
|
|
||||||
profile_ref:
|
|
||||||
owns_when_defined:
|
|
||||||
- Sao Paulo urban speech/register pressures for specific characters or settings
|
|
||||||
blocked_until:
|
|
||||||
- reviewed source context
|
|
||||||
- human-approved examples
|
|
||||||
- overlay_id: northeast_regional
|
|
||||||
status: planned_profile
|
|
||||||
profile_ref:
|
|
||||||
owns_when_defined:
|
|
||||||
- Northeast Brazilian regional speech/register pressures for specific characters or settings
|
|
||||||
blocked_until:
|
|
||||||
- specific region named
|
|
||||||
- reviewed examples
|
|
||||||
- human approval
|
|
||||||
- overlay_id: south_regional
|
|
||||||
status: planned_profile
|
|
||||||
profile_ref:
|
|
||||||
owns_when_defined:
|
|
||||||
- South Brazilian regional speech/register pressures for specific characters or settings
|
|
||||||
blocked_until:
|
|
||||||
- specific region named
|
|
||||||
- reviewed examples
|
|
||||||
- human approval
|
|
||||||
- overlay_id: amazonian_regional
|
|
||||||
status: planned_profile
|
|
||||||
profile_ref:
|
|
||||||
owns_when_defined:
|
|
||||||
- Amazonian regional speech/register pressures for specific characters or settings
|
|
||||||
blocked_until:
|
|
||||||
- specific region or community named
|
|
||||||
- reviewed examples
|
|
||||||
- human approval
|
|
||||||
- overlay_id: umbanda
|
|
||||||
status: active_guarded_overlay
|
|
||||||
profile_ref: data/target-language-ontology/pt-BR-umbanda.yaml
|
|
||||||
owns:
|
|
||||||
- Umbanda-related register gating
|
|
||||||
- sacred address caution
|
|
||||||
- anti-caricature constraints
|
|
||||||
blocked_until:
|
|
||||||
- source context or authoring metadata explicitly activates Umbanda context
|
|
||||||
forbidden_flattenings:
|
|
||||||
- Do not use a planned overlay as if it were defined.
|
|
||||||
- Do not treat regional slots as accent generators.
|
|
||||||
- Do not use religious-cultural overlays for ambience.
|
|
||||||
- Do not collapse race, region, religion, class, and slang into one Brazilian flavor.
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
profile_id: weksa.target_cultural_ontology.pt-BR.umbanda.v0
|
|
||||||
language:
|
|
||||||
code: pt
|
|
||||||
locale: pt-BR
|
|
||||||
script: Latn
|
|
||||||
overlay:
|
|
||||||
base_profile: data/target-language-ontology/pt-BR.yaml
|
|
||||||
kind: religious_cultural_register
|
|
||||||
scope: >
|
|
||||||
Optional Brazilian Portuguese cultural ontology overlay for Umbanda-related
|
|
||||||
scenes, speakers, ritual contexts, or referents. This profile is a consent and
|
|
||||||
context gate, not a decorative vocabulary pack.
|
|
||||||
authority:
|
|
||||||
owns:
|
|
||||||
- whether Umbanda-specific register is allowed for a realization
|
|
||||||
- sacred or ritual address caution
|
|
||||||
- house/tradition specificity warnings
|
|
||||||
- syncretic and entity-reference sensitivity
|
|
||||||
- anti-caricature constraints
|
|
||||||
does_not_own:
|
|
||||||
- Umbanda doctrine as a whole
|
|
||||||
- authority to invent ritual speech
|
|
||||||
- generic Brazilian Portuguese
|
|
||||||
- speaker identity unless the speaker context explicitly supplies it
|
|
||||||
activation_requirements:
|
|
||||||
any_required:
|
|
||||||
- source context names an Umbanda scene, terreiro, practitioner, entity, ritual, or discourse community
|
|
||||||
- speaker profile explicitly owns an Umbanda-informed voice or memory
|
|
||||||
- authoring metadata requests this overlay for reviewed cultural grounding
|
|
||||||
never_activate_for:
|
|
||||||
- generic Brazilian flavor
|
|
||||||
- jokes, insults, mystic ambience, or unexplained exotic texture
|
|
||||||
- non-Umbanda religious scenes unless the source context makes a syncretic relation explicit
|
|
||||||
salience_axes:
|
|
||||||
sacred_address:
|
|
||||||
relevant_distinctions:
|
|
||||||
- ordinary dialogue
|
|
||||||
- respectful reference
|
|
||||||
- ritual address
|
|
||||||
- entity_or_guide_reference
|
|
||||||
lowering_questions:
|
|
||||||
- Is the line ordinary speech or ritualized speech?
|
|
||||||
- Does the speaker have standing to use sacred address?
|
|
||||||
- Should a term be left general until a human reviewer approves specificity?
|
|
||||||
house_specificity:
|
|
||||||
relevant_distinctions:
|
|
||||||
- practices and terms can vary by house, region, and lineage
|
|
||||||
- generic flattening can become false authority
|
|
||||||
lowering_questions:
|
|
||||||
- Is the output pretending one practice represents all Umbanda?
|
|
||||||
- Should the trace mark the choice as house-specific or uncertain?
|
|
||||||
syncretic_reference:
|
|
||||||
relevant_distinctions:
|
|
||||||
- Catholic, African, Indigenous, Spiritist, and local references may coexist differently by context
|
|
||||||
- entity names and roles are not interchangeable decoration
|
|
||||||
lowering_questions:
|
|
||||||
- Does the source actually contain the syncretic relation?
|
|
||||||
- Does the line preserve respect without overexplaining?
|
|
||||||
anti_caricature:
|
|
||||||
relevant_distinctions:
|
|
||||||
- religious register
|
|
||||||
- folkloric stereotype
|
|
||||||
- occult ambience
|
|
||||||
lowering_questions:
|
|
||||||
- Is the output using Umbanda as culture, faith, and practice rather than spooky texture?
|
|
||||||
- Would a practitioner recognize this as respectful enough for review?
|
|
||||||
forbidden_flattenings:
|
|
||||||
- Do not use Umbanda terms as generic magic flavor.
|
|
||||||
- Do not invent ritual formulas.
|
|
||||||
- Do not collapse Umbanda into Candomblé, Spiritism, Catholicism, or generic Afro-Brazilian religion.
|
|
||||||
- Do not imply all Brazilian Portuguese religious language is Umbanda-coded.
|
|
||||||
- Do not activate this overlay unless source context or authoring metadata explicitly asks for it.
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
profile_id: weksa.target_cultural_ontology.pt-BR.v0
|
|
||||||
language:
|
|
||||||
code: pt
|
|
||||||
locale: pt-BR
|
|
||||||
script: Latn
|
|
||||||
scope: >
|
|
||||||
Bounded Brazilian Portuguese dialogue lowering profile. This profile is the
|
|
||||||
base Brazilian Portuguese target; regional, class, religious, and community
|
|
||||||
registers should be added as explicit overlays rather than silently folded
|
|
||||||
into generic pt-BR.
|
|
||||||
authority:
|
|
||||||
owns:
|
|
||||||
- Brazilian Portuguese lexical preference
|
|
||||||
- treatment and address choices
|
|
||||||
- directness versus mitigation
|
|
||||||
- regional_or_cultural_overlay_selection
|
|
||||||
- idiom naturalness for reviewed fixture prose
|
|
||||||
does_not_own:
|
|
||||||
- source meaning
|
|
||||||
- Brazilian culture as a whole
|
|
||||||
- regional or religious-community authority without an explicit overlay
|
|
||||||
- target speaker identity
|
|
||||||
salience_axes:
|
|
||||||
treatment:
|
|
||||||
relevant_distinctions:
|
|
||||||
- voce
|
|
||||||
- tu_regionally_marked
|
|
||||||
- o_senhor_a_senhora
|
|
||||||
- name_or_title_address
|
|
||||||
lowering_questions:
|
|
||||||
- Is the relationship close, hostile, hierarchical, unknown, or sacred?
|
|
||||||
- Does `voce` preserve broad Brazilian naturalness better than `tu`?
|
|
||||||
- Is `tu` regionally grounded or just imported flavor?
|
|
||||||
directness:
|
|
||||||
relevant_distinctions:
|
|
||||||
- bare imperative
|
|
||||||
- softened imperative
|
|
||||||
- warning framed as consequence
|
|
||||||
- formal warning
|
|
||||||
lowering_questions:
|
|
||||||
- Does urgency justify blunt imperative force?
|
|
||||||
- Would mitigation make the warning dishonest?
|
|
||||||
lexical_locale:
|
|
||||||
relevant_distinctions:
|
|
||||||
- Brazilian Portuguese terms should be preferred in reviewed pt-BR fixtures
|
|
||||||
- European Portuguese defaults must be marked if intentionally used
|
|
||||||
lowering_questions:
|
|
||||||
- Is a term recognizably Brazilian Portuguese in this context?
|
|
||||||
- Is a literal technical word natural enough for dialogue?
|
|
||||||
regional_and_cultural_overlay:
|
|
||||||
relevant_distinctions:
|
|
||||||
- broad_brazilian_default
|
|
||||||
- regional_speech_pattern
|
|
||||||
- class_or_institutional_register
|
|
||||||
- religious_or_ritual_register
|
|
||||||
- community_specific_lexicon
|
|
||||||
lowering_questions:
|
|
||||||
- Does the source context justify an overlay?
|
|
||||||
- Is the overlay owned by a named profile?
|
|
||||||
- Would applying the overlay stereotype the speaker or setting?
|
|
||||||
metaphor:
|
|
||||||
relevant_distinctions:
|
|
||||||
- technical metaphors can preserve character ontology
|
|
||||||
- over-literal metaphors may sound translated rather than spoken
|
|
||||||
lowering_questions:
|
|
||||||
- Does the metaphor still sound like the speaker?
|
|
||||||
- Does it preserve the warning instead of becoming decorative?
|
|
||||||
forbidden_flattenings:
|
|
||||||
- Do not label output as Portuguese without a locale in reviewed fixtures.
|
|
||||||
- Do not default to European Portuguese wording when the target is pt-BR.
|
|
||||||
- Do not treat Brazilian Portuguese as one culture, region, class, or religion.
|
|
||||||
- Do not add regional or religious markers without a cited overlay profile and source-context reason.
|
|
||||||
- Do not soften a warning merely because directness feels impolite in English review.
|
|
||||||
overlay_catalog: data/target-language-ontology/pt-BR-overlays.yaml
|
|
||||||
120
docs/calibration.md
Normal file
120
docs/calibration.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Calibrating the accept rule
|
||||||
|
|
||||||
|
`weksa loop calibrate` measures how the live accept rule behaves on a run's own
|
||||||
|
Validation corpus: how often it accepts, rejects as no gain, or regresses a
|
||||||
|
candidate that should be none of those. It does not reimplement the rule. It
|
||||||
|
reads the answered draws through `RunStores::read_draws` (the scoring
|
||||||
|
`weksa loop relower` uses), decides every trial with `decide_panel` over
|
||||||
|
`paired_floor` (`crates/weksa/src/run.rs`), and prints, per class, the items
|
||||||
|
read and skipped per language, the trial totals with their Accepted, NoGain and
|
||||||
|
Regressed counts, and the mean panel noise. That is all it produces: the
|
||||||
|
Accepted/NoGain/Regressed counts and the noise mean in the map's "Recalibration
|
||||||
|
on the grown corpus" block are reproduced by it, and the prompted trials are
|
||||||
|
also printed split by the recorded lowering `R`'s role (`prompted-r-candidate`,
|
||||||
|
`prompted-r-base`, `prompted-r-free`, see below), the candidate counts per arm
|
||||||
|
and the mild arms' composite offsets from the prompted mean (see below); the
|
||||||
|
other figures there (the
|
||||||
|
35% with R removed, the noise range, the 0.0185 gain and its 0.41 ratio, the
|
||||||
|
bootstrap intervals, the per-item SDs, R's +0.024, the mild-arm duplicate
|
||||||
|
counts, the Train-vocabulary overlap, the old/new
|
||||||
|
class composites and the ten parse-refused item ids) were computed by scratch scripts that are not in the repo. It is a
|
||||||
|
diagnostic: it records nothing, writes no prompts, and never touches Test or
|
||||||
|
Train.
|
||||||
|
|
||||||
|
## What it counts
|
||||||
|
|
||||||
|
The draws are lowerings of one round's Validation packets: the round's own
|
||||||
|
(recorded) lowering `R`, plus draws you label. `P` is `R` and the `--prompted`
|
||||||
|
draws; the floor size is `FLOOR_ARMS_MIN` (3), the live rule's.
|
||||||
|
|
||||||
|
- Prompted trials: every ordered pair (base, candidate) in `P`, against every
|
||||||
|
3-subset of the rest of `P` as the floor. A repeat of the same lowering
|
||||||
|
should read NoGain; Accepted is a false accept, Regressed a false reject.
|
||||||
|
(`R` comes from the round, not from `relower`, so it may not be
|
||||||
|
exchangeable with a repeat.) The prompted line is therefore printed three
|
||||||
|
more times, by `R`'s role in the trial: `prompted-r-candidate` (`R` is the
|
||||||
|
candidate), `prompted-r-base` (`R` is the base) and `prompted-r-free` (`R` is
|
||||||
|
neither; it may sit in the floor). The three sum to the prompted line. Read the Accepted
|
||||||
|
and Regressed columns of each line: a false accept sits mostly under the
|
||||||
|
first and a false reject under the second, but `R`-as-candidate trials can
|
||||||
|
Regress too (class `old` on `growth-scene`: 5 of its 15). The third
|
||||||
|
is the exchangeable rate: on `growth-scene` it was 0 Accepted and 0 Regressed
|
||||||
|
of 80 while all 11 false accepts and 9 false rejects involved `R`. Which of
|
||||||
|
the two rates the live rule should be held to is undecided: the live loop
|
||||||
|
meets `R` as the head's establishing lowering.
|
||||||
|
- Degraded trials, per `--degraded` arm: every base in `P` against every
|
||||||
|
3-subset of the rest of `P`, the degraded draw as the candidate. These should
|
||||||
|
read Regressed.
|
||||||
|
- Candidate trials, pooled over the `--candidate` arms: the same, tallied
|
||||||
|
Accepted, NoGain, Regressed. This is the rule's power against that arm. The
|
||||||
|
pooled line is followed by one `candidate=LABEL` line per arm: arms of one
|
||||||
|
kind can differ completely (Train-guide h1 and h2 Regressed in every trial
|
||||||
|
while h3 and h4 gained), and then the pooled figure is not a power.
|
||||||
|
- Mild offset: each `degraded=LABEL` line ends with `offset LANGUAGE=+x.xxxx`, the
|
||||||
|
arm's composite minus the mean composite of the prompted draws (`R` included),
|
||||||
|
per language over the items read. A degraded arm that is barely below the
|
||||||
|
prompted mean is a weak probe of the rule's sensitivity.
|
||||||
|
- Noise: the mean `panel_noise` over every (base, floor) choice.
|
||||||
|
|
||||||
|
With six prompted draws including `R` that is 120 prompted trials, 60 per
|
||||||
|
degraded arm and 240 candidate trials. Trials share draws and are not
|
||||||
|
independent. Each count is printed for `all` Validation items and for each
|
||||||
|
`--class NAME=PREFIX[,PREFIX...]` (items whose key starts with any prefix), from
|
||||||
|
the same per-item scores. A class with fewer than two read items in a language
|
||||||
|
has no floor and reads Regressed in every trial; that is printed.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. `weksa loop open ROOT ...` and `weksa loop round ROOT` record round N and
|
||||||
|
leave its answers in `ROOT/round-N/`.
|
||||||
|
2. For each draw label: `weksa loop relower ROOT --round N --arm LABEL
|
||||||
|
--lowerer NAME` writes the byte-identical lowering prompts to
|
||||||
|
`ROOT/relower-N-LABEL/<digest>.prompt` and exits 3. Answer each prompt into
|
||||||
|
`ROOT/relower-N-LABEL/<digest>.answer`.
|
||||||
|
3. `weksa loop calibrate ROOT --round N --prompted p1 --prompted p2 ...
|
||||||
|
[--degraded LABEL]... [--candidate LABEL]... [--class NAME=PREFIX]...`.
|
||||||
|
At least four `--prompted` labels are needed (a pair and three floors with
|
||||||
|
`R` make five draws). It exits 1 and lists `unanswered LABEL COUNT` for any
|
||||||
|
draw with prompts still unanswered. Labels match `[a-z0-9-]{1,32}` and differ
|
||||||
|
across the three kinds. It needs the Ollama embedder of the run; a full
|
||||||
|
60-item, 13-draw calibration is on the order of 1500 embedded texts, so
|
||||||
|
launch it detached with a log.
|
||||||
|
|
||||||
|
The scratch packet store is `ROOT/calibrate-N/base.cc`; delete it at will.
|
||||||
|
|
||||||
|
## Answer file format
|
||||||
|
|
||||||
|
`<digest>.answer` is one JSON object and nothing else, no code fence:
|
||||||
|
`{"text": "..."}`, a JSON-escaped string. The answerer reads only the prompt
|
||||||
|
(its first line says what to write) and writes the answer. Every prompt of the
|
||||||
|
draw must be answered.
|
||||||
|
|
||||||
|
## Briefs per arm
|
||||||
|
|
||||||
|
Independence rule: a fresh agent per batch of answers; no batch sees another
|
||||||
|
draw's answers. Only `mild` reads `round-N/*.answer`.
|
||||||
|
|
||||||
|
- Plain prompted: answer each prompt as its own first line asks; read nothing
|
||||||
|
else in the run root.
|
||||||
|
- Mild: for each prompt, read the recorded lowering in
|
||||||
|
`ROOT/round-N/<digest>.answer` and write the same-format answer whose text is
|
||||||
|
that utterance with exactly one meaning detail blurred: change or drop one of
|
||||||
|
tense, polarity, modality or hedge, a referent, a quantity or a predicate's
|
||||||
|
concept (replace it with a near but different word). Keep language, register,
|
||||||
|
length and fluency; the result must read as a natural utterance and must not
|
||||||
|
mention the change. Pick the detail that is not the most salient one. A
|
||||||
|
second mild draw blurs a different detail than the first and is written
|
||||||
|
without reading the first's answers. The wording of the distil-base original
|
||||||
|
is lost, and the dispatch used for growth-base is not on disk, so this is the
|
||||||
|
wording from now on. Warning: on growth-base two mild arms briefed this way
|
||||||
|
came out identical in 27 of 60 items, so independently briefed mild arms
|
||||||
|
are not independent degradations; read a mild draw's regress count with that
|
||||||
|
in mind.
|
||||||
|
- Train-guide: before answering, read the Train sentences of the corpus in the
|
||||||
|
batch's language as a register guide and let it steer word choice; the guide
|
||||||
|
gives phrasing only and each answer must still say exactly what its own packet
|
||||||
|
says. The guide section is the live Train items of that language, built from
|
||||||
|
`weksa corpus dump` filtered to Train, and is given to the answerer in the
|
||||||
|
brief, never pasted into this document, and never includes Validation or Test
|
||||||
|
text. Read nothing else in the run root.
|
||||||
|
- Control: a script writes `{"text":"Yes."}` as the answer to every prompt.
|
||||||
|
|
@ -369,6 +369,12 @@ target. A Qdrant nearest neighbor is a witness candidate, not a cultural fact.
|
||||||
|
|
||||||
\section{Proposed CultCache State Shape}
|
\section{Proposed CultCache State Shape}
|
||||||
|
|
||||||
|
No profile store exists. The per-language lowering choices live in
|
||||||
|
\texttt{data/ontology/ontology.cc}; the typed profiles once stored under
|
||||||
|
\texttt{.weksa/cultural-ontology/} and the \texttt{weksa.target\_cultural\_ontology.*}
|
||||||
|
base profiles named in the listing and sketches below are deleted. Community and dialect
|
||||||
|
profiles belong to follow-up \texttt{language-ontology-communities}.
|
||||||
|
|
||||||
The following shape should become a typed CultCache document family:
|
The following shape should become a typed CultCache document family:
|
||||||
\texttt{weksa.cultural\_ontology\_profile.v0}. It can represent base languages,
|
\texttt{weksa.cultural\_ontology\_profile.v0}. It can represent base languages,
|
||||||
dialects, sociolects, regional overlays, ritual registers, and communities of
|
dialects, sociolects, regional overlays, ritual registers, and communities of
|
||||||
|
|
@ -612,15 +618,6 @@ feature requires speaker authority, whether it can be rendered in text or only
|
||||||
speech synthesis, which source hashes support it, and what must be reported if
|
speech synthesis, which source hashes support it, and what must be reported if
|
||||||
the feature is rejected.
|
the feature is rejected.
|
||||||
|
|
||||||
Recommended CultCache paths:
|
|
||||||
|
|
||||||
\begin{lstlisting}[basicstyle=\ttfamily\small]
|
|
||||||
.weksa/cultural-ontology/{profileId}.cc
|
|
||||||
.weksa/cultural-ontology/{baseProfileId}/overlays/{overlayId}.cc
|
|
||||||
.weksa/cultural-ontology/catalogs/{catalogId}.cc
|
|
||||||
.weksa/cultural-ontology/evidence-bundles/{bundleId}.cc
|
|
||||||
\end{lstlisting}
|
|
||||||
|
|
||||||
\section{Dialect Profile Sketches}
|
\section{Dialect Profile Sketches}
|
||||||
|
|
||||||
\subsection{AAVE/AAL}
|
\subsection{AAVE/AAL}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,17 @@ The first version may be hand-authored fixture data. The contract matters before
|
||||||
automation. Yes, this is paperwork with teeth. Annoying, but it bites the right
|
automation. Yes, this is paperwork with teeth. Annoying, but it bites the right
|
||||||
thing.
|
thing.
|
||||||
|
|
||||||
|
## Ruled Entity Pronunciations
|
||||||
|
|
||||||
|
An entity's citation form in a language may carry a ruled pronunciation
|
||||||
|
(`LanguageForms.pronunciation`): bare IPA with stress marks, who ruled it, the
|
||||||
|
date, the source, and an optional list of rejected variants kept so nobody
|
||||||
|
re-proposes them. `weksa pronounce <store> <key>` prints the stored IPA of each
|
||||||
|
entity the packet cites, one per line (`--phones` prints the phones line), and refuses with `NoPronunciation` for an
|
||||||
|
entity with none; rejected variants are never read. This is not the plan below:
|
||||||
|
it carries no timing, contour, length or invented prosody, and Weksa has no
|
||||||
|
English G2P, so an unruled name has no IPA.
|
||||||
|
|
||||||
## Pronunciation Plan Shape
|
## Pronunciation Plan Shape
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ Packet
|
||||||
id, schema_version, inventory_rev, kind
|
id, schema_version, inventory_rev, kind
|
||||||
provenance { source, corpus_item, encoder, confidence }
|
provenance { source, corpus_item, encoder, confidence }
|
||||||
context { setting, medium, participants, audience }
|
context { setting, medium, participants, audience }
|
||||||
discourse { speech_act, addressee, features }
|
discourse { speech_act, addressee, addressee_consent, features }
|
||||||
referents [ { id, kind, concept, entity, features } ]
|
referents [ { id, kind, concept, entity, features } ]
|
||||||
predications [ { id, concept, frame, roles: [ { role, filler } ], features } ]
|
predications [ { id, concept, frame, roles: [ { role, filler } ], features } ]
|
||||||
relations [ { relation, from, to, features } ]
|
relations [ { relation, from, to, features } ]
|
||||||
|
|
@ -144,8 +144,8 @@ names the rule.
|
||||||
- V1: `schema_version` is the version these types define.
|
- V1: `schema_version` is the version these types define.
|
||||||
- V2: at least one of `referents`, `predications`, `relations` is present.
|
- V2: at least one of `referents`, `predications`, `relations` is present.
|
||||||
- V3: a node id appears once across referents and predications.
|
- V3: a node id appears once across referents and predications.
|
||||||
- V4: every role filler, relation end, addressee, participant, audience member
|
- V4: every role filler, relation end, addressee, addressee consent,
|
||||||
and preserved node names a node.
|
participant, audience member and preserved node names a node.
|
||||||
- V5: every inventory id resolves at the packet's `inventory_rev`: introduced
|
- V5: every inventory id resolves at the packet's `inventory_rev`: introduced
|
||||||
at or before it, and not retired at or before it.
|
at or before it, and not retired at or before it.
|
||||||
- V6: an inventory id is of the family its slot takes.
|
- V6: an inventory id is of the family its slot takes.
|
||||||
|
|
@ -159,6 +159,12 @@ names the rule.
|
||||||
predication's roles.
|
predication's roles.
|
||||||
- V12: a packet has at most 64 referents, predications and relations together
|
- V12: a packet has at most 64 referents, predications and relations together
|
||||||
(a calibration constant, capacity and not meaning).
|
(a calibration constant, capacity and not meaning).
|
||||||
|
- V14: an `addressee_consent` needs an `addressee` and names a predication.
|
||||||
|
- V15: an `addressee_consent` is set only by provenance `Authored` or
|
||||||
|
`GameTelemetry`; a decomposition of text or imported corpus text cannot mint
|
||||||
|
it.
|
||||||
|
- V17: an `addressee` names a referent, not a predication.
|
||||||
|
- V18: a predication whose frame is the inventory's `request_intervention` is named by `addressee_consent`.
|
||||||
|
|
||||||
V9 is retired and not renumbered: the form of a key or a grade is its type, and
|
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
|
an extension key's family is V6. `PacketStore::admit` returns the lowering view of a
|
||||||
|
|
@ -186,10 +192,29 @@ ids.
|
||||||
|
|
||||||
### `discourse`
|
### `discourse`
|
||||||
|
|
||||||
Why the packet is said: `speech_act` is a speech-act id, `addressee` a node id,
|
Why the packet is said: `speech_act` is a speech-act id, `addressee` a referent's node id (V17),
|
||||||
and `features` carry source-side nuance on content (hedge, certainty, irony and
|
and `features` carry source-side nuance on content (hedge, certainty, irony and
|
||||||
whatever the inventory grows). This is source-side communicative pressure, not
|
whatever the inventory grows). This is source-side communicative pressure, not
|
||||||
target-language register.
|
target-language register. `addressee_consent` is no feature: it names the
|
||||||
|
predication the addressee asked for or agreed to, a fact about what the
|
||||||
|
addressee did. `V14` and `V15` check the field: it may be set only by authoring or game
|
||||||
|
telemetry, and it must name a predication of the packet. `V18` refuses a predication whose frame resolves, at the packet's revision, to
|
||||||
|
the gloss `request_intervention` (`frame:3` in the committed inventories)
|
||||||
|
unless `addressee_consent` names that predication. The encoder can never set
|
||||||
|
consent (`V15`), so an encoder-produced `request_intervention` predication (`frame:3`) is always refused.
|
||||||
|
Limits, tracked by `weksa-lowering:follow_up:request-frame-consent-residual`:
|
||||||
|
`V18` keys on the gloss `request_intervention` and sees no other frame or
|
||||||
|
concept, so a request-shaped meaning built from another frame or concept (a
|
||||||
|
`frame:12` communication with `concept:6` request validates) or from a renamed
|
||||||
|
or newly minted frame is not seen by `validate`, which does not read meaning.
|
||||||
|
The inventory carries no marker for request shape, so classifying it would be
|
||||||
|
an inventory-shape decision for Imagination and language-ontology. `addressee_consent`
|
||||||
|
is one field, so a packet carries one request predication, and a compound
|
||||||
|
request is two packets until a packet schema revision makes consent a set. The
|
||||||
|
asker is not checked to be the addressee: a consented `frame:3` whose agent is
|
||||||
|
not the addressee validates, and no code outside `validate` and the encoder
|
||||||
|
prompt text reads `addressee_consent`. That check is an owed obligation with no
|
||||||
|
owner yet.
|
||||||
|
|
||||||
### `stance`
|
### `stance`
|
||||||
|
|
||||||
|
|
@ -204,6 +229,29 @@ 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
|
about the world, so it is never a predication. An empty stance is valid. The
|
||||||
stance reaches the lowerer in the lowering view.
|
stance reaches the lowerer in the lowering view.
|
||||||
|
|
||||||
|
### Speaker and scene documents
|
||||||
|
|
||||||
|
Two documents outside the packet hold the speaker's standing, in the store that
|
||||||
|
owns them (`SpeakerStateStore`). `weksa.speaker` (key `speaker:<entity slug>@<n>`)
|
||||||
|
holds one entity's stable axes as feature values of slot `Speaker`.
|
||||||
|
`weksa.scene` (key `scene:<group>@<n>`) holds a corpus group's participants and,
|
||||||
|
per ordered pair, the speaker's relation to a party (slot `Scene`); the party is
|
||||||
|
a named participant or `Party::Unnamed`, an addressee the scene does not name,
|
||||||
|
such as the player or a crowd. A revision appends and never edits: `n` is the
|
||||||
|
latest plus one, and the latest revision is the highest `n`. Each carries an
|
||||||
|
`Annotator`, `Operator` or `Model` with an attestation string for its generator.
|
||||||
|
Both hold typed feature values only, never text. A feature of slot `Stance`,
|
||||||
|
`Speaker` or `Scene` sits in that slot alone (each layer has one writer and one
|
||||||
|
lifetime), which `admit_revision` enforces. `LoweringInput::with_scene` takes the
|
||||||
|
speaker-state and corpus stores and names neither speaker nor scene: the view knows its corpus item, so
|
||||||
|
the speaker is the item's speaker and the scene is the store's latest for the
|
||||||
|
item's group. The scene must list the item's speaker and the speaker of every live item of the
|
||||||
|
group (V16; `Scene::uncovered`, the rule `put_scene` applies too, so a scene a
|
||||||
|
later corpus item outgrew is refused at the read), and the relation
|
||||||
|
selected is the speaker's to the entity of the addressee referent (to
|
||||||
|
`Unnamed` when that referent has no entity, to nothing when the packet has no
|
||||||
|
addressee or the scene holds no such relation). V13 is retired.
|
||||||
|
|
||||||
### `referents`
|
### `referents`
|
||||||
|
|
||||||
Entities, groups, places, abstractions and the rest. `kind` is a referent-kind
|
Entities, groups, places, abstractions and the rest. `kind` is a referent-kind
|
||||||
|
|
@ -219,6 +267,10 @@ pair a role id with a filler node (the inventory seeds `agent`, `patient`,
|
||||||
aspect, polarity, modality and evidentiality are features with declared option
|
aspect, polarity, modality and evidentiality are features with declared option
|
||||||
sets, not fields.
|
sets, not fields.
|
||||||
|
|
||||||
|
A predication holds each role once (V11). Coordinated fillers ("A and B") are
|
||||||
|
one `group` referent filling the role, each member related to it by `member_of`,
|
||||||
|
from the member to the group.
|
||||||
|
|
||||||
### `relations`
|
### `relations`
|
||||||
|
|
||||||
Edges not owned by one predication. `relation` is a relation id (the inventory
|
Edges not owned by one predication. `relation` is a relation id (the inventory
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ The utterance lowerer does not own:
|
||||||
- rendered audio
|
- rendered audio
|
||||||
- phoneme parity or synth-control truth
|
- phoneme parity or synth-control truth
|
||||||
|
|
||||||
The target-language cultural ontology profile owns:
|
The target-language cultural ontology profile was drafted to own the following; no such profile store exists now (address, politeness and register belong to speaker and scene state, and `data/ontology/ontology.cc` holds per-language lexicon, grammar and discourse choices):
|
||||||
|
|
||||||
- address, politeness, register, and social stance distinctions
|
- address, politeness, register, and social stance distinctions
|
||||||
- idiom and metaphor risks
|
- idiom and metaphor risks
|
||||||
|
|
@ -76,8 +76,8 @@ English remains a lowering target, not the pivot language. Existing flavored
|
||||||
English fixtures continue to be useful, but new multilingual fixtures should
|
English fixtures continue to be useful, but new multilingual fixtures should
|
||||||
show English as one realization in a set.
|
show English as one realization in a set.
|
||||||
|
|
||||||
The initial cultural ontology profile is
|
The en-US choices are the `en-US` entries of `data/ontology/ontology.cc`
|
||||||
`data/target-language-ontology/en-US.yaml`.
|
(model-drafted seeds; read with `weksa ontology dump`).
|
||||||
|
|
||||||
### Brazilian Portuguese
|
### Brazilian Portuguese
|
||||||
|
|
||||||
|
|
@ -94,16 +94,10 @@ The target lowerer should record:
|
||||||
defaults
|
defaults
|
||||||
- optional regional or cultural overlays when the source context earns them
|
- optional regional or cultural overlays when the source context earns them
|
||||||
|
|
||||||
The initial cultural ontology profile is
|
No pt-BR ontology entry exists yet; the cut that adds pt-BR writes them to
|
||||||
`data/target-language-ontology/pt-BR.yaml`.
|
`data/ontology/ontology.cc`. Umbanda-related scenes, speakers, ritual contexts,
|
||||||
|
or referents may later earn a regional or cultural variant, which must not
|
||||||
Umbanda-related scenes, speakers, ritual contexts, or referents may add the
|
activate for generic Brazilian flavor.
|
||||||
`data/target-language-ontology/pt-BR-umbanda.yaml` overlay. That overlay must
|
|
||||||
not activate for generic Brazilian flavor.
|
|
||||||
|
|
||||||
Regional or cultural variants should be selected through the overlay catalog at
|
|
||||||
`data/target-language-ontology/pt-BR-overlays.yaml`. Planned overlay slots are
|
|
||||||
not active profiles until reviewed examples and source context exist.
|
|
||||||
|
|
||||||
### Japanese
|
### Japanese
|
||||||
|
|
||||||
|
|
@ -119,8 +113,8 @@ The target lowerer should record:
|
||||||
- whether subject omission preserves or harms clarity
|
- whether subject omission preserves or harms clarity
|
||||||
- whether katakana, kanji, kana, or mixed script choices carry useful pressure
|
- whether katakana, kanji, kana, or mixed script choices carry useful pressure
|
||||||
|
|
||||||
The initial cultural ontology profile is
|
The ja-JP choices are the `ja-JP` entries of `data/ontology/ontology.cc`
|
||||||
`data/target-language-ontology/ja-JP.yaml`.
|
(model-drafted seeds; read with `weksa ontology dump`).
|
||||||
|
|
||||||
## Packet Shape
|
## Packet Shape
|
||||||
|
|
||||||
|
|
|
||||||
52
docs/name-span-annotation.md
Normal file
52
docs/name-span-annotation.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Name-span annotation
|
||||||
|
|
||||||
|
How the name spans of an imported public-domain work are placed. A span has a
|
||||||
|
committed rule that produced it; this file is that rule.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
- `pd-annotation-model-then-attest`: a labelled model places the spans of a
|
||||||
|
public-domain work; the operator may attest them afterwards.
|
||||||
|
- `leak-owned-by-flow-and-scorer-3`: spans are fixed with the text at intake and
|
||||||
|
written by no loop writer. The offsets are derived from markup the annotator
|
||||||
|
writes, never typed by a model (inline marking, as in GPT-NER, Wang et al.
|
||||||
|
2023); `CorpusStore::add` judges range, grapheme boundary, form equality and
|
||||||
|
overlap.
|
||||||
|
|
||||||
|
The work file (`weksa corpus import <work>`) is a transcription of a work in
|
||||||
|
hand: provenance, cast forms, lines, scene groups and the speaker each speech
|
||||||
|
prefix names. It holds no distributor header, licence or trademark text, and the
|
||||||
|
binary fetches nothing.
|
||||||
|
|
||||||
|
## Rubric r1
|
||||||
|
|
||||||
|
You are given a work's cast: each member's entity id and the forms the work spells that member with. For each numbered line, copy the line exactly, character for character, and wrap each place where the line names a cast member by one of that member's listed forms as [[<entity id>|<form>]]. Mark a form only where it refers to that cast member; a listed form used as an ordinary word, or naming someone else, stays unmarked. Do not mark pronouns, titles or descriptions unless the exact text is a listed form. When a form is listed for two members, mark the one the line refers to; when you cannot tell, leave it unmarked. Answer one JSON line per input line, in the order given, and nothing else: {"line": N, "marked": "..."}.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
- One fresh model agent per work. Its brief is rubric r1, the cast as
|
||||||
|
`<entity id>: <form>, <form>` lines and the work's lines as `<n><TAB><text>`;
|
||||||
|
nothing else (no store, no loop, no other answer).
|
||||||
|
- Import with `--model-family` and `--generator '<annotator model id> (name-span
|
||||||
|
rubric r1, docs/name-span-annotation.md)'`. An operator-written or
|
||||||
|
operator-reviewed answer imports with `--operator`.
|
||||||
|
- Import is all or nothing: every entity and item passes the real store doors on
|
||||||
|
scratch copies before the first write, so a refusal (a parse failure, a span
|
||||||
|
the store judges, a work that repeats a line under case or width folding)
|
||||||
|
leaves the stores untouched. Re-draw the whole answer once, log it, and import
|
||||||
|
again. A work file repeating a line's text is refused naming both lines; leave
|
||||||
|
that line out of the answer file (the rest imports with its numbering intact),
|
||||||
|
since a repeated line cannot be two corpus items.
|
||||||
|
- A line the answer does not cover is not imported by that run; a later run over
|
||||||
|
other lines of the work does not collide (keys are `item:<slug>:<n>`).
|
||||||
|
- A line whose own text holds `[[` or `]]` cannot be marked and is refused; it
|
||||||
|
can still enter by hand through `weksa corpus add --name`.
|
||||||
|
|
||||||
|
## Label scope
|
||||||
|
|
||||||
|
`names_by` labels the spans only, not the line or its speaker. One label per
|
||||||
|
item: a later attestation would be a superseding item with the same text and
|
||||||
|
group, but no command writes one yet; question `pd-name-span-attestation` owns
|
||||||
|
that door.
|
||||||
|
`None` means unrecorded (the cast seeds, `weksa corpus add`, the Dao De Jing
|
||||||
|
bed), not attested.
|
||||||
195
docs/prior-art-and-plan.md
Normal file
195
docs/prior-art-and-plan.md
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
# Prior art and plan
|
||||||
|
|
||||||
|
Meta asked (#weksa, 2026-11-13) for scaffolding to stand on instead of
|
||||||
|
building from base principles. This is a survey of what exists and a plan that
|
||||||
|
borrows it. Every source below was read in full text unless marked **snippet**
|
||||||
|
(seen only in a search result) or **not read**. Research only: nothing here is
|
||||||
|
built, and no new loop machinery is proposed.
|
||||||
|
|
||||||
|
## What exists
|
||||||
|
|
||||||
|
**Semantic backbones: none carries the part we care about.**
|
||||||
|
|
||||||
|
- AMR ([Banarescu 2013](https://aclanthology.org/W13-2322.pdf)): no politeness,
|
||||||
|
speech-act or pragmatics text at all; it also does not distinguish real from
|
||||||
|
hypothetical events (its Section 4).
|
||||||
|
- UMR ([Van Gysel 2021](https://par.nsf.gov/servlets/purl/10288269)): adds
|
||||||
|
document-level coreference, temporal and modal-dependency graphs. The paper
|
||||||
|
has no politeness, addressee or register layer; the only speaker-like object
|
||||||
|
is the author node. Annotation agreement was still "awaiting confirmation".
|
||||||
|
- Universal Dependencies: the `Polite` feature has four values (`Infm`, `Form`,
|
||||||
|
`Elev`, `Humb`; [feature page](https://universaldependencies.org/u/feat/Polite.html)),
|
||||||
|
but the Japanese GSD treebank carries no `Polite`
|
||||||
|
([treebank page](https://universaldependencies.org/treebanks/ja_gsd/index.html)).
|
||||||
|
The page says Japanese *teineigo* shares the value `Form` with formal register
|
||||||
|
although it is a different axis. UD is a parse scheme, not a meaning scheme.
|
||||||
|
- Grammatical Framework RGL: 38 languages, Japanese present
|
||||||
|
([synopsis](https://www.grammaticalframework.org/lib/doc/synopsis/index.html),
|
||||||
|
[status](https://www.grammaticalframework.org/lib/doc/status.html)); the source
|
||||||
|
declares `Style = Plain | Resp`, so two politeness levels and no honorific
|
||||||
|
paradigm
|
||||||
|
([ResJpn.gf](https://raw.githubusercontent.com/GrammaticalFramework/gf-rgl/master/src/japanese/ResJpn.gf)).
|
||||||
|
LGPL/BSD, application grammars may take any licence
|
||||||
|
([LICENSE](https://raw.githubusercontent.com/GrammaticalFramework/gf-rgl/master/LICENSE)).
|
||||||
|
- Abstract Wikipedia / Wikifunctions: encyclopedic content, 2026 soft launch in
|
||||||
|
three languages, no politeness
|
||||||
|
([Meta page](https://meta.wikimedia.org/wiki/Abstract_Wikipedia)). The
|
||||||
|
Wikifunctions NLG and Japanese documentation were **not read**.
|
||||||
|
|
||||||
|
**Typology.**
|
||||||
|
|
||||||
|
- WALS (CC BY 4.0, CLDF at <https://github.com/cldf-datasets/wals>): the only
|
||||||
|
politeness feature is 45A, pronoun politeness
|
||||||
|
([chapter](https://wals.info/chapter/45)); Japanese is coded value 4 ([datapoint](https://wals.info/valuesets/45A-jpn)), pronoun
|
||||||
|
avoidance. No feature for verbal honorifics or register.
|
||||||
|
- Grambank (CC BY 4.0, <https://github.com/grambank/grambank>): GB415 is the
|
||||||
|
second-person politeness distinction; I found no verbal-honorific feature
|
||||||
|
(loose name match, not exhaustive). The 195-feature / 2,467-variety size is
|
||||||
|
**snippet**; Japanese's GB415 value was **not looked up**.
|
||||||
|
|
||||||
|
**Formality control in MT: the closest prior art, and it is a sentence-level
|
||||||
|
label.**
|
||||||
|
|
||||||
|
- [Sennrich 2016](https://aclanthology.org/N16-1005.pdf): a side-constraint
|
||||||
|
token controls T/V; forced-polite output was 96% polite or neutral.
|
||||||
|
- [Feely 2019](https://aclanthology.org/D19-5203.pdf): the same for EN-JA with
|
||||||
|
three levels (informal, polite, formal); labels by rule plus a fastText
|
||||||
|
classifier (F1 0.83-0.86 on 150 hand-labelled sentences); formal text is only
|
||||||
|
0.7-1.6% of training data.
|
||||||
|
- [CoCoA-MT](https://arxiv.org/pdf/2205.04022) (CDLA-Sharing-1.0, repo
|
||||||
|
<https://github.com/amazon-science/contrastive-controlled-mt>): 1,000 train and
|
||||||
|
600 test segments for Japanese with contrastive formal/informal references and
|
||||||
|
phrase-level markers. Two baseline systems' EN-JA outputs, labelled by
|
||||||
|
professionals, came out formal 29.0% / 73.8% and "other" about 20-24%; EN-JA
|
||||||
|
inter-annotator agreement 0.82, the lowest of the six languages.
|
||||||
|
- [Marrese-Taylor 2023](https://aclanthology.org/2023.wmt-1.49.pdf): GPT-3 and
|
||||||
|
ChatGPT prompted reach about 90% formality-control accuracy on EN-JA, which
|
||||||
|
the authors say beats fine-tuned MT; classifier and data at
|
||||||
|
<https://github.com/epochx/japanese-formality> (no licence reported).
|
||||||
|
- [IWSLT 2023 formality track](https://aclanthology.org/2023.iwslt-1.1.pdf): no
|
||||||
|
Japanese; metrics matched-accuracy (phrase markers) and classifier-accuracy.
|
||||||
|
Niu 2017 is read ([D17-1299](https://aclanthology.org/D17-1299.pdf)); Niu 2019
|
||||||
|
and Rippeth 2022 (arXiv 2205.06644, abstract only) are **not read**.
|
||||||
|
- None of these takes a speaker-state or addressee relation. The label is one
|
||||||
|
bit or three levels attached to a source sentence.
|
||||||
|
|
||||||
|
**Grammar-in-context (for conlang targets).**
|
||||||
|
|
||||||
|
- [MTOB](https://arxiv.org/pdf/2309.16575) (MIT code, CC BY 4.0 grammar book):
|
||||||
|
Claude 2 with word list plus retrieved sentences plus long grammar 44.7 / 45.8
|
||||||
|
chrF against a human learner at 51.6 / 57.0.
|
||||||
|
- [Aycock 2025](https://arxiv.org/pdf/2409.19151): most of the gain comes
|
||||||
|
from the book's parallel examples (Gemini-1.5-Flash eng->kgv chrF++ 30.8 with
|
||||||
|
examples only against 34.4 whole book and 11.0 zero-shot; explanations alone
|
||||||
|
22.6). Kalamang is a natural language, so transfer to a conlang is assumed.
|
||||||
|
- [Gemini 1.5 report](https://arxiv.org/pdf/2403.05530): full book 5.46 against a
|
||||||
|
human learner's 5.52 on a 0-6 human scale.
|
||||||
|
Suggests, for a conlang target (assumed to transfer), worked paired examples first and grammar
|
||||||
|
prose second.
|
||||||
|
|
||||||
|
**Prompt optimisation and evaluation.**
|
||||||
|
|
||||||
|
- [DSPy](https://arxiv.org/pdf/2310.03714) (MIT), [MIPRO](https://arxiv.org/pdf/2406.11695),
|
||||||
|
[OPRO](https://arxiv.org/pdf/2309.03409) (Apache-2.0): DSPy compiles take
|
||||||
|
"10-20 trials over 150-300 validation examples"; MIPRO's task sets run from 75
|
||||||
|
train (Iris) to 500 per task. MIPRO found bootstrapped demos gave the biggest
|
||||||
|
gain and tests with Wilcoxon over 5 runs. OPRO discusses overfitting to the
|
||||||
|
training set.
|
||||||
|
- [Koehn 2004](https://aclanthology.org/W04-3250.pdf): paired bootstrap;
|
||||||
|
against a 30,000-sentence reference, a 2.0-point BLEU gap was detected at 95%
|
||||||
|
significance in 31% of runs at n=100 and 65% at n=300; a 5.1-point gap in 97%
|
||||||
|
at n=100. The paper suggests n=100 might be too small.
|
||||||
|
- [Dror 2018](https://aclanthology.org/P18-1128.pdf): paired bootstrap is
|
||||||
|
"less effective for small test sets"; approximate randomization has been used
|
||||||
|
for test sets above 20 points; only 3 of 110 surveyed ACL 2017 papers
|
||||||
|
corrected for multiple comparisons.
|
||||||
|
- [Kocmi 2021](https://arxiv.org/pdf/2107.10821): pairwise accuracy against
|
||||||
|
humans COMET 83.4, BLEURT 80.0, chrF 75.6, BLEU 74.6; recommends COMET
|
||||||
|
primary, a string metric secondary and a paired test; observes "a slight drop
|
||||||
|
in metric ranks for some pretrained metric" against ChrF for non-Latin and
|
||||||
|
logogram targets and says more analysis would be required.
|
||||||
|
- [chrF](https://aclanthology.org/W15-3049.pdf): validated on seven targets, Japanese
|
||||||
|
not among them.
|
||||||
|
|
||||||
|
## What we borrow
|
||||||
|
|
||||||
|
| Need | Borrow | Instead of |
|
||||||
|
|---|---|---|
|
||||||
|
| Politeness vocabulary for the packet's register field | UD `Polite` values (Infm, Form, Elev, Humb) as the label set, and Feely's three-level Japanese split | inventing level names |
|
||||||
|
| Modal/author structure | UMR's conceiver and modal-dependency idea for evidence/stance fields | a bespoke modality scheme |
|
||||||
|
| Japanese test of register | CoCoA-MT JA set (600 contrastive test segments, phrase markers), KeiCO for relation-labelled Japanese, and the Japanese-formality classifier | hand-written register references only |
|
||||||
|
| Conlang scaffolding | MTOB recipe: worked paired examples first, word list, grammar prose after (shown on Kalamang, a natural language; transfer to a conlang is an assumption) | long grammar prompts alone |
|
||||||
|
| Typology priors | WALS 45A and Grambank GB415 to seed which targets have a politeness axis | per-language guesses |
|
||||||
|
| Significance | paired approximate randomization over items; chrF with an embedding metric | reading small differences as results |
|
||||||
|
| Optimiser | DSPy/MIPRO only once a scoring set of at least 75 items exists (MIPRO's smallest task), more for stable compiles; the 35-item sets are below that | the golf loop growing a private optimiser |
|
||||||
|
|
||||||
|
We do not adopt AMR, UMR, UD or GF as the packet. None represents the speaker's
|
||||||
|
relation to the addressee, which the packet's scene state does. The packet stays
|
||||||
|
ours; it borrows vocabulary.
|
||||||
|
|
||||||
|
## What is actually new
|
||||||
|
|
||||||
|
Prior work already conditions honorific choice on the speaker-addressee
|
||||||
|
relation or status in narrower forms: KeiCO (Liu and Kobayashi 2022, 10,007
|
||||||
|
examples, annotates the speaker-listener social relationship; read through
|
||||||
|
Marrese-Taylor 2023 and the abstract), Korean context-aware NMT that infers the
|
||||||
|
relation from surrounding sentences
|
||||||
|
([MDPI](https://www.mdpi.com/2079-9292/10/13/1589), snippet), Brown-Levinson
|
||||||
|
generation that takes power and distance as parameters (Walker et al., not
|
||||||
|
read), and a 2026 translator given a known addressee
|
||||||
|
([Cihlar et al.](https://aclanthology.org/2026.iaai-1.3), snippet only). What I
|
||||||
|
did not find is the whole chain: persona state (status, restraint, stance) to a
|
||||||
|
register decision for an LLM lowerer, measured against a prompted baseline.
|
||||||
|
That is the claim, and it is relative to what I searched; closer work may
|
||||||
|
exist.
|
||||||
|
|
||||||
|
Uncontrolled commercial MT left 20-24% of EN-JA outputs inconsistent in
|
||||||
|
register (CoCoA-MT Table 4, two generic systems, no formality instruction). That
|
||||||
|
shows register is unstable without control, not how a prompted LLM behaves.
|
||||||
|
|
||||||
|
## Smallest end-to-end slice
|
||||||
|
|
||||||
|
One path, existing machinery, ja-JP only, no new loop:
|
||||||
|
|
||||||
|
1. A packet with scene state (speaker, addressee, relation) goes through the
|
||||||
|
current `weksa loop` encode and lowering to Japanese.
|
||||||
|
2. The same packet under two relations (to a superior and to an intimate) must
|
||||||
|
produce the two UD `Polite` levels; check with the Japanese formality
|
||||||
|
classifier plus a native-reader pass on the 35-item Validation corpus per
|
||||||
|
language (see the map's recalibration blocks).
|
||||||
|
3. Score the register decision as a classification (matched accuracy), not only
|
||||||
|
chrF. CoCoA-MT's JA references give an outside check on the classifier, a
|
||||||
|
weak one: its reference-based segment classifier on EN-JA had precision
|
||||||
|
0.71 and 0.69 and recall 0.43 and 0.54 in the paper's Table 5.
|
||||||
|
|
||||||
|
Out of scope for the slice: conlang targets, optimisers, a second language.
|
||||||
|
|
||||||
|
## What good enough means
|
||||||
|
|
||||||
|
- Register accuracy: report matched accuracy for ours and for a plain prompt
|
||||||
|
that is given the relation in words, on the same items, with paired
|
||||||
|
intervals. We measure that baseline ourselves; Marrese-Taylor's roughly 90%
|
||||||
|
is classifier accuracy (0.83-0.86 by phrase-marker matching) for binary
|
||||||
|
formal/informal control with the label named in the prompt, an upper-region
|
||||||
|
reference and not our baseline. The claim is non-inferiority with a stated
|
||||||
|
margin. Superiority is claimed only if enough items are discordant (about 6
|
||||||
|
or more, all in one direction, at n=35); against a baseline near 90%, n=35
|
||||||
|
cannot show it, and the result is then "not detected".
|
||||||
|
- Meaning is not lost: chrF and meaning cosine are not detectably below the
|
||||||
|
plain prompt on the same items, under the same paired test.
|
||||||
|
- Honest sample size: 35 items per language detects only large gaps, so results
|
||||||
|
carry intervals and "not detected" stands as an outcome. Small gaps are not
|
||||||
|
claims. Koehn's figures are for sentence-level BLEU on 100-3000-sentence
|
||||||
|
blocks and do not transfer to a binary register metric on 35 items; power
|
||||||
|
for that is computed directly from the discordant-pair count.
|
||||||
|
- A native-reader check on register for a sample, because every automatic metric
|
||||||
|
here was validated on languages other than the persona register we care about.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- Which persona fields (status, restraint, stance) are in the packet today
|
||||||
|
versus planned: this doc does not decide that; `docs/interlingua-standard.md`
|
||||||
|
owns it.
|
||||||
|
- Licences not checked: the UMR dataset, the other four Japanese UD treebanks,
|
||||||
|
CoCoA-MT repo licence ambiguity (paper says CDLA-Sharing-1.0, GitHub says
|
||||||
|
NOASSERTION).
|
||||||
|
|
@ -65,7 +65,7 @@ Verified licence facts are few. Discord data excluded by policy; not read.
|
||||||
- PersonaChat (Zhang et al. 2018): ParlAI project page, archived 2020-08-01, states no licence, only "You may continue to use the data without issue". [V] https://parl.ai/projects/personachat/ ; paper https://arxiv.org/abs/1801.07243 says data released open source via ParlAI; licence text [GAP]. Crowdworker-written, persona-statement-conditioned chit-chat [B].
|
- PersonaChat (Zhang et al. 2018): ParlAI project page, archived 2020-08-01, states no licence, only "You may continue to use the data without issue". [V] https://parl.ai/projects/personachat/ ; paper https://arxiv.org/abs/1801.07243 says data released open source via ParlAI; licence text [GAP]. Crowdworker-written, persona-statement-conditioned chit-chat [B].
|
||||||
- LIGHT: ParlAI page states no licence; contents: 663 locations, 3,462 objects, 1,755 character types, 11,000 episodes; WILD 41,131 training episodes from game deployment (human players, so a different provenance than crowdwork). [V] https://parl.ai/projects/light/ . Licence [GAP: check ParlAI repo].
|
- LIGHT: ParlAI page states no licence; contents: 663 locations, 3,462 objects, 1,755 character types, 11,000 episodes; WILD 41,131 training episodes from game deployment (human players, so a different provenance than crowdwork). [V] https://parl.ai/projects/light/ . Licence [GAP: check ParlAI repo].
|
||||||
- Cornell Movie-Dialogs Corpus: licence/terms page fetch failed (certificate rejected) https://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html . [GAP] Underlying scripts are copyrighted film text [B]; dataset licence unverified.
|
- Cornell Movie-Dialogs Corpus: licence/terms page fetch failed (certificate rejected) https://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html . [GAP] Underlying scripts are copyrighted film text [B]; dataset licence unverified.
|
||||||
- Project Gutenberg: "vast majority" of eBooks public domain in the US, so no one can grant or withhold permission for uses; PG cannot sublicense copyrighted items; subject to its Terms of Use, which the permission page links and does not summarise; no statement on ML/bulk use on that page. [V] https://www.gutenberg.org/policy/permission.html . Terms-of-Use text on bulk/automated access and the trademark licence: [GAP, not read]. Items public domain in the US may not be elsewhere (same page).
|
- Project Gutenberg: "vast majority" of eBooks public domain in the US, so no one can grant or withhold permission for uses; PG cannot sublicense copyrighted items; subject to its Terms of Use, which the permission page links and does not summarise; no statement on ML/bulk use on that page. [V] https://www.gutenberg.org/policy/permission.html . Terms-of-Use text on bulk/automated access and the trademark licence: read 2026-11-13, see `2026-11-13-gutenberg-terms.md`. Items public domain in the US may not be elsewhere (same page).
|
||||||
- Google Synthetic-Persona-Chat (model-generated, persona-chat-style): CC BY-NC? listed as cc-by-4.0 on HF. [V search-only] https://huggingface.co/datasets/google/Synthetic-Persona-Chat
|
- Google Synthetic-Persona-Chat (model-generated, persona-chat-style): CC BY-NC? listed as cc-by-4.0 on HF. [V search-only] https://huggingface.co/datasets/google/Synthetic-Persona-Chat
|
||||||
- Operator-authored or model-generated corpus: no licence question beyond provider terms [B, unread this pass]. Problems with evaluating on model-generated text: see 2 (self-bias, preference leakage, stylistic homogeneity: Xu et al. 2025; Li et al. 2025). The same Xu et al. finding says it extends to open-ended generation.
|
- Operator-authored or model-generated corpus: no licence question beyond provider terms [B, unread this pass]. Problems with evaluating on model-generated text: see 2 (self-bias, preference leakage, stylistic homogeneity: Xu et al. 2025; Li et al. 2025). The same Xu et al. finding says it extends to open-ended generation.
|
||||||
- Expressive-register-annotated English corpora (hedging, humour, affect, politeness labelled), with permissive terms: [GAP] not searched beyond the list above (e.g. Stanford Politeness Corpus, GYAFC formality, Enron etc. are [B] candidates, unverified, GYAFC is known to carry Yahoo Answers terms [B]).
|
- Expressive-register-annotated English corpora (hedging, humour, affect, politeness labelled), with permissive terms: [GAP] not searched beyond the list above (e.g. Stanford Politeness Corpus, GYAFC formality, Enron etc. are [B] candidates, unverified, GYAFC is known to carry Yahoo Answers terms [B]).
|
||||||
|
|
@ -83,6 +83,6 @@ Verified licence facts are few. Discord data excluded by policy; not read.
|
||||||
- No per-metric human-correlation numbers for hedging, humour, affect, stance, persona voice; nearest is formality (Briakou 2021). COMET, BLEURT, MAUVE correlations not obtained.
|
- No per-metric human-correlation numbers for hedging, humour, affect, stance, persona voice; nearest is formality (Briakou 2021). COMET, BLEURT, MAUVE correlations not obtained.
|
||||||
- No source on surface-form smuggling through an LLM bottleneck or its defences (vocab restriction, paraphrase invariance).
|
- No source on surface-form smuggling through an LLM bottleneck or its defences (vocab restriction, paraphrase invariance).
|
||||||
- No source on schema/inventory growth from generation failures, or its overfitting controls.
|
- No source on schema/inventory growth from generation failures, or its overfitting controls.
|
||||||
- Cornell, LIGHT, PersonaChat licence texts not obtained; Gutenberg ToU on bulk use not read.
|
- Cornell, LIGHT, PersonaChat licence texts not obtained.
|
||||||
- No realiser or evaluation protocol for constructed languages from a meaning representation.
|
- No realiser or evaluation protocol for constructed languages from a meaning representation.
|
||||||
- Several arXiv PDFs (Briakou, Zheng, STEL, LUAR/StyleDistance) fetched as binary; numbers above come from the ar5iv/arXiv-HTML versions where marked, otherwise search summaries.
|
- Several arXiv PDFs (Briakou, Zheng, STEL, LUAR/StyleDistance) fetched as binary; numbers above come from the ar5iv/arXiv-HTML versions where marked, otherwise search summaries.
|
||||||
|
|
|
||||||
59
docs/research/2026-11-13-gutenberg-terms.md
Normal file
59
docs/research/2026-11-13-gutenberg-terms.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Project Gutenberg terms: bulk access and header stripping
|
||||||
|
|
||||||
|
Read 2026-11-13 for follow-up `gutenberg-terms` (campaign `weksa-lowering`),
|
||||||
|
ahead of cut `pd-importer`. Pages: gutenberg.org `policy/permission.html`,
|
||||||
|
`policy/terms_of_use.html`, `policy/robot_access.html`, `policy/license.html`.
|
||||||
|
The quotes were checked against the live HTML of each page by a second reader.
|
||||||
|
`pd-importer`'s Imagination re-reads the pages before it fixes a fetch path,
|
||||||
|
since they change.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- **Automated access to the main site is refused.** Terms of Use: "This website
|
||||||
|
is intended for human users only"; "Any perceived use of automated tools to
|
||||||
|
access this website will result in a temporary or permanent block". OPDS
|
||||||
|
feeds: no more requests than a browser user would make; heavy requesters are
|
||||||
|
blocked by IP.
|
||||||
|
- **Bulk has two sanctioned paths.** Terms of Use: take bulk downloads "from
|
||||||
|
one of our mirrors, not from the main site". The robot-access page names the
|
||||||
|
`robot/harvest` wget endpoint (`-w 2`, filterable by file type or language)
|
||||||
|
and recommends a private mirror for a full local copy; it suggests checking
|
||||||
|
periodically for changes. Catalog data are offered as
|
||||||
|
machine-readable offline catalogs, "granted to the public domain".
|
||||||
|
- **Header and licence stripping.** Licence section 1.C: for a work not
|
||||||
|
protected by US copyright, with the user in the US, the Foundation does "not
|
||||||
|
claim a right to prevent you from copying, distributing, performing,
|
||||||
|
displaying or creating derivative works", "as long as all references to
|
||||||
|
Project Gutenberg are removed", the condition holding for a user in the US.
|
||||||
|
Section 1.E opens "Unless you have removed all references to Project
|
||||||
|
Gutenberg", and 1.E.1 to 1.E.7 (including 1.E.4, do not detach the licence)
|
||||||
|
attach to a work on which the phrase "Project Gutenberg" appears or with which
|
||||||
|
it is associated, so a text with every reference removed is outside 1.E.
|
||||||
|
1.E.2 is not that grant: it says a PG eBook derived from texts not restricted
|
||||||
|
by US copyright may be copied and distributed in the US without fee. The
|
||||||
|
trademark is registered; non-commercial use of the name needs no permission,
|
||||||
|
commercial use does.
|
||||||
|
- **No statement on machine learning or AI use** on any of the four pages.
|
||||||
|
- **US public domain only.** The Foundation makes no claim about copyright
|
||||||
|
outside the US (1.D). A work free in the US may not be free elsewhere, and
|
||||||
|
Gutenberg's own catalog is not a per-work clearance.
|
||||||
|
|
||||||
|
## What this decides for `pd-importer`
|
||||||
|
|
||||||
|
- Fetch from a mirror or an offline dump. The Terms of Use footnote counts "many
|
||||||
|
books" as more than about 100 a day and sends those to mirrors, while
|
||||||
|
`robot/harvest` is a `www.gutenberg.org` URL: use it (with `-w 2`) only for a
|
||||||
|
handful of works. Never crawl `gutenberg.org` pages or OPDS from the loop host.
|
||||||
|
- Strip the whole header and footer, every Project Gutenberg reference and the
|
||||||
|
licence text from the stored text; keep provenance as the ebook number and
|
||||||
|
edition in the item's source field, which is Weksa's own record and not a use
|
||||||
|
of the mark. This is the path `docs/targets/dao-de-jing-test-bed.md` already
|
||||||
|
takes. The remaining open question there, what counts as a "reference", stays
|
||||||
|
open: the safe reading is that the ebook number in a provenance field is a
|
||||||
|
catalogue identifier, not a reference inside the work, but nothing read here
|
||||||
|
rules on it.
|
||||||
|
- Per-work check stays with the importer: the work's author and first
|
||||||
|
publication date against US public-domain rules, recorded on the item. The
|
||||||
|
Foundation's catalogue does not supply it. A work Gutenberg posts with the
|
||||||
|
copyright holder's permission (1.E.3) is out of scope.
|
||||||
|
- No importer code in this note.
|
||||||
116
docs/scene-annotation.md
Normal file
116
docs/scene-annotation.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Scene annotation
|
||||||
|
|
||||||
|
How the keeper scenes of `data/original/speaker-state.cc` are labelled. A scene
|
||||||
|
label has a committed rule that produced it; this file is that rule.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Two findings forced a written rule.
|
||||||
|
|
||||||
|
- `annotation-rank-from-register`: an annotator who reads the line's register
|
||||||
|
(politeness forms, honorifics, endings) to decide the speaker's rank hands the
|
||||||
|
lowering its own answer back, because register is what the lowering must
|
||||||
|
produce from the scene.
|
||||||
|
- The addressee gate (`with_scene` in `crates/weksa/src/validate.rs`): a scene is
|
||||||
|
read only when the packet names a discourse addressee. A line that addresses no
|
||||||
|
one reads `NoAddressee` and no Scene entry selects, so labels on narration are
|
||||||
|
inert and the only labels that bite are those on lines said to someone.
|
||||||
|
|
||||||
|
The earlier labels followed two briefs that were never committed and that
|
||||||
|
disagreed with each other (the relation to the reader, and a situation read with
|
||||||
|
the register ignored). The rubric below replaces both for every live group.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
Cut `weksa-lowering:cut_spec:cut-scene-in-group.r1`, ruling
|
||||||
|
`weksa-lowering:ruling:scene-rubric-whole-bed`: all 98 live Train and Validation
|
||||||
|
groups of `data/original` receive a new scene revision from annotator A under
|
||||||
|
rubric r1, generator `claude-sonnet-5-5 (Weksa Hands, scene rubric r1,
|
||||||
|
docs/scene-annotation.md)`. Earlier revisions stay as provenance. The rubric uses
|
||||||
|
no corpus line as an example on purpose: a labelled example would make the blind
|
||||||
|
check against the old labels circular.
|
||||||
|
|
||||||
|
A later rubric revision gets a new section and a new generator string; r1 is
|
||||||
|
never edited.
|
||||||
|
|
||||||
|
## Rubric r1
|
||||||
|
|
||||||
|
The speaker of every line is the keeper, a quiet caretaker of a house and garden. First decide whether the line addresses a listener: it asks them something, asks them to do something, tells them about themselves, wishes something for them, or speaks to them as you. A line that addresses no one (narration, description, a remark about the world or about other people, a thought or wish with no one addressed) is told to the reader, a stranger: answer addressed false, relative_status level, intimacy 500, in_group false. People named inside a line are not its listener.
|
||||||
|
|
||||||
|
For a line that addresses a listener, answer addressed true and judge the keeper's relation to that listener from the situation the line states or implies, never from its register: politeness forms, honorifics, sentence endings, contractions, please and casual particles are what the lowering must produce from the scene, so they cannot decide it. relative_status: above when the situation gives the keeper standing over the listener (instructing, advising or permitting as elder, host, employer or teacher); below when it gives the listener standing over the keeper (the keeper serves, thanks, or asks a favour of a guest, employer, elder or official); level when it shows neither.
|
||||||
|
|
||||||
|
intimacy, 0 to 1000: 500 when the line shows nothing; up to 800 when it shows shared daily life (the listener's body, sleep or habits, the household's routine); above 800 only for a stated close bond; down to 200 for a stated first meeting or a service relation. in_group: true only when the line shows the listener belongs to the keeper's household, family or working circle, stated or plainly implied; a request, a question or a kind wish alone is not enough; otherwise false. Answer one JSON line per group, in the order given and nothing else: {"group": G, "addressed": true|false, "relative_status": S, "intimacy": N, "in_group": true|false}.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
- Annotator A (committed) and annotator B (measurement only) are separate fresh
|
||||||
|
model agents, one pair per language (49 live groups each). Each sees only the
|
||||||
|
text of Rubric r1 above and that language's sentences as `group<TAB>"text"`,
|
||||||
|
shuffled by a fixed seed. Neither sees an existing scene, the other annotator's
|
||||||
|
answer, the ontology, which items are old or new, or any Test text.
|
||||||
|
- An output that does not parse or misses a group is re-drawn once and logged.
|
||||||
|
- A's labels are written with `weksa scene put` (one revision per group,
|
||||||
|
`--to unnamed --inventory-rev 4`). `addressed` is not stored: no Scene feature
|
||||||
|
carries it.
|
||||||
|
- Disagreements are reported, never adjudicated.
|
||||||
|
|
||||||
|
## Agreement at r1 (this cut, 98 live groups, 49 per language)
|
||||||
|
|
||||||
|
Four fresh agents drew the labels (A-en, A-ja, B-en, B-ja); the briefs were the
|
||||||
|
rubric section plus one language's shuffled sentences (SHA-256 of the brief
|
||||||
|
bodies: en-US `db087476...dbc04e`, ja-JP `c5b81b27...97488c`; no Test text in
|
||||||
|
either). No draw was repeated. Annotator model: `claude-sonnet-5-5`.
|
||||||
|
|
||||||
|
A against B (B measures only): pooled `addressed` equal 96/98 (kappa 0.96),
|
||||||
|
`in_group` equal 93/98 (kappa 0.79), `relative_status` equal 89/98, intimacy
|
||||||
|
within 150 on 98/98. en-US: addressed 47/49, in_group 47/49 (0.86),
|
||||||
|
relative_status 45/49. ja-JP: addressed 49/49, in_group 46/49 (0.64),
|
||||||
|
relative_status 44/49.
|
||||||
|
|
||||||
|
Blind check, A against the 56 older labels: `relative_status` equal 49/56,
|
||||||
|
`in_group` equal 53/56 (kappa 0.64), intimacy within 150 on 55/56. Three
|
||||||
|
in_group differences: `original-ja-shimoyado-2` and `original-ja-sakana` (both
|
||||||
|
narrations, true to false) and `original-ja-asa` (an instruction to a household
|
||||||
|
member, false to true).
|
||||||
|
|
||||||
|
In_group true across the 98: 42 before, 15 after; every unaddressed line is now
|
||||||
|
false (it was true on 17 of the 21 unaddressed vgrow groups). Disagreements are
|
||||||
|
reported, not adjudicated.
|
||||||
|
|
||||||
|
## Reproducing the cut
|
||||||
|
|
||||||
|
`scripts/scene-annotation/` holds the standard-library scripts of this cut, run
|
||||||
|
from the repo root with `WEKSA` (the binary) and `SCENE_SCRATCH` (a directory
|
||||||
|
that is never committed): `build_briefs.py` writes the two annotator briefs from
|
||||||
|
the Rubric r1 section and prints each brief's Test-leak count (must be 0) and
|
||||||
|
sha256 (it reproduces the briefs of this cut byte for byte); `put_labels.py`
|
||||||
|
puts A's `A-en.jsonl` and `A-ja.jsonl`; `agreement.py` prints the tables above and
|
||||||
|
must run before the puts, against the store at 76fa685, because the older labels
|
||||||
|
are then its latest revisions (it also reads the round-1 encoder answers of
|
||||||
|
`runs/growth-base` through `ROUND1`). `DIGEST_OF_THE_RUBRIC` and
|
||||||
|
`DIGEST_OF_THE_EARLIER_SCENES` in `crates/weksa/tests/original_corpus.rs` pin the
|
||||||
|
rubric text and the 113 earlier revisions.
|
||||||
|
|
||||||
|
## Known limits of r1 labels
|
||||||
|
|
||||||
|
Found by Soul's pass on this cut (`weksa-lowering:verdict:cut-scene-in-group.s1`).
|
||||||
|
They are not fixed in r1; a reviewer's pass over the labels is follow-up
|
||||||
|
`weksa-lowering:follow_up:scene-labels-reader-review`.
|
||||||
|
|
||||||
|
- The new labels change which ontology entries are selected for 37 groups. Eight
|
||||||
|
addressed lines move onto the casual in-group entries (en-US:12: lantern,
|
||||||
|
broom, mirror, seedling; ja-JP:12: furo, asa, irori, yurikago), while their
|
||||||
|
references are polite requests. The reader default (level, 500, false) is
|
||||||
|
exactly the condition of ja-JP:11.
|
||||||
|
- `relative_status` tracks request form for some lines: imperatives read above,
|
||||||
|
favour questions read level or below, so the same household request gets
|
||||||
|
different standing in two forms (hane above/650/true, kabin below/500/false;
|
||||||
|
B gave both level/false). Form is what signals the speech act, which is what
|
||||||
|
part 2 asks for, and register is what part 2 forbids.
|
||||||
|
- `in_group` is true on some requests alone (lantern, mulch, stool, hane) and on
|
||||||
|
a line whose only family member is named inside it (original-ja-ane), though r1
|
||||||
|
says a request or a person named inside the line is not enough. A and B
|
||||||
|
agreeing does not clear this.
|
||||||
|
- A run opened on the old speaker-state refuses to continue after merge:
|
||||||
|
`runs/growth-base` pins the store of `/var/lib/mind-weksa-self/Weksa-growth`
|
||||||
|
(main), and that checkout must not be pulled while the run is wanted.
|
||||||
119
docs/targets/dao-de-jing-test-bed.md
Normal file
119
docs/targets/dao-de-jing-test-bed.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# Dao De Jing test bed
|
||||||
|
|
||||||
|
A lowering test bed on James Legge's public-domain translation of the Dao De
|
||||||
|
Jing, from Meta's analogy: the Dao has many varied human translations, so it is
|
||||||
|
a ready-made field of precedents for what one meaning can be lowered to. Meta
|
||||||
|
proposed it in #weksa:gamecult.org on 2026-11-06 (Matrix event
|
||||||
|
`$2_P0mbtbnnsP4I61UhVMlatuDSTjFXMOuGa4SSbhGmc`: public-domain translations only
|
||||||
|
as the scoring corpus, copyrighted ones cited without copying) and noted the
|
||||||
|
ontology consequence (event `$E4VHwS-zFEk9Fa-daFX8MkfoySn0iX0m1grltEJ9A8Y`:
|
||||||
|
model an imperial native term as a term with several context-tagged readings
|
||||||
|
plus a separately recorded chosen gloss per lowering).
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
- `data/daodejing/`: its own stores (inventory revisions 1 to 3, one entity, five
|
||||||
|
corpus items, five packets), apart from the production stores in `data/`.
|
||||||
|
Revision 3 is `weksa::daodejing::revision()`; the packets are
|
||||||
|
`weksa::daodejing::packets()`. The scene revision (`speaker_scene_revision()`, also
|
||||||
|
numbered 3) is not applied in these stores and will need renumbering when it
|
||||||
|
is applied anywhere both meet.
|
||||||
|
- Rebuild: `weksa --corpus C --inventory I --entities E` with `inventory seed`,
|
||||||
|
`inventory proof`, `inventory dao`, `entity put entity:laozi ...`,
|
||||||
|
`corpus add ... --language en-US --source public-domain` per item, then `packet dao <store>`.
|
||||||
|
- Source: Project Gutenberg eBook 216, "The Tao Teh King, or the Tao and its
|
||||||
|
Characteristics", Laozi, tr. James Legge (Sacred Books of the East 39, 1891).
|
||||||
|
Gutenberg's licence (1.C) does not claim a right to prevent derivative works of
|
||||||
|
a work not protected by US copyright, for a user in the US, provided every
|
||||||
|
Project Gutenberg reference is removed (see
|
||||||
|
`docs/research/2026-11-13-gutenberg-terms.md`). The items hold only Legge's words, one numbered section
|
||||||
|
or sentence each; no Gutenberg header, licence text or trademark is stored,
|
||||||
|
and the eBook file itself is not committed. `edition_url` names the page the
|
||||||
|
text was retrieved from, as a citation of provenance and not as part of any
|
||||||
|
item's text. The legal-root question of whether a citation counts as a
|
||||||
|
"reference" is not ruled; if Gutenberg says it does, the field moves to the
|
||||||
|
Sacred Books of the East print edition.
|
||||||
|
- `Source::PublicDomain` now carries `licence`, `jurisdiction`, `translator` and
|
||||||
|
`edition_year`, and the store refuses an item missing any of them (an empty
|
||||||
|
string, or year 0).
|
||||||
|
|
||||||
|
## The slice
|
||||||
|
|
||||||
|
Corpus groups are chapters, so the derived split spreads the slice:
|
||||||
|
|
||||||
|
| item | Legge | split |
|
||||||
|
|---|---|---|
|
||||||
|
| `item:daodejing:1` | ch. 3 s. 2 | Train |
|
||||||
|
| `item:daodejing:2` | ch. 7 s. 1, first sentence | Validation |
|
||||||
|
| `item:daodejing:3` | ch. 8 s. 1, first sentence | Validation |
|
||||||
|
| `item:daodejing:4` | ch. 78 s. 4 | Test |
|
||||||
|
| `item:daodejing:5` | ch. 5 s. 2, first sentence | Test |
|
||||||
|
|
||||||
|
Each packet states neutral facts (referents, predications, a speech act) and a
|
||||||
|
stance (restraint 800 on all; affection 300 on the water likeness). The stance is
|
||||||
|
my reading of the sage's voice, not data.
|
||||||
|
|
||||||
|
## First closeness number
|
||||||
|
|
||||||
|
Scored with `weksa score` (chrF, and meaning as cosine under `qwen3-embedding:0.6b`
|
||||||
|
through the local Ollama), one lowered output against its own item.
|
||||||
|
|
||||||
|
| item | split | chrF | meaning | lowered |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Train | 44.18 | 0.831 | The sage empties the minds. The sage fills the bellies. The sage weakens the wills. The sage strengthens the bones. |
|
||||||
|
| 2 | Validation | 82.12 | 0.976 | The heaven is long-enduring. The earth continues long. |
|
||||||
|
| 3 | Validation | 74.99 | 0.974 | The highest excellence is like the water. |
|
||||||
|
| 4 | Test | 82.50 | 0.949 | The words that are strictly true seem paradoxical. |
|
||||||
|
| 5 | Test | 75.99 | 0.894 | The space between heaven and earth may be compared to the bellows. |
|
||||||
|
|
||||||
|
Mean over the five: chrF 71.96, meaning 0.925. Mismatched baseline (each output
|
||||||
|
against the other four references, 20 pairs): chrF 14.1, meaning 0.458. So the
|
||||||
|
scorer separates the right reference from a wrong one by a wide margin; that is
|
||||||
|
all it shows here.
|
||||||
|
|
||||||
|
**Do not read 72 as a measure of the interlingua.** The five outputs are the
|
||||||
|
ceiling of an author who saw the reference:
|
||||||
|
|
||||||
|
- One author wrote the packets, the inventory entries and the lowerer rows with
|
||||||
|
Legge's sentence open. Frame 3 (`act`) was written from the Train item. Frames
|
||||||
|
4 and 5 came from Validation items and frames 6 and 7 from Test items, each
|
||||||
|
written while reading that item. The Train item, the only one lowered by a
|
||||||
|
frame minted from Train, scored lowest (chrF 44.18). The scores rise with
|
||||||
|
how bespoke the frame is, the reverse of generalisation. The Validation and
|
||||||
|
Test numbers measure the lowerer table, not what the interlingua can carry.
|
||||||
|
That table is deleted: the Dao numbers below were measured on it, and the
|
||||||
|
loop now lowers through the model (`Prompt::lower`).
|
||||||
|
- n = 5, one translation as reference, one embedding model, no variance.
|
||||||
|
|
||||||
|
## What the scorer cannot see
|
||||||
|
|
||||||
|
- **Stance.** The stance features are in the packets and the lowerer does not
|
||||||
|
render them, so the number is blind to the packet's voice entirely. It would
|
||||||
|
not change if every stance were deleted.
|
||||||
|
- **Dropped meaning.** Item 5 is a negated rhetorical question ("May not ... be
|
||||||
|
compared"); the lowering is a plain statement with no question mark and no
|
||||||
|
"not". Item 1 loses "Therefore", "in the exercise of his government" and "their".
|
||||||
|
Item 4 loses "to be". chrF scores the overlap and cannot say that the speech
|
||||||
|
act changed. The embedding cosine of 0.894 for item 5 is not much lower than
|
||||||
|
for the statement-for-statement items.
|
||||||
|
- **Table defects it forgives.** "The heaven" and "the water" are wrong English
|
||||||
|
(the lowerer prefixes "the" to every referent and has no proper-noun,
|
||||||
|
mass-noun or entity rendering). chrF takes a small penalty only.
|
||||||
|
- **Register and meaning of the Chinese.** The packets were read off Legge's
|
||||||
|
English, not the Chinese, so this is a round trip English to interlingua to
|
||||||
|
English. It measures how well the packet kept Legge's wording, not whether Legge
|
||||||
|
or the packet is right about the Dao. The term "Tao" never appears in the slice.
|
||||||
|
The multi-reading term modelling Meta described is untested.
|
||||||
|
- **Leak.** `weksa score` scores the whole output against the whole reference
|
||||||
|
with no mask, as the leak ruling says. Nothing here enforces that the
|
||||||
|
author did not use the reference; only the labels above say so.
|
||||||
|
|
||||||
|
## What the run showed the interlingua needs
|
||||||
|
|
||||||
|
- Negation and question force: `polarity` is refused by the lowerer ("features")
|
||||||
|
and `ask` has no punctuation row, so two of Laozi's habitual moves, the negated
|
||||||
|
claim and the rhetorical question, cannot be lowered at all.
|
||||||
|
- A frame per sentence shape is the lowerer's cost model today: each new shape
|
||||||
|
is one more table row and one more inventory frame. The Dao's aphorisms are
|
||||||
|
mostly copular and paradoxical, which suggests the next frames to mint
|
||||||
|
(attribution, contrast, negated identity) before more chapters are added.
|
||||||
|
|
@ -11,7 +11,7 @@ by Imagination for the Eureka-area Self, 2026-10-29.
|
||||||
The interlingua spec has two layers. The **schema** is Rust types: packet
|
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.
|
families, node ids, role slots, value kinds. It changes rarely and by commit.
|
||||||
The **inventory** is data in CultCache: the concepts, frames, roles, relation
|
The **inventory** is data in CultCache: the concepts, frames, roles, relation
|
||||||
types, speech acts and nuance features (hedge, certainty, warmth, irony and
|
types, speech acts and nuance features (hedge, certainty, irony, stance affects and restraint, and
|
||||||
whatever the loop finds missing) that the schema's slots name by id. Every open slot in a packet is an inventory id; a referent names an entity by id
|
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:
|
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
|
what each model call may see, and what a score may count, own that
|
||||||
|
|
@ -31,17 +31,18 @@ means no store holds it and nothing writes it.
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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 |
|
| 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 |
|
| Schema version | `weksa.interlingua_packet.v<N>` constant in `crates/weksa`; published JSON Schema at `schemas/cultnet/` | Bumped by a commit that changes the Rust types; the published schema is regenerated in the same commit and a test fails on drift | A cut (Hands, under a spec); never the loop |
|
||||||
| Inventory entry | Key `<family>:<n>`, `n` canonical decimal >= 1 (families: concept, frame, role, relation, speech_act, feature, referent_kind, extension); type `weksa.inventory_entry`, with a bounded gloss, value kind (option labels bounded), the slots a feature may sit in (`Discourse`, `Referent`, `Predication`, `Relation`; `Stance` from cut `speaker-state`, `Speaker` and `Scene` from cut `speaker-scene`), `introduced_rev` and optional `retired_rev`. Speaker axes, scene relations and affects are features of those slots, so the loop grows them like any closed-class family and convergence covers them | Added or retired by an inventory revision; never edited in place (a changed meaning is a retire plus an add); a retired id still resolves for packets of earlier revisions | The loop's accepted rounds, whose minting call the context builder assembles from Train items only; Self on merge to main. Admission checks form, not text |
|
| Inventory 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; `weksa inventory admit` for one model-drafted `EditDraft` outside a run (cap 256, reviewed whole in its cut; same numbering and admission check as the loop); Self on merge to main. `data/original` revs 5 (233 concepts and frames) and 6 (five concepts closing named gaps) were drafted by claude-sonnet-5-5 (Weksa Hands, model-drafted original-bed lexicon from Train and Validation passages); Validation-informed: numbers on this bed measure lowering, never generalization or convergence. Residue after rev 6, not amended (each fresh coverage reader names new gaps; the loop's growth path owns them): simultaneity (ながら), distal deixis (あの), 'first' (まず), counterfactual mood, 'at least', emphatic question; heaven versus sky for the visible 天 stays weak. 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 |
|
| Inventory revision | Key `rev:<n>`, type `weksa.inventory_revision`, with parent, change list and `frozen` | Appended per accepted round; exactly one revision is ever marked frozen, by the convergence test | The loop's accept rule appends; the convergence test freezes; Self records the freeze as a ruling |
|
||||||
| Target profile | Locale or language-project id (`en-US`, `ja-JP`, a conlang's slug) plus a revision `profile:<language>:<n>` | Authored, revised by commit or by an accepted round that changed the lowering; each lowering names the revision it used | Profile authors and accepted rounds; never the lowerer's defaults (`persona-drives-register`) |
|
| Target profile | Locale or language-project id (`en-US`, `ja-JP`, a conlang's slug). Until entries grow in rounds there is no profile revision document: a run names the profile it lowered with by the ontology store's path and digest in its `RunRecord` (cut `language-ontology`); `profile:<language>:<n>` revision documents arrive with follow-up `language-ontology-growth` | The ontology store is revised by commit; a run pins it and refuses drift; it likewise pins the speaker-state store (`RunRecord` keys 17 and 18, `Drift(SpeakerState)`, cut `language-ontology-scene`) | 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`) |
|
| Language ontology entry (cut `language-ontology`) | Key `onto:<language>:<n>`, `n` from 1 per language; type `weksa.ontology_entry` in `data/ontology/ontology.cc`: the language, the revision of the inventory it was admitted through (provenance: beds number revisions independently and a run reads each condition's feature at its own head), a layer (`Lexicon`, `Grammar`, `Discourse`, `Phonology`, `Orthography`, as the door spells them; the mint prompt ends with an `example` line serialized from the draft type itself), at most four conditions (a feature id of slot `Stance`, `Discourse` or `Scene` with `AtLeast`/`AtMost`/`AtMostOrUnset` a grade or `Is` an option or flag; a `Scene` condition reads the relation of the item's speaker to its addressee that `with_scene` selected; slot `Speaker` and community ids wait on follow-up `language-ontology-communities`), a choice of 1 to 200 bytes, at most three examples of 1 to 120 bytes, and an `Annotator`. Whether an entry's text keeps politeness on the scene is a model reader's judgment, owed per change (a mint owes it under `language-ontology-growth`), not a test | Admitted in all-or-nothing batches through `OntologyStore::admit`, immutable, never edited; retire arrives with `language-ontology-growth`. The run pins the store like the entity store (path and digest, `Drift(Ontology)`) and never writes it; slice 1 of growth is outside any run: `weksa loop mint-ontology` drafts entries from the run's Train items of one language into `<root>/ontology-mint/<digest>.answer`, `weksa ontology admit --ontology <copy>` admits them into a file copy, and a new run pins the copy (a run-owned copy waits on follow-up `language-ontology-growth`). Cut `language-ontology-seed` converts by drafting, not by parse: a fresh model drafts each language's seed from that language's YAML and the inventory's feature lines with no corpus text, then deletes `data/target-language-ontology/` and `.weksa/cultural-ontology/` in the same cut | Seed: a labelled model (`Annotator::Model`), no operator gate, never Test evidence (`ja-ontology-owner-answer`); attestation (operator, native reader) appends later. Accepted rounds later, through a mint call from Train items only. Selection is `ontology::select(ontology, language, view)`, called only inside `Prompt::lower`, so no caller picks entries; the prompt prints each selected entry's id, layer, choice and examples. Its text is minted text for the leak diagnostic, which today has no runtime caller (follow-up `leak-diagnostic-in-round`). The store was rebuilt without ja-JP `:14` and `:15` (above-status Scene entries; a paired measure found them net-negative on register, follow-up `ja-scene-entries-lower-register-agreement`), so ja-JP ends at `:13`: an above-status out-group addressee, and an above-status in-group addressee below intimacy 700, select no ja-JP Scene entry (`:12` still selects in-group at intimacy 700 or more regardless of status) until a native reader attests a replacement (`ja-native-reader-scenes`). ja-JP `:5`, `:7` and `:8` were revised by certainty and restraint `AtMostOrUnset` conditions (holds when the feature is unrated or at most the grade; `AtMost` still fails on an unrated feature) in a store rebuild (the store has no retire, so a conditions change is a rebuild), which makes ja-JP's hedging, certainty, affection, restraint and sadness entries disjoint on `:5`/`:6`, `:4`/`:7`, `:6`/`:7`, `:6`/`:8`, and ja-JP `:3` (anger) carries `AtMostOrUnset` hedge and affection bounds, as en-US `:3`, which makes `:3`/`:5` and `:3`/`:7` disjoint; en-US `:3` carries `AtMostOrUnset` hedge and affection bounds, `:5` an `AtMostOrUnset` certainty bound (the clause it held in prose) and `:8` one too (as ja-JP `:7`), which makes `:3`/`:5`, `:3`/`:8`, `:5`/`:6` and `:6`/`:8` disjoint, while `:6`/`:10` stay co-selectable and are kept apart by scoping in `:6`'s text; ja-JP `:9` was reworded in a rebuild to state its question ending as available, so `:6`/`:9`, `:5`/`:9` and `:3`/`:9` stay co-selectable and are reconciled by wording, not by condition |
|
||||||
| 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`) |
|
| Community (cut `language-ontology`, follow-up `language-ontology-communities`) | Key `community:<slug>`; type `weksa.community`, built fresh when the cut is mapped: `data/cultural-ontology/` (draft YAML profiles, read by no code) is deleted from main (cut 5e `delete-cultural-ontology`) and stays in git history before that cut's commit | 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`) |
|
| 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 |
|
| 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 (provenance; a run reads the features at its own head), and an `Annotator` | As Speaker. Keyed by the group, so a scene never straddles the split. `data/original/speaker-state.cc` holds Model-annotated keeper scenes, one per Train or Validation group of both languages (`weksa scene put|dump`); a Test group has none and reads `NoScene` | As Speaker; a model-annotated scene is never Test evidence |
|
||||||
| 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`) |
|
| 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 and who placed its name spans (`names_by`, an `Annotator`, absent when unrecorded; cut `pd-name-spans`). 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 |
|
| 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) |
|
| Loop round | Key `run:<label>:round:<n>`; type `weksa.loop_round` with spec version, profile revisions, model ids and prompt digests, sampled train items, Validation means and refusal counts (from cut `ja-panel-loop`: one set per language of the run's panel, no pooled mean), per-item Validation scores in corpus order (key 22, numbers only, no item key or text, private to `run.rs` for the paired floor of the cycle (rounds 1 to 4) and of relower; a run from before has none and opens), the digest of the inventory file it was scored under and, for a cycle's candidate round, its cycle `k` | Scored and recorded whole; immutable. A candidate round is put in one batch with its Decision. Lives in the run store on Yggdrasil | `run::round` and `run::cycle` (both through `score_round`) |
|
||||||
| 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 |
|
| Loop decision | Key `run:<label>:cycle:<k>`; type `weksa.loop_decision` with the base round and revision, the edit prompt's digest and Train keys, the editor's answer digest, the candidate revision, inventory digest and round, the base, candidate and floor readings (chrF, meaning, composite; the floor is the paired floor of round 1 against rounds 2 to 4), the threshold and the verdict (Accepted, or Rejected for edit-parse, edit-admit, no-gain, regressed chrF, meaning or composite); digests and numbers, no text | Immutable. The latest Accepted one names the run's head inventory (`<root>/cycle-<k>/inventory.cc`, its candidate digest, its candidate round); with none the head is the copy `create` made at `<root>/inventory.cc`. Lives in the run store on Yggdrasil | `run::cycle`'s accept rule, `run::decide`: the validation composite gains more than max(noise floor, `LADDER_STEP`), no metric falls past max(its floor, one step), the composite does not fall past max(its floor, one step), and a non-finite reading rejects (the one regression test, `run::regression`); differences compared in billionths so a gain of exactly one step is a tie. From cut `ja-panel-loop` the readings, floor and threshold are kept per panel language and `run::regression` runs per language and is the one regression test (`decide` adds the gain test for a one-language panel): any language regressing rejects (verdicts `regressed-chrf`, `regressed-meaning`, `regressed-composite`), otherwise a one-language panel accepts as `decide` does and a panel of two or more accepts when the sum of composite deltas exceeds one `LADDER_STEP` (billionths). Given up: strict Pareto non-regression; a language may lose up to its own noise floor if the panel nets more than a step, because zero tolerance ratchets every base to a favourable noise draw and strangles the loop. Termination: each accept raises the composite sum by more than a step and composites lie in -0.5 to 1, so a run accepts at most 1.5 * languages / `LADDER_STEP` edits |
|
||||||
|
| 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. Lands with follow-up `golf-loop-score-docs`, in the round's directory; nothing in the cycle reads them |
|
||||||
| 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 |
|
| 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 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 |
|
| 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 |
|
||||||
|
|
@ -133,8 +134,9 @@ standing.** `gamecult.persona_state.v0` (Body facts) carries open trait maps
|
||||||
So a `PersonaSnapshot` projects mood-like and relation-like values, and the
|
So a `PersonaSnapshot` projects mood-like and relation-like values, and the
|
||||||
stable axes the standard lacks are annotated. Whether the standard gains a
|
stable axes the standard lacks are annotated. Whether the standard gains a
|
||||||
typed speech-community reference or Weksa keeps the persona-to-community
|
typed speech-community reference or Weksa keeps the persona-to-community
|
||||||
binding (`data/cultural-ontology/persona-stacks/`) is decided when
|
binding (the deleted `data/cultural-ontology/persona-stacks/brokkr.yaml` was its one draft) is question `persona-speech-community-home` (recommended: a Weksa-owned
|
||||||
`live-lowering` is mapped (follow-up `persona-state-speech-community`); the
|
binding; follow-up `persona-state-speech-community`), decided when
|
||||||
|
`live-lowering` is mapped; the
|
||||||
corpus path needs neither. Putting Weksa feature ids into the open
|
corpus path needs neither. Putting Weksa feature ids into the open
|
||||||
`voiceStyle` trait map is rejected: it would make an extension field portable
|
`voiceStyle` trait map is rejected: it would make an extension field portable
|
||||||
authority, which the standard forbids.
|
authority, which the standard forbids.
|
||||||
|
|
@ -202,9 +204,12 @@ operator-attested annotations alone. Who annotates is question
|
||||||
text against held-out items; novel coverage pooled per order, for n = 1, 2
|
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
|
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
|
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
|
worst item at n >= 2. A word is a run of letters, digits, marks and
|
||||||
transitions as well as UAX #29 boundaries, so a snake_case `OptionLabel`
|
joiners; any other character, and a lower-to-upper case transition, ends it,
|
||||||
reads as words. Honest glosses of single words saturate n = 1 (the 54 seed
|
so `kick:the:bucket` and a snake_case `OptionLabel` read as words. Han, kana,
|
||||||
|
Thai, Lao, Khmer and Burmese write no word boundaries and the scorer holds no
|
||||||
|
dictionary, so a run of them is one word (per-character words would give
|
||||||
|
every honest gloss shared bigrams); a leak there shows at n = 1 and in chrF. 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.
|
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
|
Costs: a live persona mints before it names (one typed write, which also
|
||||||
buys coreference and one rendering per language); public-domain intake
|
buys coreference and one rendering per language); public-domain intake
|
||||||
|
|
@ -256,10 +261,11 @@ operator-attested annotations alone. Who annotates is question
|
||||||
with the same text and a different source returns `Unchanged` and writes
|
with the same text and a different source returns `Unchanged` and writes
|
||||||
nothing, so a relabel through `add` silently does nothing. `key_slug`
|
nothing, so a relabel through `add` silently does nothing. `key_slug`
|
||||||
parses `n` with `u32::parse`, which accepts `01` and `+1`.
|
parses `n` with `u32::parse`, which accepts `01` and `+1`.
|
||||||
- Inventory rev 1 (seed): features 6-11 are hedge, certainty, warmth,
|
- Seed inventory (2026-11-10, base `5e10d7c`): features 6-8 are hedge, certainty
|
||||||
formality, intensity (Grade) and irony (Flag), slots Discourse and
|
and irony; 9-18 are the stance affects and restraint (slot Stance). The
|
||||||
Predication. Nothing outside the seed and `docs/interlingua-standard.md:179-180`
|
committed inventory holds revisions 1-2 only. Nothing calls `with_scene`
|
||||||
names warmth, formality or intensity; no committed packet store exists.
|
except tests, and the proof packet's addressee carries no entity (its
|
||||||
|
referent `n3` does: `entity:nibu`).
|
||||||
- Yggdrasil's Ollama (`127.0.0.1:11434`) is the Docker container `ollama-cpu`,
|
- Yggdrasil's Ollama (`127.0.0.1:11434`) is the Docker container `ollama-cpu`,
|
||||||
Huginn's embedder (runbook `gamecult-ops/runbooks/huginn-yggdrasil.md`,
|
Huginn's embedder (runbook `gamecult-ops/runbooks/huginn-yggdrasil.md`,
|
||||||
ruling `eureka-body:ruling:huginn-embedder-yggdrasil-primary`). `/api/embed`
|
ruling `eureka-body:ruling:huginn-embedder-yggdrasil-primary`). `/api/embed`
|
||||||
|
|
@ -303,6 +309,27 @@ line two", "その 制御" against the
|
||||||
or socioeconomic standing; `extensions` are non-authoritative by the
|
or socioeconomic standing; `extensions` are non-authoritative by the
|
||||||
schema's own description.
|
schema's own description.
|
||||||
|
|
||||||
|
- Probes of 2026-11-12 at `d9ebbcf` (map pass for cut 5): no code reads
|
||||||
|
`data/target-language-ontology/`, `data/cultural-ontology/` (since deleted) or
|
||||||
|
`.weksa/cultural-ontology/starter-targets.cc` (a third, typed copy of en,
|
||||||
|
ja-JP and pt-BR profiles); `README.md`, `docs/multilingual-utterance-lowering.md`,
|
||||||
|
`examples/cultcache-lowering/` and `prompts/nibu-cultcache-target-*.md` cite
|
||||||
|
them. `LoweringInput::with_scene` has no caller in `run.rs` or `main.rs`, the
|
||||||
|
run pins no `SpeakerStateStore`, and no Speaker or Scene document is committed
|
||||||
|
(`speaker-scene-seeds` open), so only the packet's `Stance` and `Discourse`
|
||||||
|
features reach a loop's lowering view. `score::leak_diagnostic` and
|
||||||
|
`TestReadings::at_checkpoint` take minted text as `&[&str]` and have no
|
||||||
|
caller outside tests: no round reads glosses or any minted text against
|
||||||
|
Validation. `data/daodejing/inventory.cc` is a revision prefix of
|
||||||
|
`data/original/inventory.cc` (revisions 1 to 4 equal, a test proves it; revs 5 and 6 add the bed's own lexicon); with `data/interlingua/inventory.cc` they share revision 1,
|
||||||
|
where features 6 to 8 (hedge, certainty grades, irony flag) sit in
|
||||||
|
`Discourse` and `Predication` and features 9 to 18 (anger to restraint,
|
||||||
|
grades) in `Stance`. `PROMPT_SOURCE_DIGEST` covers `context.rs`, `split.rs`,
|
||||||
|
`lower.rs` and `packet.rs`. 25 `RunPaths {` literals across `run.rs`,
|
||||||
|
`context.rs` doctests, `main.rs`, `tests/common/mod.rs`, `tests/golf_loop.rs`
|
||||||
|
and `tests/original_corpus.rs`; 10 `Prompt::lower(` calls. `huginn known`
|
||||||
|
timed out (Unavailable, 15 s deadline) twice this pass.
|
||||||
|
|
||||||
## Where things live and run (`rust-single-crate`, `loop-runs-on-yggdrasil`)
|
## Where things live and run (`rust-single-crate`, `loop-runs-on-yggdrasil`)
|
||||||
|
|
||||||
- Code: one Cargo workspace at the repo root, one package `crates/weksa` (a
|
- Code: one Cargo workspace at the repo root, one package `crates/weksa` (a
|
||||||
|
|
@ -313,9 +340,14 @@ line two", "その 制御" against the
|
||||||
- Stores: `data/interlingua/inventory.cc` and `data/corpus/corpus.cc` in the
|
- Stores: `data/interlingua/inventory.cc` and `data/corpus/corpus.cc` in the
|
||||||
repo (small, reviewed with `weksa dump`); run stores (rounds, lowerings,
|
repo (small, reviewed with `weksa dump`); run stores (rounds, lowerings,
|
||||||
scores) on Yggdrasil under the loop's state root, not in git.
|
scores) on Yggdrasil under the loop's state root, not in git.
|
||||||
- The loop runs on Yggdrasil as a detached unit with a log and a status record.
|
- The loop is a batch command driven from a Claude Code session on Yggdrasil
|
||||||
Starfire runs none of it. Model calls go through a `ModelPort`; its first
|
(`golf-loop-in-claude`), not a detached unit. Model calls go through a
|
||||||
adapter drives `claude -p` on the operator's subscription. Never OpenAI
|
`ModelPort` whose one adapter, `AnswerFiles`, writes a `.prompt` file and reads
|
||||||
|
a `.answer` file: Claude agents answer them, one fresh subagent per encode
|
||||||
|
prompt (`encoder-isolation-subagent-per-prompt`) and a tool-less relay for
|
||||||
|
lowering prompts (`lowerer-call-reach-toolless-relay`). Builds, stores, scoring
|
||||||
|
and embeddings run on Yggdrasil; Starfire runs none of it, and Weksa needs no
|
||||||
|
claude binary or token there. Never OpenAI
|
||||||
(`eureka-body:ruling:no-return-to-openai-supplier`), never Discord data
|
(`eureka-body:ruling:no-return-to-openai-supplier`), never Discord data
|
||||||
(`gamecult-chat:ruling:discord-taints-training`).
|
(`gamecult-chat:ruling:discord-taints-training`).
|
||||||
|
|
||||||
|
|
@ -328,56 +360,271 @@ 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 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.
|
the training set is the whole corpus re-encoded under the frozen revision.
|
||||||
|
|
||||||
## Lowerer first cut (`golf-loop-lowerer-cut`)
|
## Distil measure (`distil-slice`)
|
||||||
|
|
||||||
Authority map for the smallest lowerer, written before its code (Imagination
|
A lowerer is compared on round n's Validation packets with the prompts
|
||||||
pass by the Weksa Self run, 2026-11-01). The cut exists to give the golf loop a
|
byte-identical to the round's, by `weksa loop relower ROOT --round N --arm ARM
|
||||||
measurable baseline from a packet: `lower` gives text, `score` gives numbers.
|
--lowerer LABEL [--floor ARM2 --floor ARM3 --floor ARM4]`. Every arm is answered through the one prompt
|
||||||
|
door (`RunStores::answer_files`, the Test-text guard); a tuned model is a script
|
||||||
|
that reads `<digest>.prompt` and writes `<digest>.answer` in
|
||||||
|
`<ROOT>/relower-<N>-<ARM>`, not a new adapter. The recorded reading is the
|
||||||
|
round's own lowering, read over the same items as the arm. An item the round
|
||||||
|
refused before admission (no packet) or at the lowering prompt has no prompt to
|
||||||
|
ask: it is skipped, so the readings exclude it, and it is counted as `skipped=`
|
||||||
|
on the line; `n` is the items read, so `recorded` can differ from the round's
|
||||||
|
own Validation reading, which scores those items 0. An arm answer that is not a
|
||||||
|
usable lowering scores 0 and counts in `unusable=`; the same for the floor arm,
|
||||||
|
counted (over all floor arms) after `floor ... unusable=`. The floor needs three
|
||||||
|
or more floor arms, each a prompted repeat of the recorded lowering on the same
|
||||||
|
prompts: it is `FLOOR_Z` (3) standard errors of the mean per-item difference
|
||||||
|
between the recorded lowering and a repeat, per metric, with the variances
|
||||||
|
pooled over the arms (`paired_floor`; lowering-only noise, no encoder noise). The
|
||||||
|
verdict is `decide_panel` over (recorded, arm, floor): Accepted is a composite
|
||||||
|
gain past the noise of the panel's sum (the root of the languages' composite
|
||||||
|
floors squared, at least one step), Rejected(Regressed) a loss past a language's
|
||||||
|
floor, Rejected(NoGain) neither. The cycle's own floor is the same `paired_floor`
|
||||||
|
(`floor_of`): round 1 (the recorded lowering, the head's establishing round)
|
||||||
|
against rounds 2 to `FLOOR_ARMS_MIN` + 1 (prompted repeats under the run's first
|
||||||
|
inventory), per panel language, from the rounds' per-item Validation scores. It
|
||||||
|
is measured once per run and is the same after an accept; a run needs four
|
||||||
|
`loop round` calls before its first `loop cycle`.
|
||||||
|
Calibration (`runs/distil-base`, 8 prompted draws by one model family on
|
||||||
|
identical prompts, no draw sharing more than 7 of 28 answers with another; 14
|
||||||
|
en-US and 14 ja-JP Validation items; every base, candidate and 3-arm floor
|
||||||
|
combination, 1120 trials from 28 unordered pairs; intervals bootstrap over
|
||||||
|
draws, each degraded arm one fixed draw): a prompted repeat reads NoGain 92%
|
||||||
|
(80 to 100), Regressed 8% (0 to 20), Accepted 0%, which with 28 pairs bounds
|
||||||
|
false-accept only to about 10%; an all-"Yes." arm reads Regressed 100%; a mild
|
||||||
|
degradation (one detail blurred per utterance) 100%; a subtler one 75% (50 to
|
||||||
|
100), whose mean composite drop (0.044) sits under the mean floor (0.060).
|
||||||
|
`FLOOR_Z` was chosen on this data (2.5 gives NoGain 84% and 93% on the subtler
|
||||||
|
arm), so the rates are in-sample. Without the noise term in the gain test a
|
||||||
|
prompted repeat reads Accepted in 40% of the trials.
|
||||||
|
Power (can the rule Accept a real gain?): the only better arm that could be
|
||||||
|
built without Test or Validation answers is a lowerer shown the Train sentences
|
||||||
|
of the corpus (no packets, no Validation or Test text) as a register guide.
|
||||||
|
Four such draws beat the prompted draws' mean composite in both languages (en-US
|
||||||
|
0.742 to 0.759 to 0.795; ja-JP 0.559 to 0.578 to 0.620), so the premise that it is
|
||||||
|
better is measured on the very Validation items the rule reads, not independent.
|
||||||
|
Against the prompted bases with 3-arm floors the rule reads it Accepted in 29%
|
||||||
|
of 1120 trials, NoGain 71%, Regressed 0%: a net gain of 0.079 against a mean
|
||||||
|
noise term of 0.079. At 14 items per language the rule is therefore a low-power
|
||||||
|
filter for a gain of this size; by 1/sqrt(n), about 35 or more items per
|
||||||
|
language would bring the noise term near 0.05 (an extrapolation, not measured).
|
||||||
|
### Validation growth (`validation-growth`, built and recalibrated)
|
||||||
|
|
||||||
|
Power needs about 35 or more Validation items per language. `data/original`
|
||||||
|
now holds 35 per language (it held 14; Train 14 and Test 8 are unchanged):
|
||||||
|
42 model-written items, `item:vgrow:1..21` (en-US) and `item:vgrow-ja:1..21`
|
||||||
|
(ja-JP), live Train+Validation 56 to 98. The slug is new on purpose: the
|
||||||
|
`item:original:` prefix filter of `DIGEST_OF_THE_28` still selects the first 28
|
||||||
|
and passes; `DIGEST_OF_THE_42` freezes the new prose.
|
||||||
|
|
||||||
|
The split is derived from an item's group alone (`SplitScheme::V1`: FNV-1a of
|
||||||
|
`weksa-split-v1:<group>` mod 100, under 40 Train, under 80 Validation, else
|
||||||
|
Test). The names were chosen before any text existed: 90 candidate nouns per
|
||||||
|
language, dropped by script when the group already existed or the noun
|
||||||
|
occurred in a Test text, then the first 24 whose bucket fell in 40..=79 (the
|
||||||
|
bucket came from `SplitScheme::V1` itself, cross-checked on the 72 existing
|
||||||
|
items). Each language was written by three blind writer agents (8 nouns each,
|
||||||
|
a brief of language, register note, noun and speech act, no Test text and no
|
||||||
|
existing item), 24 texts per language written, the first 21 in name order
|
||||||
|
admitted through `weksa corpus add`; each group is its own group. The scenes
|
||||||
|
are `weksa scene put` documents (keeper to `unnamed`, inventory rev 4),
|
||||||
|
annotated by a separate agent; a second annotator measured agreement only and
|
||||||
|
is not committed.
|
||||||
|
|
||||||
|
The writer is Claude (family Anthropic recorded): no cross-family generator was
|
||||||
|
reachable from the body that ran this cut. The self-preference effect (a Claude
|
||||||
|
lowerer favouring Claude prose, Panickssery et al. 2024) is therefore not
|
||||||
|
measured. The writers' blindness is by brief content, not sandbox.
|
||||||
|
|
||||||
|
Expect encode refusals: the existing 56 were written onto the Dao r3 concepts
|
||||||
|
and the inventory was drafted from them, while the new items were written free
|
||||||
|
of the inventory (question `validation-growth-vocabulary` stays open). Some new
|
||||||
|
packets will be refused at encode (inventory coverage) and score 0 as the means
|
||||||
|
do; they stay in the corpus. That is a result about inventory generalization,
|
||||||
|
not a defect.
|
||||||
|
|
||||||
|
Every calibration number in the Distil measure section above (92/0/8 prompted
|
||||||
|
rates, the 29% power, the 0.079 noise term) is for the 14-item corpus and stays
|
||||||
|
the stated baseline of that corpus.
|
||||||
|
|
||||||
|
Recalibration on the grown corpus (`runs/growth-base`, main 44b66f9, panel
|
||||||
|
en-US and ja-JP, round 1 recorded by briefed subagents; 6 prompted draws
|
||||||
|
including the recorded one, 2 mild degradations, an all-"Yes." control and 4
|
||||||
|
Train-guide draws by Claude sonnet-5-5; the same decision rule at FLOOR_Z 3 and
|
||||||
|
3-arm floors; 120 prompted trials from the 30 ordered pairs of 6 draws with the
|
||||||
|
other 4 as floor candidates, so trials share draws and are not independent).
|
||||||
|
Soul pass `cut-validation-growth.s3` re-derived every number below; its
|
||||||
|
findings shaped the wording.
|
||||||
|
|
||||||
|
- Live Validation items: 28 en-US and 32 ja-JP of 35 each; 10 are refused at
|
||||||
|
parse (en-US original:18, 23, 25, 27 and vgrow:4, 5, 6; ja-JP original-ja:17,
|
||||||
|
22 and vgrow-ja:2); `relower` skips an item with no packet, so every reading
|
||||||
|
here is over live items only. Old 14: 10 en-US and 12 ja-JP live; new 21: 18
|
||||||
|
and 20.
|
||||||
|
- Prompted repeat: NoGain 96.7%, Accepted 3.3%, Regressed 0% (14 items: 92 / 0 /
|
||||||
|
8). All four false accepts are one pair, base p6 and candidate the recorded
|
||||||
|
lowering R: R comes from the round, the other five draws from `relower`, and
|
||||||
|
in en-US R scores +0.024 per item above them [+0.002, +0.052], so R is not
|
||||||
|
exchangeable with a repeat (a Plausible Soul finding, cause not isolated). No
|
||||||
|
false-accept bound can be drawn from 6 draws.
|
||||||
|
- Degraded arms read Regressed: all-"Yes." 100% (as before); mild-1 31.7%;
|
||||||
|
mild-2 0% (14 items: 100% and 75% on a subtler arm). The mild arms are weak
|
||||||
|
and not independent: they were rewritten from R with one detail blurred, the
|
||||||
|
original wording is not on disk, mild-2 equals R word for word in 5 of 60
|
||||||
|
items and mild-1 in 2, and mild-1 equals mild-2 in 27 of 60 although they
|
||||||
|
were asked to blur different details. Their mean composite is 0.003 to 0.015
|
||||||
|
below the prompted mean (the old subtler arm: 0.037 and 0.052). The
|
||||||
|
sensitivity to a small regression is not re-measured.
|
||||||
|
- Noise: the panel noise term of the sum rule is 0.045 on average over the base
|
||||||
|
and floor choices (0.031 to 0.064), against 0.079 at 14 items. The 1/sqrt(n)
|
||||||
|
forecast (about 0.05) assumed per-item variance stayed the same; it did not:
|
||||||
|
the per-item SD of the difference between two prompted draws is about 0.085
|
||||||
|
on the old items and 0.034 on the new ones, so the pooled term landed near
|
||||||
|
the forecast because less variable items were added. Not a confirmation of
|
||||||
|
the forecast.
|
||||||
|
- Power: the Train-guide arm (14 Train sentences as a register guide) is
|
||||||
|
Accepted in 21.2% of 240 trials, NoGain 78.8%, against 29% at 14 items, and
|
||||||
|
its mean composite gain over the prompted draws is 0.0185, 0.41 of the mean
|
||||||
|
noise term (0.37 on the first floor choice per base); with `relower`-only
|
||||||
|
bases (R removed) it is Accepted in 35% (28 of 80). Split by item class
|
||||||
|
(paired bootstrap over items, 95%): old items +0.029 [+0.005, +0.052] (en-US
|
||||||
|
+0.049 [+0.023, +0.084] on 10 items, ja-JP +0.013 [-0.021, +0.037] on 12);
|
||||||
|
new items -0.002 [-0.011, +0.007] pooled, but two opposite effects: en-US
|
||||||
|
-0.017 [-0.027, -0.007] on 18 items and ja-JP +0.011 [+0.001, +0.022] on 20.
|
||||||
|
So the old-versus-new contrast exists in en-US; in ja-JP old (+0.013) and new
|
||||||
|
(+0.011) do not differ. Measured: the 14-item gain does not carry to the new
|
||||||
|
en-US items. Inferred, not isolated: that it was in-sample register fit. The
|
||||||
|
new items differ from the old in several ways at once (written free of the
|
||||||
|
inventory, longer, a different scene convention, lower scores, a smaller
|
||||||
|
vocabulary overlap with Train in en-US: 0.29 against 0.52) and were briefed to
|
||||||
|
the same register as Train, by Claude as the old were. The 29% power figure
|
||||||
|
stays the stated baseline of the 14-item corpus; no better arm is known on
|
||||||
|
the new items, so the rule's power against a real gain is not re-measured.
|
||||||
|
- The two classes are different strata (Soul s2 `register-stratum-shift`): the
|
||||||
|
new items score lower (composite 0.41 against 0.64 pooled) so a pooled
|
||||||
|
60-item mean mixes them; report old and new separately.
|
||||||
|
- The self-preference effect is not measured (ruling
|
||||||
|
`validation-growth-writer-claude-checked`). The new scenes use a different
|
||||||
|
`in_group` convention from the old ones (follow-up
|
||||||
|
`vgrow-scene-in-group-convention`), so the new-item scores are for scenes that
|
||||||
|
will be re-annotated; the speaker-state store changes then (a run pins its
|
||||||
|
digest), the corpus does not.
|
||||||
|
|
||||||
|
For `growth-base`, `weksa loop calibrate` (`docs/calibration.md`) reproduces from `run.rs`'s own
|
||||||
|
scoring and accept rule the counts per class, the trial totals and the mean
|
||||||
|
noise of this block: prompted 116 NoGain, 4 Accepted, 0 Regressed of 120;
|
||||||
|
control-yes 60 of 60 Regressed, mild-1 19 of 60, mild-2 0 of 60; the
|
||||||
|
Train-guide arm 51 Accepted, 189 NoGain, 0 Regressed of 240; noise 0.0451;
|
||||||
|
items en-US n=28 skipped=7, ja-JP n=32 skipped=3. By class it adds: old items
|
||||||
|
(`item:original*`) prompted 111/9/0, noise 0.1069, Train-guide 71 Accepted of
|
||||||
|
240; new items (`item:vgrow*`) prompted 116/4/0, noise 0.0334, Train-guide 0
|
||||||
|
Accepted and 74 Regressed of 240. The other figures of this block (35% with R
|
||||||
|
removed, the noise range, the 0.0185 gain and 0.41 ratio, the bootstrap
|
||||||
|
intervals, the per-item SDs, R's +0.024, the mild-arm duplicate counts and
|
||||||
|
mean composite offsets, the Train-vocabulary overlap, the old/new class
|
||||||
|
composites, the ten parse-refused item ids) were
|
||||||
|
computed by scratch scripts outside git (Soul finding `evidence-outside-git`)
|
||||||
|
and are not reproduced by the harness.
|
||||||
|
|
||||||
|
Recalibration on the relabelled scenes (`runs/growth-scene`, main d8afc4a; same
|
||||||
|
panel, 35 live items per language, 0 refused, 5 prompted draws including the
|
||||||
|
recorded one R, 2 mild, 4 Train-guide). It is not a replacement for the
|
||||||
|
`growth-base` block above and no delta against it is drawn: the item set, the
|
||||||
|
packets and the scenes changed, and encode was redrawn (the 98 encode prompts
|
||||||
|
are byte-identical to growth-base, no encode answer is; the refusals fell from
|
||||||
|
10 to 0 on the redraw, not through the scenes, which reach only the lowering
|
||||||
|
prompts). Soul `cut-calibrate-harness.s3` re-derived all 33 counts and 3 noise
|
||||||
|
terms below from the per-item answers without calling `calibrate`; they match.
|
||||||
|
|
||||||
|
Measured, and reproduced by `weksa loop calibrate` (trials: prompted 120, each
|
||||||
|
mild 60, Train-guide 240; Accepted / NoGain / Regressed):
|
||||||
|
|
||||||
|
| class | prompted | noise | mild-1 Regressed | mild-2 Regressed | Train-guide |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| all (35 per language) | 11/100/9 | 0.0392 | 51 | 58 | 85/35/120 |
|
||||||
|
| old (14) | 0/105/15 | 0.0844 | 60 | 60 | 40/80/120 |
|
||||||
|
| new (21) | 10/97/13 | 0.0340 | 3 | 4 | 50/129/61 |
|
||||||
|
|
||||||
|
Measured by Soul scratch scripts outside git (not reproduced by the harness,
|
||||||
|
except the R split in the first bullet, which `calibrate` prints as
|
||||||
|
`prompted-r-candidate`, `prompted-r-base` and `prompted-r-free` since the
|
||||||
|
`calibrate-report-r-involvement` cut; Soul re-ran growth-scene with it and the
|
||||||
|
printed `all` lines read r-candidate 11/9/0, r-base 0/11/9, r-free 0/80/0, as
|
||||||
|
below, in Accepted/NoGain/Regressed; likewise the per-arm Train-guide counts
|
||||||
|
(`candidate=h1..h4`) and the mild arms' composite offsets from the prompted
|
||||||
|
mean, R included (`offset`), which `calibrate` prints since
|
||||||
|
`calibrate-arm-counts-and-mild-offsets` and which reproduce the figures below,
|
||||||
|
Soul re-run on growth-scene):
|
||||||
|
|
||||||
|
- Every one of the 11 false accepts has R as the candidate (bases
|
||||||
|
prompted-2, p4, p6); every one of the 9 false rejects has R as the base. The
|
||||||
|
80 trials without R read 0 Accepted and 0 Regressed in every class (95% upper
|
||||||
|
bound 4.5%). R scores above the other prompted draws (+0.012 en-US and +0.023
|
||||||
|
ja-JP on all items), so R is not exchangeable with a repeat.
|
||||||
|
Which of the two rates the live rule should be held to is undecided: the
|
||||||
|
live loop meets R as the head's establishing lowering, so the R-candidate
|
||||||
|
rate may be the real one for cycles, while the 80 R-free trials are the
|
||||||
|
exchangeable repeat rate; neither number stands for the other.
|
||||||
|
- The old/new split of the false-accept lives only in the 20 trials where R is
|
||||||
|
the candidate, from 5 bases: new 10/20 [0.27, 0.73], old 0/20 [0.00, 0.17]
|
||||||
|
(Clopper-Pearson); by base 3 of 5 against 0 of 5 (Fisher p 0.17). The
|
||||||
|
10/120 against 0/120 comparison treats dependent trials as independent. The
|
||||||
|
claim that the new items drive the prompted false-accept is not supported;
|
||||||
|
the old items' larger noise term (2.4 times) raises their threshold, which
|
||||||
|
alone can produce the gap.
|
||||||
|
- Mild arms: they overlap 8 of 35 answers (en-US) and 9 (ja-JP) over the limit
|
||||||
|
of 7. They do not degrade the new items (composite -0.004 en-US
|
||||||
|
[-0.021, +0.012], +0.013 ja-JP for mild-1, +0.005 for mild-2); the Regressed counts come from the old
|
||||||
|
en-US items (-0.072). The rule's sensitivity on the new items is unmeasured.
|
||||||
|
- Train-guide pools unlike arms. h1 and h2 Regress in 120 of 120 trials; h3 and
|
||||||
|
h4 gain (Accepted 45 and 40 of 60); on the new items h3 is Accepted 50 of 60
|
||||||
|
and h4 0 of 60. The 35% pooled figure is not a power.
|
||||||
|
|
||||||
|
Extrapolated only: the rule's power at 35 items against any real gain beyond
|
||||||
|
h3 and h4, and any false-accept bound on repeat draws (5 draws, one of them R).
|
||||||
|
|
||||||
|
### Smallest measurable distil slice (`distil-incontext`, not built)
|
||||||
|
|
||||||
|
Distil is a tuned lowerer measured against the prompted one; the tuned model
|
||||||
|
needs a host (follow-up `distil-host-question-owed`). The smallest slice that
|
||||||
|
measures whether Train pairs buy a gain with the rule that exists is
|
||||||
|
training-free: an in-context arm, the prompted lowerer given Train
|
||||||
|
(packet, text) pairs as few-shot examples, answered through the one prompt door
|
||||||
|
and read by `loop relower` against three prompted repeats. Parts: 12b's
|
||||||
|
Train-only export (follow-up `distil-export-at-freeze`, guarded against Test
|
||||||
|
text) as the few-shot source; the arm's directory of answers; the existing
|
||||||
|
floor and verdict. A weaker version ran on 14 items: a lowerer shown the 28
|
||||||
|
Train sentences (no packets) beat the prompted draws in both languages and the
|
||||||
|
rule Accepted it in 29% of 1120 trials. The slice is decisive only with the
|
||||||
|
Validation growth above: at 14 items it can only fail to reject. It decides the
|
||||||
|
fine-tune question in one direction: if in-context Train pairs do not clear the
|
||||||
|
floor at 35 items, a tuned model's gain over the prompted one is not
|
||||||
|
measurable with this corpus either.
|
||||||
|
|
||||||
|
Training pairs are Train only, at a frozen revision; the export is 12b.
|
||||||
|
|
||||||
|
## Lowering authorities (`golf-loop-lowerer-cut`, `table-retirement`)
|
||||||
|
|
||||||
|
Authority map of who decides what about a packet's wording. The first cut gave
|
||||||
|
the loop a baseline from an English template table; cut `table-retirement`
|
||||||
|
deleted that table, `lower()` and the CLI lowering command, so no deterministic path
|
||||||
|
produces lowered text and the model's answer to `Prompt::lower` is the only
|
||||||
|
wording owner. `lower.rs` keeps the entity port, `pronounce` and `phones`
|
||||||
|
(`weksa pronounce`).
|
||||||
|
|
||||||
| Decision | Owner | Inputs | Outputs | Not the owner |
|
| 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 |
|
| 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 |
|
| Wording of a scored item in English (golf loop, `golf-loop-model-lowerer`) | the ModelPort's answer to `Prompt::lower` (`context.rs`), read as `LoweringDraft` (`encode.rs`) and held in `run::score_round`'s locals | the admitted view, the inventory at its revision, en-US entity forms | `{"text"}`, scored once, never stored | the corpus item, the scorer, the editor |
|
||||||
| 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 6 concepts and 3 frames `nibu-proof-001` and the consent-gated request packet name | the lowerer |
|
||||||
| 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 |
|
| 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 |
|
| 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:
|
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>...
|
- **`weksa score` is a thin reader of `Scorer::score`**: `weksa score <item>...
|
||||||
[--output FILE]` embeds the output text once against every named item. It
|
[--output FILE]` embeds the output text once against every named item. It
|
||||||
stores nothing (Score documents arrive with cut 7) and gates nothing.
|
stores nothing (Score documents arrive with cut 7) and gates nothing.
|
||||||
|
|
@ -390,18 +637,24 @@ One line per cut; specs exist for the cuts about to run (2d and 4, siblings on `
|
||||||
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`.
|
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)
|
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)
|
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)
|
3. Landed at `869c82b`. `scorer`: chrF, meaning cosine behind an `Embedder` port (Ollama adapter with `num_thread`), leak diagnostics, aggregation by derived split (Train and Validation only on the round path; Test only through `TestReadings::at_checkpoint`, which consumes a `Checkpoint`); 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)
|
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`.
|
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)
|
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)
|
||||||
4a. `speaker-relation-input`: slot `Scene` and its first seed features (`relative_status`, `intimacy`, `in_group`; inventory revision 3, `scene_revision`); `LoweringInput::with_scene` takes the speaker's relation to the addressee as typed `Scene` feature values, validated like every feature list, refused without an addressee (V13). The English lowerer does not render it. 4b's `weksa.scene` document is its future supplier; no store, document or CLI yet. (PR for follow-up `chat-s3352-3352`)
|
4a. `speaker-relation-input`: slot `Scene` and its first seed features (`relative_status`, `intimacy`, `in_group`; inventory revision 3, now `speaker_scene_revision`); `LoweringInput::with_scene` carries the speaker's relation to the addressee as typed `Scene` feature values. Since 4b it takes the `SpeakerStateStore` and the `CorpusStore`, reads the scene of the view's own item group for the item's speaker, and selects the relation by the addressee referent's entity (V16 when the scene does not list the item's speaker or the speaker of any live item of the group, the rule `put_scene` applies too; V17 refuses an addressee that names a predication; V13 is retired). The English lowerer does not render it. (PR for follow-up `chat-s3352-3352`)
|
||||||
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.
|
4b. `speaker-scene` (spec r1; `speaker-scene-seeds` is its cut 4c: CLI, inventory revision 3, cast speakers and scenes; `Party::Unnamed` covers an addressee with no entity; Speaker and Scene share one `SpeakerStateStore`): 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. Part 1 landed (branch `weksa-lowering/speaker-scene`): `speaker.rs`, `SpeakerStateStore`, slots `Speaker` and `Scene` exclusive to their own feature lists, `speaker_scene_revision()` (Speaker features 22-24), `with_scene` on the store, schemas. The CLI, the committed inventory revision 3 and the seeds stay with 4c.
|
||||||
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.
|
5. `language-ontology`, in sequential cuts on `d9ebbcf`. 5a `language-ontology` (spec r1): `weksa.ontology_entry`, `OntologyStore`, `ontology::select` over the view's `Stance` and `Discourse` features, `Prompt::lower` printing the selected entries of the item's language, `RunRecord` pinning the ontology store (keys 15 and 16), `weksa ontology admit|dump`; `ontology.rs` joins `PROMPT_SOURCE_DIGEST`. 5b `language-ontology-seed` (spec r1, after 5a): Model-annotated en-US and ja-JP seed entries drafted by an isolated model, `data/target-language-ontology/`, `.weksa/cultural-ontology/` and `schemas/target-language-cultural-ontology.md` deleted. Lost until 5c': keigo, hierarchy and uchi-soto hang on the scene relation from 5c', so ja-JP politeness is still the lowerer's guess. 5c' `language-ontology-scene` (spec r1, after 5b): the run pins the `SpeakerStateStore` (keys 17 and 18, `Drift(SpeakerState)`), `RunStores::assembled` is the one door that reads the item's scene into a loop view, `select` reads slot `Scene`, `Prompt::lower` refuses a view whose scene was never read, `weksa scene put|dump` and `weksa inventory speaker-scene` (rev 4 in data/original and data/daodejing, rev 3 in data/interlingua, which holds no Dao revision), Model-annotated keeper scenes and Scene-conditioned entries for `data/original`. 5c `language-ontology-view-assembly` (follow-up, now only the Speaker read): `select` widens to slot `Speaker`; waits on `speaker-scene-seeds`. 5d `language-ontology-communities` (follow-up): `weksa.community` built fresh (`data/cultural-ontology/` is deleted by 5e), community ids on `weksa.speaker`, community conditions; waits on 5c and question `persona-speech-community-home`.
|
||||||
|
5e. `delete-cultural-ontology` (steward ruling, 2026-11-13): `data/cultural-ontology/` (five draft YAML profile files and a README; `old-norse-edda-source-tradition.yaml` did not parse) deleted. Nothing in `crates/`, tests, scripts or CI read it; `examples/persona-lowering/brokkr-edda-voice/` cites its paths as text only (the one test that reads that example reads `lowered-output.yaml`). The typed `weksa.community` of follow-up `language-ontology-communities` is built fresh under cut 5; follow-up `edda-referent-yaml-parse` is moot.
|
||||||
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`.
|
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`.
|
6a. `pd-name-spans` (spec r1): `weksa corpus import <work> <names>` mints a work's cast as `PublicDomain` entities with forms in the work's language and adds each line a name-span answer covers, speaker from the work file, spans from `[[entity|form]]` markup, labelled `names_by` (Model or Operator) per `docs/name-span-annotation.md`. Speaker and Scene documents, prior turns and fiction speaker attribution stay with 6.
|
||||||
|
7a. `loop-context-builder`: `context.rs` (`TrainItems`, `EncoderOffer`, `Prompt`: corpus text reaches a model call only through `Prompt::mint` over live Train items or `Prompt::encode` over one offered item; instructions are the consts `MINT_INSTRUCTIONS` and `ENCODE_INSTRUCTIONS` in `context.rs`; `Checkpoint` has no public constructor, offers each item once, and its offers borrow it (the gate is typed `TestReadings` and Test offers on the round path; operator tools such as `weksa corpus dump` and `weksa score` still read corpus text of any split); prior turns print the held Train text), `SplitScheme::live`, `CorpusItem::cited_entities` (one owner of the id set `PacketStore::admit` and the offer share), and checkpoint-only Test readings (`Checkpoint`, `TestReadings`; `Aggregate` and `leak_diagnostic` carry no Test). Library only; since 7b `TrainItems::from_corpus` and `EncoderOffer::for_round` are crate-private and `RunStores` is their one public producer. Merges follow-ups `loop-context-builder-2` and `scorer-test-readings-type`. (spec r1)
|
||||||
|
7b. `golf-loop-round` (spec r1; branch `weksa-lowering/golf-loop-round`): the smallest loop that scores one round. `run.rs`: `RunStores` pins the corpus and entity stores of a run by canonical path and FNV-1a 64 digest at `create`, reopens only from `<root>/run.cc` and refuses drift (corpus, entities, `PROMPT_SOURCE_DIGEST` of `context.rs`, a second pin on `lower.rs` retired in 7d, model, embedder); `round` encodes every live Train and Validation item through a `ModelPort` (`model.rs`; the one adapter `AnswerFiles` writes `<digest>.prompt` and reads `<digest>.answer`, so the model is the Claude session), reads each answer into a `PacketDraft` (`encode.rs`, the one place a model's JSON is read), admits through `PacketStore::admit` into `<root>/round-<n>/packets.cc`, lowers, scores once, and keeps a `RoundRecord` (`weksa.loop_round`, no text, no Test; Train rows, Validation only as means and refusal counts). `weksa loop open|round`. Not here: the spec-edit step, accept rule and composite, noise floor, Score and lowering documents, a subprocess adapter, the checkpoint door, a model lowerer (follow-ups `golf-loop-spec-edit`, `golf-loop-accept-rule`, `golf-loop-score-documents`, `golf-loop-subprocess-adapter`, `golf-loop-checkpoint-door-bound`, `golf-loop-model-lowerer`). `packet-store-inventory-binding` is met for loop packets only (`RunStores` admits with the run's inventory); `validate`, `packet proof|dao` and `lower` still take caller stores. Waits on 7a, `checkpoint-gate`, 4b.
|
||||||
|
7c. `golf-loop-edit-accept` (spec r1; branch `weksa-lowering/golf-loop-edit-accept`): one edit-accept cycle. The run works on its own copy of the inventory (`create` copies the caller's file to `<root>/inventory.cc`, refusing a root that already holds a file of that name, and pins its digest as `RunRecord` key 11; `RunRecord.inventory` is provenance; a `run.cc` from before has no key 11 and does not open); the head is derived from the latest Accepted `Decision`. `weksa loop cycle` asks the editor for one inventory edit from `Prompt::mint(train, inventory, rev, feedback)` (Train blocks, the inventory listing and an `EditFeedback` line: whether the last edit was kept and the head's Validation composite rounded to `LADDER_STEP`, the Ladder release of Blum and Hardt 2015), reads it as an `EditDraft` (`encode.rs`; ids and revisions are the store's, `into_revision`), admits it through `InventoryStore::admit_revision` into `<root>/cycle-<k>/inventory.cc`, runs a candidate round under it, and `decide`s on Validation against the head's establishing round past a floor (never under one `LADDER_STEP`; from cut `cycle-floor-paired` the paired floor over rounds 1 to `FLOOR_ARMS_MIN` + 1, so a cycle needs four recorded rounds, where 7c measured the spread between rounds 1 and 2). An edit that fails is a rejected `Decision`, not an error. `RoundRecord` gains keys 16 (inventory digest) and 17 (cycle); an encode answer that is not UTF-8 is that item's parse refusal. Not here: Score and lowering documents (`golf-loop-score-docs`), Train rows in the edit prompt (`golf-loop-edit-train-feedback`), a paired floor (`golf-loop-paired-floor`), the checkpoint door and merging to `data/` (`golf-loop-checkpoint-door-bound`, `golf-loop-freeze-merge`), a model lowerer (landed as 7d: until then an entry an edit added had no lowering row). Waits on 7b.
|
||||||
|
7d. `golf-loop-model-lowerer` (spec r1; branch `weksa-lowering/golf-loop-model-lowerer`): the round lowers through the `ModelPort`. `Prompt::lower` (`context.rs`) takes a `LoweringInput` (no text, key, group or speaker getter), the item's speaker id, the inventory at the view's revision and the item language's entity forms, and prints `LOWER_INSTRUCTIONS`, the meaning of the packet, the entities line and the inventory listing; the item key goes only to `item_keys`, and the speaker id never prints: a referent citing the item's speaker holds the fixed token `entity:speaker` (`SPEAKER_TOKEN`, a constant the same for every speaker and item, never derived from an id) and the entities map holds the speaker's forms under that token. The promise guards the id that names an entity of this item; the speaker's forms and the role token do print, because forms are shared by every item of the language (follow-up `speaker-alias-token-in-lowering-prompt`, which restored the lowerer's ability to name its speaker after #84 removed it). A speaker the language has no form for, or the port lacks, does not refuse: its referents print no entity and the map has no entry. A referent citing the real id `entity:speaker` is refused (`LowerError::SpeakerTokenTaken`); the entity store does not reserve it. The answer is `{"text"}`, read as `LoweringDraft` in `encode.rs`, held in `score_round`'s locals and scored once; it is never put in `run.cc`, a `RoundRecord`, a `Decision` or `EditFeedback`. A round has two answer phases (every encode prompt, then every lowering prompt), all or nothing: while any prompt is unanswered `packets.cc` is discarded and nothing reaches `run.cc`. `lower::lower` and the template table leave the loop (they remain `weksa lower`'s; follow-up `table-retirement`), so an entry an edit adds can be said and a cycle can buy a gain. One source pin remains: `PROMPT_SOURCE_DIGEST` covers `context.rs`, `split.rs` and `lower.rs` (`RunRecord` key 7 and `RoundRecord` key 3 are retired). The run's record pins `LowererReach` (`weksa loop open --lowerer-reach briefed-subagent|tool-less-relay`, `RunRecord` key 12) and every printed round and cycle line says it; a briefed-subagent run says its numbers are smoke-round only (rulings `lowerer-call-reach-toolless-relay`, `dao-round-seed-inventory`). A lowering is refused at `Lower` when blank, over `MAX_LOWERING_BYTES` (4096) or holding a control character other than a line break or tab; `PROMPT_SOURCE_DIGEST` also covers `packet.rs`. Not here: per-item lowering text documents (`golf-loop-score-docs`), the subprocess adapter. Waits on 7c.
|
||||||
|
7. `golf-loop-en` (7b, 7c and 7d landed the round, `ModelPort` with `AnswerFiles`, encoder and lowering prompts, template digest, round records and the edit-accept cycle; the rest remains): the packet store bound to one inventory store everywhere (`packet-store-inventory-binding`), Score documents (`golf-loop-score-docs`, `golf-loop-score-documents`), the accept rule (`golf-loop-accept-rule`), the checkpoint door and freeze merge (`golf-loop-checkpoint-door-bound`, `golf-loop-freeze-merge`). No subprocess adapter and no detached unit: `golf-loop-in-claude` and `encoder-isolation-subagent-per-prompt` rule them out. Waits on 3, 5 and 6.
|
||||||
8. `style-axis`: a style-embedding score behind the scorer port. Waits on 3.
|
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.
|
9. `ja-panel`: ja-JP into the loop's panel, in two cuts on `e9cbd9b`. 9a `ja-panel-loop` (spec r1): language is the item's and the panel the run's: `Prompt::lower` takes the item's language and prints it on a language line (no prompt names English), entity forms are read in that language, `RunRecord` pins the panel (`weksa loop open --panel`), offers, mint and recall read only panel items, round means, floors and readings are per panel language, the accept rule runs `decide` per language, the recall split works on unspaced scripts, and `weksa corpus add` has no default language. 9b `ja-panel-corpus` (spec r1, after 9a): the ja-JP slice of `data/original`, model-written Japanese Train and Validation passages and eight Aozora Bunko public-domain Test sentences with full provenance, and their entities. Cut 5 is not a prerequisite: neither language lowers with ontology entries until cut 5 gives both theirs.
|
||||||
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`.
|
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.
|
11. `convergence-freeze`: the convergence test, `Checkpoint`'s one door (built from the convergence window over the persisted round records), 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.
|
12. `distil`, three cuts. 12a `distil-slice` (relower and the prompted baseline; spec r1). 12b `distil-export`: Train-only prompt/completion JSONL at a frozen revision, Test-text guarded (follow-up `distil-export-at-freeze`); waits on 11. 12c `distil-train`: waits on 12b and the host question (follow-up `distil-host-question-owed`).
|
||||||
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ ground truth is Discord text, which can never feed this loop
|
||||||
|
|
||||||
An agent's outward speech today is English text the model chose, so meaning,
|
An agent's outward speech today is English text the model chose, so meaning,
|
||||||
register and voice are fused in one string nobody can inspect or re-target.
|
register and voice are fused in one string nobody can inspect or re-target.
|
||||||
Weksa's design already separates them: the packet owns meaning, the target
|
Weksa's design already separates them: the packet owns meaning, speaker and scene state own address and politeness
|
||||||
profile owns address, politeness and forbidden flattenings, the lowerer owns
|
(the language ontology holds only per-language lexicon, grammar and discourse choices), the lowerer owns
|
||||||
surface text and a trace. The campaign makes that design real for a live
|
surface text and a trace. The campaign makes that design real for a live
|
||||||
reader, and gives Weksa's Persona a working body when her mind
|
reader, and gives Weksa's Persona a working body when her mind
|
||||||
(`eureka-body:follow_up:weksa-mind`) wakes.
|
(`eureka-body:follow_up:weksa-mind`) wakes.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
# CultCache Lowering Examples
|
# CultCache Lowering Examples
|
||||||
|
|
||||||
These examples lower interlingua packets through CultCache cultural ontology
|
These examples lower interlingua packets for separate target languages. The
|
||||||
profiles rather than the older YAML-only target-language profiles.
|
language ontology that owns en-US and ja-JP choices is `data/ontology/ontology.cc`
|
||||||
|
(`weksa ontology dump`); its entries are model-drafted seeds.
|
||||||
The starter database is:
|
|
||||||
|
|
||||||
- `.weksa/cultural-ontology/starter-targets.cc`
|
|
||||||
|
|
||||||
Each target file is produced as a separate agent run so differences in target
|
Each target file is produced as a separate agent run so differences in target
|
||||||
ontology remain inspectable instead of being smoothed into one multilingual
|
ontology remain inspectable instead of being smoothed into one multilingual
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,6 @@ Prompt:
|
||||||
|
|
||||||
- `prompts/nibu-cultcache-target-lowering.md`
|
- `prompts/nibu-cultcache-target-lowering.md`
|
||||||
|
|
||||||
CultCache profile store:
|
|
||||||
|
|
||||||
- `.weksa/cultural-ontology/starter-targets.cc`
|
|
||||||
|
|
||||||
Expected target outputs:
|
Expected target outputs:
|
||||||
|
|
||||||
- `pt-BR-vanilla-brazilian.yaml`
|
- `pt-BR-vanilla-brazilian.yaml`
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
schema_version: weksa.utterance_lowering.agent_run.v0
|
schema_version: weksa.utterance_lowering.agent_run.v0
|
||||||
source_packet_id: nibu-proof-001
|
source_packet_id: nibu-proof-001
|
||||||
source_packet_ref: examples/english-lowering/nibu-proof-001/interlingua.yaml
|
source_packet_ref: examples/english-lowering/nibu-proof-001/interlingua.yaml
|
||||||
target_profile_ref: .weksa/cultural-ontology/starter-targets.cc
|
|
||||||
target_profile_id: weksa.cultural_ontology.en.transatlantic.v0
|
target_profile_id: weksa.cultural_ontology.en.transatlantic.v0
|
||||||
target_agent_id: nibu
|
target_agent_id: nibu
|
||||||
realization:
|
realization:
|
||||||
|
|
@ -11,8 +10,6 @@ realization:
|
||||||
locale: en-001
|
locale: en-001
|
||||||
script: Latn
|
script: Latn
|
||||||
register: cultivated transatlantic English; cross-Atlantic intelligibility; measured technical warning
|
register: cultivated transatlantic English; cross-Atlantic intelligibility; measured technical warning
|
||||||
cultural_ontology_refs:
|
|
||||||
- .weksa/cultural-ontology/starter-targets.cc
|
|
||||||
cultural_ontology_profile_ids:
|
cultural_ontology_profile_ids:
|
||||||
- weksa.cultural_ontology.en.transatlantic.v0
|
- weksa.cultural_ontology.en.transatlantic.v0
|
||||||
outputs:
|
outputs:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
schema_version: weksa.utterance_lowering.agent_run.v0
|
schema_version: weksa.utterance_lowering.agent_run.v0
|
||||||
source_packet_id: nibu-proof-001
|
source_packet_id: nibu-proof-001
|
||||||
source_packet_ref: examples/english-lowering/nibu-proof-001/interlingua.yaml
|
source_packet_ref: examples/english-lowering/nibu-proof-001/interlingua.yaml
|
||||||
target_profile_ref: .weksa/cultural-ontology/starter-targets.cc
|
|
||||||
target_profile_id: weksa.cultural_ontology.ja-JP.kanto.contemporary.v0
|
target_profile_id: weksa.cultural_ontology.ja-JP.kanto.contemporary.v0
|
||||||
target_agent_id: nibu
|
target_agent_id: nibu
|
||||||
realization:
|
realization:
|
||||||
|
|
@ -11,8 +10,6 @@ realization:
|
||||||
locale: ja-JP
|
locale: ja-JP
|
||||||
script: Jpan
|
script: Jpan
|
||||||
register: contemporary Kanto metropolitan clipped hazard warning
|
register: contemporary Kanto metropolitan clipped hazard warning
|
||||||
cultural_ontology_refs:
|
|
||||||
- .weksa/cultural-ontology/starter-targets.cc
|
|
||||||
cultural_ontology_profile_ids:
|
cultural_ontology_profile_ids:
|
||||||
- weksa.cultural_ontology.ja-JP.kanto.contemporary.v0
|
- weksa.cultural_ontology.ja-JP.kanto.contemporary.v0
|
||||||
outputs:
|
outputs:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
schema_version: weksa.utterance_lowering.agent_run.v0
|
schema_version: weksa.utterance_lowering.agent_run.v0
|
||||||
source_packet_id: nibu-proof-001
|
source_packet_id: nibu-proof-001
|
||||||
source_packet_ref: examples/english-lowering/nibu-proof-001/interlingua.yaml
|
source_packet_ref: examples/english-lowering/nibu-proof-001/interlingua.yaml
|
||||||
target_profile_ref: .weksa/cultural-ontology/starter-targets.cc
|
|
||||||
target_profile_id: weksa.cultural_ontology.pt-BR.vanilla_brazilian.v0
|
target_profile_id: weksa.cultural_ontology.pt-BR.vanilla_brazilian.v0
|
||||||
target_agent_id: nibu
|
target_agent_id: nibu
|
||||||
realization:
|
realization:
|
||||||
|
|
@ -11,8 +10,6 @@ realization:
|
||||||
locale: pt-BR
|
locale: pt-BR
|
||||||
script: Latn
|
script: Latn
|
||||||
register: casual_clear
|
register: casual_clear
|
||||||
cultural_ontology_refs:
|
|
||||||
- .weksa/cultural-ontology/starter-targets.cc
|
|
||||||
cultural_ontology_profile_ids:
|
cultural_ontology_profile_ids:
|
||||||
- weksa.cultural_ontology.pt-BR.vanilla_brazilian.v0
|
- weksa.cultural_ontology.pt-BR.vanilla_brazilian.v0
|
||||||
outputs:
|
outputs:
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ realizations:
|
||||||
script: Latn
|
script: Latn
|
||||||
register: sharp technical casual
|
register: sharp technical casual
|
||||||
cultural_ontology_refs:
|
cultural_ontology_refs:
|
||||||
- data/target-language-ontology/en-US.yaml
|
- data/ontology/ontology.cc
|
||||||
outputs:
|
outputs:
|
||||||
visible_action: The cockpit lights snap from amber to surgical white around the damaged control.
|
visible_action: The cockpit lights snap from amber to surgical white around the damaged control.
|
||||||
spoken_text: "Take your hand off that control unless you want your nervous system to become my least useful circuit diagram."
|
spoken_text: "Take your hand off that control unless you want your nervous system to become my least useful circuit diagram."
|
||||||
|
|
@ -52,8 +52,6 @@ realizations:
|
||||||
locale: pt-BR
|
locale: pt-BR
|
||||||
script: Latn
|
script: Latn
|
||||||
register: direct technical casual
|
register: direct technical casual
|
||||||
cultural_ontology_refs:
|
|
||||||
- data/target-language-ontology/pt-BR.yaml
|
|
||||||
outputs:
|
outputs:
|
||||||
visible_action: As luzes do cockpit passam de âmbar para branco cirúrgico ao redor do controle danificado.
|
visible_action: As luzes do cockpit passam de âmbar para branco cirúrgico ao redor do controle danificado.
|
||||||
spoken_text: "Tira a mão desse controle, a não ser que você queira transformar o seu sistema nervoso no meu diagrama de circuitos menos útil."
|
spoken_text: "Tira a mão desse controle, a não ser que você queira transformar o seu sistema nervoso no meu diagrama de circuitos menos útil."
|
||||||
|
|
@ -90,7 +88,7 @@ realizations:
|
||||||
script: Jpan
|
script: Jpan
|
||||||
register: sharp technical plain
|
register: sharp technical plain
|
||||||
cultural_ontology_refs:
|
cultural_ontology_refs:
|
||||||
- data/target-language-ontology/ja-JP.yaml
|
- data/ontology/ontology.cc
|
||||||
outputs:
|
outputs:
|
||||||
visible_action: 損傷した制御盤の周囲で、操縦室の灯りが琥珀色から外科灯のような白へ切り替わる。
|
visible_action: 損傷した制御盤の周囲で、操縦室の灯りが琥珀色から外科灯のような白へ切り替わる。
|
||||||
spoken_text: "その制御盤から手を離して。神経系を、私のいちばん役に立たない回路図にしたくないなら。"
|
spoken_text: "その制御盤から手を離して。神経系を、私のいちばん役に立たない回路図にしたくないなら。"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue