entity-door-lows (2d): attested origins, every Cf refused in forms, bytes-only form bound, S6 under NFKC + case fold keeping marks, no-alnum texts refused, CLI flags and echoes (squash of weksa-lowering/entity-door-lows f1c9815..57dacf4; Soul s1, s2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d5ae292ebc
commit
8d9c0270d1
16 changed files with 1413 additions and 84 deletions
|
|
@ -17,10 +17,13 @@ pub struct Turn {
|
|||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum Source {
|
||||
/// A line from a cast member's hand-authored fixture.
|
||||
/// A line from a cast member's hand-authored fixture, with the human who
|
||||
/// wrote it. A fixture that records no author is `ModelWritten` with family
|
||||
/// `Unrecorded`, never this (`corpus-labels-tell-truth`).
|
||||
CastAuthored {
|
||||
persona: String,
|
||||
fixture: String,
|
||||
author: String,
|
||||
},
|
||||
OperatorWritten,
|
||||
OperatorApproved {
|
||||
|
|
|
|||
|
|
@ -13,19 +13,21 @@ use crate::packet::EntityId;
|
|||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct LanguageForms {
|
||||
pub language: String,
|
||||
/// 1 to 64 bytes each, no control character, no edge whitespace, none repeated.
|
||||
/// 1 to 64 bytes each, no control or invisible format character, no edge
|
||||
/// whitespace, none repeated.
|
||||
pub forms: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where an entity was minted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum EntityOrigin {
|
||||
/// From a cast member's fixture.
|
||||
Cast { persona: String },
|
||||
/// From a cast member's fixture: the persona and the fixture path the
|
||||
/// entity came from.
|
||||
Cast { persona: String, fixture: String },
|
||||
/// From a public-domain work.
|
||||
PublicDomain { work: String },
|
||||
/// Minted live by a persona before it names the entity.
|
||||
Live,
|
||||
/// Minted live by the named persona before it names the entity.
|
||||
Live { persona: String },
|
||||
}
|
||||
|
||||
/// Key: the entity id. Forms only grow: see `EntityStore::put`.
|
||||
|
|
|
|||
|
|
@ -86,3 +86,114 @@ pub(crate) fn is_slug(s: &str) -> bool {
|
|||
&& b[0] != b'-'
|
||||
&& b[b.len() - 1] != b'-'
|
||||
}
|
||||
|
||||
/// A General_Category Cf code point, or the line and paragraph separators
|
||||
/// U+2028 and U+2029, other than the joiners ZWNJ U+200C and ZWJ U+200D. The
|
||||
/// ranges are Unicode 15.1 Cf; `tests/format_refused.txt` is the full list from
|
||||
/// Python's `unicodedata` that the table is swept against.
|
||||
pub(crate) fn is_format(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{ad}'
|
||||
| '\u{600}'..='\u{605}'
|
||||
| '\u{61c}'
|
||||
| '\u{6dd}'
|
||||
| '\u{70f}'
|
||||
| '\u{890}'..='\u{891}'
|
||||
| '\u{8e2}'
|
||||
| '\u{180e}'
|
||||
| '\u{200b}'
|
||||
| '\u{200e}'..='\u{200f}'
|
||||
| '\u{2028}'..='\u{202e}'
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{206f}'
|
||||
| '\u{feff}'
|
||||
| '\u{fff9}'..='\u{fffb}'
|
||||
| '\u{110bd}'
|
||||
| '\u{110cd}'
|
||||
| '\u{13430}'..='\u{1343f}'
|
||||
| '\u{1bca0}'..='\u{1bca3}'
|
||||
| '\u{1d173}'..='\u{1d17a}'
|
||||
| '\u{e0001}'
|
||||
| '\u{e0020}'..='\u{e007f}'
|
||||
)
|
||||
}
|
||||
|
||||
/// ZWNJ and ZWJ: Cf, but scripts and emoji sequences need them inside a form.
|
||||
pub(crate) fn is_joiner(c: char) -> bool {
|
||||
matches!(c, '\u{200c}' | '\u{200d}')
|
||||
}
|
||||
|
||||
/// A character a name or form may not hold: every `is_format` code point, and
|
||||
/// the invisible fillers that are Default_Ignorable without being Cf (Hangul
|
||||
/// U+115F, U+1160, U+3164, U+FFA0 and Khmer U+17B4, U+17B5). Joiners,
|
||||
/// variation selectors, CGJ and the Mongolian selectors are not refused.
|
||||
pub(crate) fn is_refused_format(c: char) -> bool {
|
||||
is_format(c)
|
||||
|| matches!(
|
||||
c,
|
||||
'\u{115f}' | '\u{1160}' | '\u{17b4}' | '\u{17b5}' | '\u{3164}' | '\u{ffa0}'
|
||||
)
|
||||
}
|
||||
|
||||
/// A joiner, a variation selector (U+FE00..U+FE0F, U+E0100..U+E01EF), CGJ
|
||||
/// U+034F or a Mongolian free variation selector (U+180B..U+180D, U+180F): it
|
||||
/// shapes a neighbour and shows nothing alone.
|
||||
pub(crate) fn is_joining_only(c: char) -> bool {
|
||||
is_joiner(c)
|
||||
|| matches!(
|
||||
c,
|
||||
'\u{34f}'
|
||||
| '\u{180b}'..='\u{180d}'
|
||||
| '\u{180f}'
|
||||
| '\u{fe00}'..='\u{fe0f}'
|
||||
| '\u{e0100}'..='\u{e01ef}'
|
||||
)
|
||||
}
|
||||
|
||||
/// An attestation string (author, persona, fixture, note, work, url, generator,
|
||||
/// digest): at most 200 bytes, at least one alphanumeric, no control, format or
|
||||
/// joiner character. Whitespace between words is fine; it attests something.
|
||||
pub(crate) fn is_attestation(s: &str) -> bool {
|
||||
s.len() <= 200
|
||||
&& s.chars().any(char::is_alphanumeric)
|
||||
&& !s
|
||||
.chars()
|
||||
.any(|c| c.is_control() || is_refused_format(c) || is_joiner(c))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The rule against every code point: `is_format` is exactly the listed
|
||||
/// Cf-minus-joiners-plus-U+2028/9 set, so each range has both of its
|
||||
/// edges and the gaps between ranges pinned.
|
||||
#[test]
|
||||
fn format_rule_matches_the_unicode_list_at_every_code_point() {
|
||||
let listed: std::collections::BTreeSet<u32> = include_str!("../tests/format_refused.txt")
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#'))
|
||||
.map(|l| u32::from_str_radix(l, 16).unwrap())
|
||||
.collect();
|
||||
for code in 0..=0x10ffff_u32 {
|
||||
if let Some(c) = char::from_u32(code) {
|
||||
assert_eq!(is_format(c), listed.contains(&code), "U+{code:X}");
|
||||
}
|
||||
}
|
||||
assert!(!is_format('\u{200c}') && !is_format('\u{200d}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attestation_is_bounded_readable_text() {
|
||||
assert!(is_attestation("a"));
|
||||
assert!(is_attestation(&"x".repeat(200)));
|
||||
assert!(!is_attestation(&"x".repeat(201)));
|
||||
assert!(is_attestation("two words, 2 more"));
|
||||
for bad in [
|
||||
"", " ", "\t", "\u{200b}", "\u{200d}", "\0", "--", "a\nb", "a\u{ad}b",
|
||||
] {
|
||||
assert!(!is_attestation(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,9 +88,12 @@ struct PutArgs {
|
|||
forms: Vec<String>,
|
||||
#[arg(long, value_enum)]
|
||||
origin: OriginKind,
|
||||
/// The persona of a `cast` origin; refused with any other origin.
|
||||
/// The persona of a `cast` or `live` origin; refused with any other origin.
|
||||
#[arg(long)]
|
||||
persona: Option<String>,
|
||||
/// The fixture path a `cast` entity came from; refused with any other origin.
|
||||
#[arg(long)]
|
||||
fixture: Option<String>,
|
||||
/// The work of a `public-domain` origin; refused with any other origin.
|
||||
#[arg(long)]
|
||||
work: Option<String>,
|
||||
|
|
@ -107,9 +110,7 @@ impl PutArgs {
|
|||
fn entity(self) -> Result<Entity> {
|
||||
let mut forms: Vec<LanguageForms> = Vec::new();
|
||||
for f in &self.forms {
|
||||
let (language, form) = f
|
||||
.split_once('=')
|
||||
.with_context(|| format!("--form {f:?} is not language=form"))?;
|
||||
let (language, form) = f.split_once('=').context("--form is not language=form")?;
|
||||
match forms.iter_mut().find(|lf| lf.language == language) {
|
||||
Some(lf) => lf.forms.push(form.nfc().collect()),
|
||||
None => forms.push(LanguageForms {
|
||||
|
|
@ -118,16 +119,19 @@ impl PutArgs {
|
|||
}),
|
||||
}
|
||||
}
|
||||
let origin = match (self.origin, self.persona, self.work) {
|
||||
(OriginKind::Cast, persona, None) => EntityOrigin::Cast {
|
||||
let origin = match (self.origin, self.persona, self.fixture, self.work) {
|
||||
(OriginKind::Cast, persona, fixture, None) => EntityOrigin::Cast {
|
||||
persona: need(persona, "persona")?,
|
||||
fixture: need(fixture, "fixture")?,
|
||||
},
|
||||
(OriginKind::PublicDomain, None, work) => EntityOrigin::PublicDomain {
|
||||
(OriginKind::PublicDomain, None, None, work) => EntityOrigin::PublicDomain {
|
||||
work: need(work, "work")?,
|
||||
},
|
||||
(OriginKind::Live, None, None) => EntityOrigin::Live,
|
||||
(OriginKind::Live, persona, None, None) => EntityOrigin::Live {
|
||||
persona: need(persona, "persona")?,
|
||||
},
|
||||
_ => bail!(
|
||||
"--persona is only for --origin cast and --work only for --origin public-domain"
|
||||
"--fixture is only for --origin cast, --persona only for --origin cast or live and --work only for --origin public-domain"
|
||||
),
|
||||
};
|
||||
Ok(Entity {
|
||||
|
|
@ -202,6 +206,7 @@ struct AddArgs {
|
|||
note: Option<String>,
|
||||
#[arg(long)]
|
||||
work: Option<String>,
|
||||
/// Who wrote the line: for `cast-authored` and `public-domain`.
|
||||
#[arg(long)]
|
||||
author: Option<String>,
|
||||
#[arg(long)]
|
||||
|
|
@ -226,10 +231,37 @@ fn need(value: Option<String>, flag: &str) -> Result<String> {
|
|||
impl AddArgs {
|
||||
fn source(&self) -> Result<Source> {
|
||||
let get = |v: &Option<String>, flag: &str| need(v.clone(), flag);
|
||||
let applies: &[&str] = match self.source {
|
||||
SourceKind::CastAuthored => &["persona", "fixture", "author"],
|
||||
SourceKind::OperatorWritten => &[],
|
||||
SourceKind::OperatorApproved => &["note"],
|
||||
SourceKind::PublicDomain => &["work", "author", "edition-url"],
|
||||
SourceKind::ModelWritten => &["model-family", "generator", "prompt-digest"],
|
||||
};
|
||||
let given = [
|
||||
("persona", self.persona.is_some()),
|
||||
("fixture", self.fixture.is_some()),
|
||||
("author", self.author.is_some()),
|
||||
("note", self.note.is_some()),
|
||||
("work", self.work.is_some()),
|
||||
("edition-url", self.edition_url.is_some()),
|
||||
("model-family", self.family.is_some()),
|
||||
("generator", self.generator.is_some()),
|
||||
("prompt-digest", self.prompt_digest.is_some()),
|
||||
];
|
||||
let stray: Vec<String> = given
|
||||
.into_iter()
|
||||
.filter(|(flag, given)| *given && !applies.contains(flag))
|
||||
.map(|(flag, _)| format!("--{flag}"))
|
||||
.collect();
|
||||
if !stray.is_empty() {
|
||||
bail!("{} does not apply to this --source", stray.join(", "));
|
||||
}
|
||||
Ok(match self.source {
|
||||
SourceKind::CastAuthored => Source::CastAuthored {
|
||||
persona: get(&self.persona, "persona")?,
|
||||
fixture: get(&self.fixture, "fixture")?,
|
||||
author: get(&self.author, "author")?,
|
||||
},
|
||||
SourceKind::OperatorWritten => Source::OperatorWritten,
|
||||
SourceKind::OperatorApproved => Source::OperatorApproved {
|
||||
|
|
@ -258,9 +290,11 @@ impl AddArgs {
|
|||
.map(|t| {
|
||||
let (speaker, text) = t
|
||||
.split_once('=')
|
||||
.with_context(|| format!("--prior-turn {t:?} is not speaker=text"))?;
|
||||
.context("--prior-turn is not speaker=text")?;
|
||||
Ok(Turn {
|
||||
speaker: speaker.parse()?,
|
||||
speaker: speaker.parse().map_err(|_| {
|
||||
anyhow::anyhow!("--prior-turn speaker is not entity:<slug>")
|
||||
})?,
|
||||
text: text.nfc().collect(),
|
||||
})
|
||||
})
|
||||
|
|
@ -269,13 +303,13 @@ impl AddArgs {
|
|||
.names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let bad = || format!("--name {n:?} is not start..end=entity:<slug>");
|
||||
let (range, entity) = n.split_once('=').with_context(bad)?;
|
||||
let (start, end) = range.split_once("..").with_context(bad)?;
|
||||
let bad = || anyhow::anyhow!("--name is not start..end=entity:<slug>");
|
||||
let (range, entity) = n.split_once('=').ok_or_else(bad)?;
|
||||
let (start, end) = range.split_once("..").ok_or_else(bad)?;
|
||||
Ok(NameSpan {
|
||||
entity: entity.parse()?,
|
||||
start: start.parse().with_context(bad)?,
|
||||
end: end.parse().with_context(bad)?,
|
||||
entity: entity.parse().map_err(|_| bad())?,
|
||||
start: start.parse().map_err(|_| bad())?,
|
||||
end: end.parse().map_err(|_| bad())?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
|
@ -338,7 +372,7 @@ fn main() -> Result<()> {
|
|||
}
|
||||
Command::Corpus(CorpusCommand::Add(args)) => {
|
||||
let mut store = CorpusStore::open(&cli.corpus)?;
|
||||
let key = store.next_key(&args.slug)?;
|
||||
let key = store.next_key(&args.slug).map_err(refused)?;
|
||||
let entities = EntityStore::open(&cli.entities)?;
|
||||
let outcome = store
|
||||
.add((*args).item(key.clone())?, &entities)
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ use std::collections::BTreeSet;
|
|||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use caseless::default_case_fold_str;
|
||||
use cultcache_rs::{CultCache, SingleFileMessagePackBackingStore};
|
||||
use unicode_normalization::is_nfc;
|
||||
use unicode_normalization::{UnicodeNormalization, char::is_combining_mark, is_nfc};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::corpus::{CorpusItem, CorpusItemId, item_key, key_slug};
|
||||
use crate::entity::Entity;
|
||||
use crate::form::is_language_tag;
|
||||
use crate::corpus::{CorpusItem, CorpusItemId, Source, item_key, key_slug};
|
||||
use crate::entity::{Entity, EntityOrigin};
|
||||
use crate::form::{is_attestation, is_joining_only, is_language_tag, is_refused_format};
|
||||
use crate::inventory::{Change, InvId, Inventory, InventoryEntry, InventoryRevision, revision_key};
|
||||
use crate::packet::{EntityId, Packet};
|
||||
use crate::validate::{Refusal, validate};
|
||||
|
|
@ -88,15 +89,12 @@ pub enum EntityBound {
|
|||
Forms,
|
||||
/// A form of more than 64 bytes.
|
||||
FormBytes,
|
||||
/// A form of more than 4 words.
|
||||
FormWords,
|
||||
}
|
||||
|
||||
impl EntityBound {
|
||||
pub const MAX_LANGUAGES: usize = 8;
|
||||
pub const MAX_FORMS: usize = 8;
|
||||
pub const MAX_FORM_BYTES: usize = 64;
|
||||
pub const MAX_FORM_WORDS: usize = 4;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -185,7 +183,7 @@ impl CorpusStore {
|
|||
}
|
||||
|
||||
/// The next free key for `slug`; withdrawn items keep their numbers.
|
||||
pub fn next_key(&self, slug: &str) -> anyhow::Result<String> {
|
||||
pub fn next_key(&self, slug: &str) -> Result<String, StoreError> {
|
||||
let highest = self
|
||||
.all()?
|
||||
.iter()
|
||||
|
|
@ -193,7 +191,10 @@ impl CorpusStore {
|
|||
.filter_map(|i| i.id.rsplit_once(':')?.1.parse::<u32>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
Ok(item_key(slug, highest + 1))
|
||||
let next = highest
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| malformed(slug, "slug has no free key"))?;
|
||||
Ok(item_key(slug, next))
|
||||
}
|
||||
|
||||
/// The item `id` names, or `UnknownCorpusItem` when it holds nothing.
|
||||
|
|
@ -236,8 +237,14 @@ impl CorpusStore {
|
|||
if !is_nfc(&item.text) || item.prior_turns.iter().any(|t| !is_nfc(&t.text)) {
|
||||
return Err(StoreRefusal::NotNfc { key: item.id }.into());
|
||||
}
|
||||
check_entities(&item, entities)?;
|
||||
let text = normalized(&item.text);
|
||||
if !text.chars().any(char::is_alphanumeric) {
|
||||
return Err(malformed(&item.id, "text has no alphanumeric character"));
|
||||
}
|
||||
if let Some(why) = empty_attestation(&item.source) {
|
||||
return Err(malformed(&item.id, why));
|
||||
}
|
||||
check_entities(&item, entities)?;
|
||||
for held in self.all()? {
|
||||
if normalized(&held.text) == text && item.supersedes.as_deref() != Some(&held.id) {
|
||||
return Err(StoreRefusal::S6TextHeld {
|
||||
|
|
@ -350,15 +357,58 @@ fn check_entities(item: &CorpusItem, entities: &EntityStore) -> Result<(), Store
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Lowercased alphanumerics only, so case, spacing and punctuation variants of
|
||||
/// a text compare equal.
|
||||
/// The S6 key of a text: Unicode compatibility caseless matching (D146: NFKC,
|
||||
/// default case fold, NFKC) keeping letters, digits and combining marks
|
||||
/// (General_Category L, N, M) so tone marks and viramas keep words apart. Composed and decomposed,
|
||||
/// fullwidth, ligature and case-fold variants (straße, STRASSE) and spacing and
|
||||
/// punctuation variants compare equal. Homoglyphs across scripts (Cyrillic a for
|
||||
/// Latin a) stay apart: no key here reads what a character looks like.
|
||||
fn normalized(text: &str) -> String {
|
||||
text.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
let folded = default_case_fold_str(&text.nfkc().collect::<String>());
|
||||
folded
|
||||
.nfkc()
|
||||
.filter(|c| c.is_alphanumeric() || is_combining_mark(*c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The field that holds an unattested provenance string, if any: a string with
|
||||
/// no alphanumeric, a control or format character, or over 200 bytes attests
|
||||
/// nothing.
|
||||
fn empty_attestation(source: &Source) -> Option<&'static str> {
|
||||
let empty = |s: &String| !is_attestation(s);
|
||||
let empty_some = |s: &Option<String>| s.as_ref().is_some_and(empty);
|
||||
match source {
|
||||
Source::CastAuthored { persona, .. } if empty(persona) => {
|
||||
Some("a cast-authored source names its persona")
|
||||
}
|
||||
Source::CastAuthored { fixture, .. } if empty(fixture) => {
|
||||
Some("a cast-authored source names its fixture")
|
||||
}
|
||||
Source::CastAuthored { author, .. } if empty(author) => {
|
||||
Some("a cast-authored source names its author")
|
||||
}
|
||||
Source::OperatorApproved { note } if empty(note) => {
|
||||
Some("an operator-approved source carries its note")
|
||||
}
|
||||
Source::PublicDomain { work, .. } if empty(work) => {
|
||||
Some("a public-domain source names its work")
|
||||
}
|
||||
Source::PublicDomain { author, .. } if empty(author) => {
|
||||
Some("a public-domain source names its author")
|
||||
}
|
||||
Source::PublicDomain { edition_url, .. } if empty(edition_url) => {
|
||||
Some("a public-domain source names its edition url")
|
||||
}
|
||||
Source::ModelWritten { generator, .. } if empty_some(generator) => {
|
||||
Some("a model-written source names a non-empty generator")
|
||||
}
|
||||
Source::ModelWritten { prompt_digest, .. } if empty_some(prompt_digest) => {
|
||||
Some("a model-written source carries a non-empty prompt digest")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntityStore {
|
||||
cache: CultCache,
|
||||
}
|
||||
|
|
@ -384,8 +434,9 @@ impl EntityStore {
|
|||
/// every stored language and form in order and origin as it was, and only
|
||||
/// appends: forms grow, never shrink, reorder or change. An entity has 1 to
|
||||
/// 8 languages, each a BCP 47-form tag named once, with 1 to 8 NFC forms of
|
||||
/// 1 to 64 bytes and at most 4 words, no control character, no edge
|
||||
/// whitespace, none repeated.
|
||||
/// 1 to 64 bytes, no control or invisible format character (joiners and
|
||||
/// variation selectors stay), no edge whitespace, none repeated; the origin
|
||||
/// attests where it came from with strings that hold an alphanumeric, no control or format character and at most 200 bytes.
|
||||
pub fn put(&mut self, entity: Entity) -> Result<AddOutcome, StoreError> {
|
||||
let key = entity.id.to_string();
|
||||
check_entity_shape(&entity)?;
|
||||
|
|
@ -418,6 +469,20 @@ fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> {
|
|||
}
|
||||
.into()
|
||||
};
|
||||
let (persona, fixture, work) = match &entity.origin {
|
||||
EntityOrigin::Cast { persona, fixture } => (Some(persona), Some(fixture), None),
|
||||
EntityOrigin::Live { persona } => (Some(persona), None, None),
|
||||
EntityOrigin::PublicDomain { work } => (None, None, Some(work)),
|
||||
};
|
||||
if persona.is_some_and(|s| !is_attestation(s)) {
|
||||
return Err(bad("an origin names its persona"));
|
||||
}
|
||||
if fixture.is_some_and(|s| !is_attestation(s)) {
|
||||
return Err(bad("a cast origin names its fixture"));
|
||||
}
|
||||
if work.is_some_and(|s| !is_attestation(s)) {
|
||||
return Err(bad("a public-domain origin names its work"));
|
||||
}
|
||||
if entity.forms.is_empty() {
|
||||
return Err(bad("an entity has forms in at least one language"));
|
||||
}
|
||||
|
|
@ -443,9 +508,6 @@ fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> {
|
|||
if f.len() > EntityBound::MAX_FORM_BYTES {
|
||||
return Err(bound(EntityBound::FormBytes));
|
||||
}
|
||||
if f.split_whitespace().count() > EntityBound::MAX_FORM_WORDS {
|
||||
return Err(bound(EntityBound::FormWords));
|
||||
}
|
||||
if !is_nfc(f) {
|
||||
return Err(StoreRefusal::NotNfc {
|
||||
key: entity.id.to_string(),
|
||||
|
|
@ -457,6 +519,12 @@ fn check_entity_shape(entity: &Entity) -> Result<(), StoreError> {
|
|||
"a form is not empty and has no control character or edge whitespace",
|
||||
));
|
||||
}
|
||||
if f.chars().any(is_refused_format) {
|
||||
return Err(bad("a form holds an invisible format character"));
|
||||
}
|
||||
if f.chars().all(is_joining_only) {
|
||||
return Err(bad("a form is more than joiners and selectors"));
|
||||
}
|
||||
if !seen.insert(f) {
|
||||
return Err(bad("a form is not repeated within its language"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
mod common;
|
||||
use std::process::{Command, Output};
|
||||
use weksa::{InventoryStore, PacketStore};
|
||||
use weksa::{CorpusStore, EntityStore, InventoryStore, PacketStore};
|
||||
|
||||
fn weksa(dir: &std::path::Path, args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_weksa"))
|
||||
|
|
@ -35,6 +35,8 @@ fn cast_entities(dir: &std::path::Path, slugs: &[&str]) {
|
|||
"cast",
|
||||
"--persona",
|
||||
slug,
|
||||
"--fixture",
|
||||
"f.yaml",
|
||||
],
|
||||
);
|
||||
assert!(put.status.success(), "{}", out(&put));
|
||||
|
|
@ -81,7 +83,7 @@ fn seed_add_dump_split_withdraw() {
|
|||
"{dump}"
|
||||
);
|
||||
|
||||
// A cast-authored item needs its persona and fixture.
|
||||
// A cast-authored item needs its persona, fixture and author.
|
||||
let missing = weksa(d.path(), &ADD);
|
||||
assert!(
|
||||
!missing.status.success() && out(&missing).contains("--persona"),
|
||||
|
|
@ -89,7 +91,14 @@ fn seed_add_dump_split_withdraw() {
|
|||
out(&missing)
|
||||
);
|
||||
let mut add = ADD.to_vec();
|
||||
add.extend(["--persona", "nibu", "--fixture", "f.yaml"]);
|
||||
add.extend([
|
||||
"--persona",
|
||||
"nibu",
|
||||
"--fixture",
|
||||
"f.yaml",
|
||||
"--author",
|
||||
"Ada Writer",
|
||||
]);
|
||||
let added = weksa(d.path(), &add);
|
||||
assert!(
|
||||
added.status.success() && out(&added).contains("Added item:nibu:1"),
|
||||
|
|
@ -294,7 +303,15 @@ fn schema_writes_the_published_files() {
|
|||
fn entity_put_and_name_spans() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let put = |args: &[&str]| {
|
||||
let mut all = vec!["entity", "put", "entity:kay", "--origin", "cast"];
|
||||
let mut all = vec![
|
||||
"entity",
|
||||
"put",
|
||||
"entity:kay",
|
||||
"--origin",
|
||||
"cast",
|
||||
"--fixture",
|
||||
"f.yaml",
|
||||
];
|
||||
all.extend_from_slice(args);
|
||||
weksa(d.path(), &all)
|
||||
};
|
||||
|
|
@ -386,24 +403,59 @@ fn entity_origin_flags_must_match_origin() {
|
|||
weksa(d.path(), &all)
|
||||
};
|
||||
for mismatched in [
|
||||
&["--origin", "live", "--persona", "kay"][..],
|
||||
&["--origin", "live", "--work", "edda"],
|
||||
&["--origin", "cast", "--persona", "kay", "--work", "edda"],
|
||||
&["--origin", "live"][..],
|
||||
&["--origin", "live", "--persona", "kay", "--work", "edda"],
|
||||
&[
|
||||
"--origin",
|
||||
"public-domain",
|
||||
"live",
|
||||
"--persona",
|
||||
"kay",
|
||||
"--fixture",
|
||||
"f.yaml",
|
||||
],
|
||||
&["--origin", "cast", "--persona", "kay"],
|
||||
&["--origin", "cast", "--fixture", "f.yaml"],
|
||||
&[
|
||||
"--origin",
|
||||
"cast",
|
||||
"--persona",
|
||||
"kay",
|
||||
"--fixture",
|
||||
"f.yaml",
|
||||
"--work",
|
||||
"edda",
|
||||
],
|
||||
&["--origin", "public-domain", "--persona", "kay"],
|
||||
&["--origin", "public-domain"],
|
||||
&[
|
||||
"--origin",
|
||||
"public-domain",
|
||||
"--work",
|
||||
"edda",
|
||||
"--persona",
|
||||
"kay",
|
||||
],
|
||||
&[
|
||||
"--origin",
|
||||
"public-domain",
|
||||
"--work",
|
||||
"edda",
|
||||
"--fixture",
|
||||
"f.yaml",
|
||||
],
|
||||
&["--origin", "cast", "--persona", "", "--fixture", "f.yaml"],
|
||||
&["--origin", "cast", "--persona", "kay", "--fixture", ""],
|
||||
&["--origin", "live", "--persona", ""],
|
||||
&["--origin", "public-domain", "--work", ""],
|
||||
] {
|
||||
let o = put(mismatched);
|
||||
assert!(!o.status.success(), "{mismatched:?}: {}", out(&o));
|
||||
}
|
||||
assert!(!out(&weksa(d.path(), &["entity", "dump"])).contains("entity:kay"));
|
||||
assert!(put(&["--origin", "live"]).status.success());
|
||||
assert!(
|
||||
put(&["--origin", "live", "--persona", "kay"])
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
let work = weksa(
|
||||
d.path(),
|
||||
&[
|
||||
|
|
@ -436,6 +488,8 @@ fn intake_normalizes_to_nfc() {
|
|||
"fr=Rene\u{301}",
|
||||
"--origin",
|
||||
"live",
|
||||
"--persona",
|
||||
"rene",
|
||||
],
|
||||
);
|
||||
assert!(put.status.success(), "{}", out(&put));
|
||||
|
|
@ -467,3 +521,224 @@ fn intake_normalizes_to_nfc() {
|
|||
let dump = out(&weksa(d.path(), &["corpus", "dump"]));
|
||||
assert!(dump.contains("Ren\u{e9} part."), "{dump}");
|
||||
}
|
||||
|
||||
/// `corpus add` of "Mind the hatch." by `entity:nibu` under `source`, with
|
||||
/// `extra` flags.
|
||||
fn add_with(dir: &std::path::Path, source: &str, extra: &[&str]) -> Output {
|
||||
let mut args = vec![
|
||||
"corpus",
|
||||
"add",
|
||||
"--slug",
|
||||
"nibu",
|
||||
"--text",
|
||||
"Mind the hatch.",
|
||||
"--speaker",
|
||||
"entity:nibu",
|
||||
"--group",
|
||||
"scene-1",
|
||||
"--admitted-on",
|
||||
"2026-10-29",
|
||||
"--source",
|
||||
source,
|
||||
];
|
||||
args.extend_from_slice(extra);
|
||||
weksa(dir, &args)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_flags_must_match_the_source() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
cast_entities(d.path(), &["nibu"]);
|
||||
// A provenance flag that does not apply to --source refuses, naming the flag.
|
||||
for (source, extra, flag) in [
|
||||
("operator-written", &["--persona", "p"][..], "--persona"),
|
||||
(
|
||||
"operator-approved",
|
||||
&["--note", "n", "--work", "w"],
|
||||
"--work",
|
||||
),
|
||||
(
|
||||
"public-domain",
|
||||
&[
|
||||
"--work",
|
||||
"w",
|
||||
"--author",
|
||||
"a",
|
||||
"--edition-url",
|
||||
"u",
|
||||
"--note",
|
||||
"n",
|
||||
],
|
||||
"--note",
|
||||
),
|
||||
(
|
||||
"cast-authored",
|
||||
&[
|
||||
"--persona",
|
||||
"p",
|
||||
"--fixture",
|
||||
"f",
|
||||
"--author",
|
||||
"a",
|
||||
"--generator",
|
||||
"g",
|
||||
],
|
||||
"--generator",
|
||||
),
|
||||
(
|
||||
"model-written",
|
||||
&["--model-family", "unrecorded", "--edition-url", "u"],
|
||||
"--edition-url",
|
||||
),
|
||||
(
|
||||
"operator-written",
|
||||
&["--model-family", "unrecorded"],
|
||||
"--model-family",
|
||||
),
|
||||
(
|
||||
"model-written",
|
||||
&[
|
||||
"--model-family",
|
||||
"unrecorded",
|
||||
"--prompt-digest",
|
||||
"d",
|
||||
"--fixture",
|
||||
"f",
|
||||
],
|
||||
"--fixture",
|
||||
),
|
||||
// A source missing a flag it needs.
|
||||
(
|
||||
"cast-authored",
|
||||
&["--persona", "p", "--fixture", "f"],
|
||||
"--author",
|
||||
),
|
||||
(
|
||||
"public-domain",
|
||||
&["--work", "w", "--edition-url", "u"],
|
||||
"--author",
|
||||
),
|
||||
// Empty provenance strings.
|
||||
(
|
||||
"model-written",
|
||||
&["--model-family", "unrecorded", "--generator", ""],
|
||||
"",
|
||||
),
|
||||
(
|
||||
"model-written",
|
||||
&["--model-family", "unrecorded", "--prompt-digest", ""],
|
||||
"",
|
||||
),
|
||||
(
|
||||
"cast-authored",
|
||||
&["--persona", "", "--fixture", "f", "--author", "a"],
|
||||
"",
|
||||
),
|
||||
("operator-approved", &["--note", ""], ""),
|
||||
] {
|
||||
let o = add_with(d.path(), source, extra);
|
||||
assert!(!o.status.success(), "{source} {extra:?}: {}", out(&o));
|
||||
assert!(out(&o).contains(flag), "{source} {extra:?}: {}", out(&o));
|
||||
}
|
||||
assert!(!out(&weksa(d.path(), &["corpus", "dump"])).contains("item:"));
|
||||
// A source whose flags all apply is admitted; --author reaches a
|
||||
// cast-authored item.
|
||||
let cast = add_with(
|
||||
d.path(),
|
||||
"cast-authored",
|
||||
&["--persona", "p", "--fixture", "f", "--author", "Ada Writer"],
|
||||
);
|
||||
assert!(cast.status.success(), "{}", out(&cast));
|
||||
let dump = out(&weksa(d.path(), &["corpus", "dump"]));
|
||||
assert!(dump.contains("author: \"Ada Writer\""), "{dump}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_intake_never_echoes_its_values() {
|
||||
const CANARY: &str = "CANARY-7f3a";
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
cast_entities(d.path(), &["nibu"]);
|
||||
let shape = |value: &str| format!("{CANARY}{value}");
|
||||
let puts = [
|
||||
(vec![shape("")], "--form"),
|
||||
(vec![shape("_x=Kay")], ""),
|
||||
(vec![format!("en-US={CANARY}\u{7}")], ""),
|
||||
(vec![format!("{CANARY}_x=Kay")], ""),
|
||||
];
|
||||
for (forms, flag) in puts {
|
||||
let mut args = vec![
|
||||
"entity",
|
||||
"put",
|
||||
"entity:kay",
|
||||
"--origin",
|
||||
"live",
|
||||
"--persona",
|
||||
"kay",
|
||||
];
|
||||
for f in &forms {
|
||||
args.extend(["--form", f]);
|
||||
}
|
||||
let o = weksa(d.path(), &args);
|
||||
assert!(!o.status.success(), "{forms:?}: {}", out(&o));
|
||||
assert!(!out(&o).contains(CANARY), "{forms:?}: {}", out(&o));
|
||||
assert!(out(&o).contains(flag), "{forms:?}: {}", out(&o));
|
||||
}
|
||||
let bad_turn = [shape(""), format!("{CANARY}=hello")];
|
||||
let bad_name = [
|
||||
shape(""),
|
||||
format!("0..1={CANARY}"),
|
||||
format!("{CANARY}..1=entity:nibu"),
|
||||
format!("0..{CANARY}=entity:nibu"),
|
||||
];
|
||||
let cases = bad_turn
|
||||
.iter()
|
||||
.map(|t| ("--prior-turn", t))
|
||||
.chain(bad_name.iter().map(|n| ("--name", n)));
|
||||
for (flag, value) in cases {
|
||||
let o = add_with(d.path(), "operator-written", &[flag, value]);
|
||||
assert!(!o.status.success(), "{flag} {value}: {}", out(&o));
|
||||
assert!(!out(&o).contains(CANARY), "{flag} {value}: {}", out(&o));
|
||||
assert!(out(&o).contains(flag), "{flag} {value}: {}", out(&o));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_decomposed_form_and_turn_is_stored_composed() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
cast_entities(d.path(), &["nibu"]);
|
||||
let put = weksa(
|
||||
d.path(),
|
||||
&[
|
||||
"entity",
|
||||
"put",
|
||||
"entity:rene",
|
||||
"--form",
|
||||
"fr=Rene\u{301}",
|
||||
"--form",
|
||||
"fr=Zoe\u{308}",
|
||||
"--origin",
|
||||
"live",
|
||||
"--persona",
|
||||
"rene",
|
||||
],
|
||||
);
|
||||
assert!(put.status.success(), "{}", out(&put));
|
||||
let rene = EntityStore::open(&d.path().join("e.cc"))
|
||||
.unwrap()
|
||||
.get(&"entity:rene".parse().unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rene.forms[0].forms, ["Ren\u{e9}", "Zo\u{eb}"]);
|
||||
let add = add_with(
|
||||
d.path(),
|
||||
"operator-written",
|
||||
&["--prior-turn", "entity:rene=Cafe\u{301} now"],
|
||||
);
|
||||
assert!(add.status.success(), "{}", out(&add));
|
||||
let item = CorpusStore::open(&d.path().join("c.cc"))
|
||||
.unwrap()
|
||||
.get("item:nibu:1")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(item.prior_turns[0].text, "Caf\u{e9} now");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ pub fn cast() -> Source {
|
|||
Source::CastAuthored {
|
||||
persona: "nibu".into(),
|
||||
fixture: "f.yaml".into(),
|
||||
author: "Ada Writer".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +92,7 @@ pub fn ent(slug: &str, language: &str, forms: &[&str]) -> Entity {
|
|||
}],
|
||||
origin: EntityOrigin::Cast {
|
||||
persona: slug.into(),
|
||||
fixture: "f.yaml".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,11 @@ fn entity_forms_only_grow() {
|
|||
},
|
||||
|e| e.forms.reverse(),
|
||||
|e| e.forms[1].language = "fi".into(),
|
||||
|e| e.origin = EntityOrigin::Live,
|
||||
|e| {
|
||||
e.origin = EntityOrigin::Live {
|
||||
persona: "kay".into(),
|
||||
}
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
refusal(store.put(ent("kay", "en-US", &["Kay"]))),
|
||||
|
|
|
|||
|
|
@ -163,28 +163,33 @@ fn entity_capacity_is_bounded() {
|
|||
let make = |forms: Vec<LanguageForms>| Entity {
|
||||
id: eid("big"),
|
||||
forms,
|
||||
origin: EntityOrigin::Live,
|
||||
origin: EntityOrigin::Live {
|
||||
persona: "p".into(),
|
||||
},
|
||||
};
|
||||
|
||||
// A whole line is not a name: 7 words.
|
||||
let line = "I will never leave you, not ever.";
|
||||
let e = make(vec![lf("en-US", one(line))]);
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(e.clone())),
|
||||
bounds(&e, EntityBound::FormWords)
|
||||
);
|
||||
// Four words is the edge; five is over. 64 bytes is the edge; 65 is over.
|
||||
w.entities
|
||||
.put(Entity {
|
||||
id: eid("four"),
|
||||
..make(vec![lf("en-US", one("Mary of the Isles"))])
|
||||
})
|
||||
.unwrap();
|
||||
let five = make(vec![lf("en-US", one("Mary of the Isles too"))]);
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(five.clone())),
|
||||
bounds(&five, EntityBound::FormWords)
|
||||
);
|
||||
// A form is bounded by bytes, not words: a spaced name of any word count and
|
||||
// names in scripts that write no spaces are admitted (the Thai name counts
|
||||
// five UAX #29 words, a Han name one per character).
|
||||
for (i, name) in [
|
||||
"I will never leave you, not ever.",
|
||||
"Mary of the Isles too",
|
||||
"de la Cruz y Garcia",
|
||||
"\u{e2a}\u{e21}\u{e0a}\u{e32}\u{e22} \u{e43}\u{e08}\u{e14}\u{e35}",
|
||||
"\u{5c71}\u{7530}\u{592a}\u{90ce}",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
w.entities
|
||||
.put(Entity {
|
||||
id: eid(&format!("name-{i}")),
|
||||
..make(vec![lf("en-US", one(name))])
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
// 64 bytes is the edge; 65 is over, counted in bytes (21 Han characters are
|
||||
// 63 bytes, 22 are 66).
|
||||
let wide = make(vec![lf("en-US", one(&"a".repeat(65)))]);
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(wide.clone())),
|
||||
|
|
@ -196,6 +201,17 @@ fn entity_capacity_is_bounded() {
|
|||
..make(vec![lf("en-US", one(&"a".repeat(64)))])
|
||||
})
|
||||
.unwrap();
|
||||
w.entities
|
||||
.put(Entity {
|
||||
id: eid("han-edge"),
|
||||
..make(vec![lf("ja", one(&"\u{5c71}".repeat(21)))])
|
||||
})
|
||||
.unwrap();
|
||||
let han = make(vec![lf("ja", one(&"\u{5c71}".repeat(22)))]);
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(han.clone())),
|
||||
bounds(&han, EntityBound::FormBytes)
|
||||
);
|
||||
|
||||
// 8 forms per language is the edge.
|
||||
let forms = |n: usize| (0..n).map(|i| format!("form{i}")).collect::<Vec<_>>();
|
||||
|
|
@ -321,6 +337,7 @@ fn entities_are_append_only_and_replay_needs_them() {
|
|||
let mut persona = base.clone();
|
||||
persona.origin = EntityOrigin::Cast {
|
||||
persona: "someone-else".into(),
|
||||
fixture: "f.yaml".into(),
|
||||
};
|
||||
assert_eq!(refusal(w.entities.put(persona)), not_append);
|
||||
let mut duplicate = base.clone();
|
||||
|
|
@ -346,3 +363,276 @@ fn entities_are_append_only_and_replay_needs_them() {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// One entity holding `form` as its only en-US form.
|
||||
fn with_form(slug: &str, form: &str) -> Entity {
|
||||
Entity {
|
||||
id: eid(slug),
|
||||
forms: vec![LanguageForms {
|
||||
language: "en-US".into(),
|
||||
forms: vec![form.into()],
|
||||
}],
|
||||
origin: EntityOrigin::Live {
|
||||
persona: "p".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_format_characters_are_refused_in_forms() {
|
||||
let mut w = world();
|
||||
let hidden = |slug: &str| StoreRefusal::Malformed {
|
||||
key: format!("entity:{slug}"),
|
||||
why: "a form holds an invisible format character",
|
||||
};
|
||||
let at = |code: u32| char::from_u32(code).unwrap();
|
||||
// Every Cf code point except the joiners, plus U+2028 and U+2029, listed
|
||||
// from Python's unicodedata. The code-point sweep in form.rs pins the edges
|
||||
// of the rule; this checks the door applies it, in each position.
|
||||
let listed: Vec<u32> = include_str!("format_refused.txt")
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with('#'))
|
||||
.map(|l| u32::from_str_radix(l, 16).unwrap())
|
||||
.collect();
|
||||
assert_eq!(listed.len(), 170);
|
||||
for (n, code) in listed.iter().enumerate() {
|
||||
let c = at(*code);
|
||||
let mut forms = vec![format!("Ka{c}y")];
|
||||
if !c.is_whitespace() {
|
||||
forms.extend([format!("{c}Kay"), format!("Kay{c}")]);
|
||||
}
|
||||
for form in forms {
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(with_form("fmt", &form))),
|
||||
hidden("fmt"),
|
||||
"U+{code:X} (#{n}) in {form:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Default_Ignorable fillers that are not Cf: Hangul and Khmer.
|
||||
for code in [0x115f, 0x1160, 0x17b4, 0x17b5, 0x3164, 0xffa0] {
|
||||
assert_eq!(
|
||||
refusal(
|
||||
w.entities
|
||||
.put(with_form("fmt", &format!("Ka{}y", at(code))))
|
||||
),
|
||||
hidden("fmt"),
|
||||
"U+{code:X}"
|
||||
);
|
||||
}
|
||||
assert!(w.entities.get(&eid("fmt")).unwrap().is_none());
|
||||
|
||||
// The neighbours of the refused ranges, ZWNJ, ZWJ, CGJ, variation
|
||||
// selectors and the Mongolian selectors (all Mn) are admitted in a form.
|
||||
let admitted: [u32; 33] = [
|
||||
0xac,
|
||||
0xae,
|
||||
0x34f,
|
||||
0x5ff,
|
||||
0x606,
|
||||
0x61b,
|
||||
0x61d,
|
||||
0x6dc,
|
||||
0x6de,
|
||||
0x70e,
|
||||
0x710,
|
||||
0x88f,
|
||||
0x892,
|
||||
0x8e1,
|
||||
0x8e3,
|
||||
0x180b,
|
||||
0x180c,
|
||||
0x180d,
|
||||
0x180f,
|
||||
0x200a,
|
||||
0x200c,
|
||||
0x200d,
|
||||
0x2010,
|
||||
0x202f,
|
||||
0x2065,
|
||||
0x2070,
|
||||
0xfe00,
|
||||
0xfe0f,
|
||||
0xfeff - 1,
|
||||
0xfffc,
|
||||
0x110be,
|
||||
0x1d172,
|
||||
0xe0100,
|
||||
];
|
||||
for (n, code) in admitted.into_iter().enumerate() {
|
||||
let form = format!("Ka{}y", at(code));
|
||||
w.entities
|
||||
.put(with_form(&format!("ok-{n}"), &form))
|
||||
.unwrap_or_else(|e| panic!("U+{code:X}: {e}"));
|
||||
}
|
||||
// ZWNJ in a Persian form, ZWJ in an emoji sequence, VS16 and a Mongolian
|
||||
// free variation selector beside a letter.
|
||||
for (n, form) in [
|
||||
"\u{645}\u{6cc}\u{200c}\u{62e}\u{648}\u{627}\u{647}\u{645}",
|
||||
"\u{1f469}\u{200d}\u{1f4bb}",
|
||||
"\u{2764}\u{fe0f}",
|
||||
"\u{1820}\u{180b}",
|
||||
"\u{1f600}\u{e0100}",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
w.entities
|
||||
.put(with_form(&format!("script-{n}"), form))
|
||||
.unwrap();
|
||||
}
|
||||
// A form of only joiners, variation selectors, CGJ or Mongolian selectors
|
||||
// shows nothing and is refused.
|
||||
let empty_looking = |slug: &str| StoreRefusal::Malformed {
|
||||
key: format!("entity:{slug}"),
|
||||
why: "a form is more than joiners and selectors",
|
||||
};
|
||||
for form in [
|
||||
"\u{200d}",
|
||||
"\u{200c}",
|
||||
"\u{200c}\u{200d}",
|
||||
"\u{fe0f}",
|
||||
"\u{fe00}\u{fe0f}",
|
||||
"\u{34f}",
|
||||
"\u{180b}\u{180c}\u{180d}\u{180f}",
|
||||
"\u{e0100}",
|
||||
"\u{e01ef}\u{34f}\u{200d}",
|
||||
] {
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(with_form("blank", form))),
|
||||
empty_looking("blank"),
|
||||
"{form:?}"
|
||||
);
|
||||
}
|
||||
assert!(w.entities.get(&eid("blank")).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_origin_attests_where_the_entity_came_from() {
|
||||
let mut w = world();
|
||||
let bad = |slug: &str, why: &'static str| StoreRefusal::Malformed {
|
||||
key: format!("entity:{slug}"),
|
||||
why,
|
||||
};
|
||||
let with = |slug: &str, origin: EntityOrigin| Entity {
|
||||
origin,
|
||||
..with_form(slug, "Kay")
|
||||
};
|
||||
let cast = |persona: &str, fixture: &str| EntityOrigin::Cast {
|
||||
persona: persona.into(),
|
||||
fixture: fixture.into(),
|
||||
};
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(with("a", cast("", "f.yaml")))),
|
||||
bad("a", "an origin names its persona")
|
||||
);
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(with("b", cast("kay", "")))),
|
||||
bad("b", "a cast origin names its fixture")
|
||||
);
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(with(
|
||||
"c",
|
||||
EntityOrigin::Live {
|
||||
persona: String::new()
|
||||
}
|
||||
))),
|
||||
bad("c", "an origin names its persona")
|
||||
);
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(with(
|
||||
"d",
|
||||
EntityOrigin::PublicDomain {
|
||||
work: String::new()
|
||||
}
|
||||
))),
|
||||
bad("d", "a public-domain origin names its work")
|
||||
);
|
||||
for slug in ["a", "b", "c", "d"] {
|
||||
assert!(w.entities.get(&eid(slug)).unwrap().is_none());
|
||||
}
|
||||
w.entities.put(with("e", cast("kay", "f.yaml"))).unwrap();
|
||||
w.entities
|
||||
.put(with(
|
||||
"f",
|
||||
EntityOrigin::PublicDomain {
|
||||
work: "edda".into(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_origin_attestation_holds_something_readable() {
|
||||
let mut w = world();
|
||||
let with = |slug: &str, origin: EntityOrigin| Entity {
|
||||
origin,
|
||||
..with_form(slug, "Kay")
|
||||
};
|
||||
type Build = fn(String) -> EntityOrigin;
|
||||
let origins: [(Build, &str); 4] = [
|
||||
(
|
||||
|v| EntityOrigin::Cast {
|
||||
persona: v,
|
||||
fixture: "f.yaml".into(),
|
||||
},
|
||||
"an origin names its persona",
|
||||
),
|
||||
(
|
||||
|v| EntityOrigin::Cast {
|
||||
persona: "kay".into(),
|
||||
fixture: v,
|
||||
},
|
||||
"a cast origin names its fixture",
|
||||
),
|
||||
(
|
||||
|v| EntityOrigin::Live { persona: v },
|
||||
"an origin names its persona",
|
||||
),
|
||||
(
|
||||
|v| EntityOrigin::PublicDomain { work: v },
|
||||
"a public-domain origin names its work",
|
||||
),
|
||||
];
|
||||
let unreadable = [
|
||||
" ".to_string(),
|
||||
"\t".into(),
|
||||
"\n".into(),
|
||||
"\u{200b}".into(),
|
||||
"\u{200d}".into(),
|
||||
"\0".into(),
|
||||
"--".into(),
|
||||
"ok\u{200b}ok".into(),
|
||||
"ok\u{2028}ok".into(),
|
||||
"ok\0ok".into(),
|
||||
"x".repeat(201),
|
||||
"x".repeat(2 * 1024 * 1024),
|
||||
];
|
||||
let mut n = 0;
|
||||
for (build, why) in &origins {
|
||||
for bad in &unreadable {
|
||||
n += 1;
|
||||
let slug = format!("o-{n}");
|
||||
assert_eq!(
|
||||
refusal(w.entities.put(with(&slug, build(bad.clone())))),
|
||||
StoreRefusal::Malformed {
|
||||
key: format!("entity:{slug}"),
|
||||
why
|
||||
},
|
||||
"{why}: {:?}",
|
||||
bad.chars().take(12).collect::<String>()
|
||||
);
|
||||
assert!(w.entities.get(&eid(&slug)).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
for (m, (build, _)) in origins.iter().enumerate() {
|
||||
for (k, good) in ["x".repeat(200), "two words, 2 more".to_string()]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
w.entities
|
||||
.put(with(&format!("g-{}", 2 * m + k), build(good)))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
171
crates/weksa/tests/format_refused.txt
Normal file
171
crates/weksa/tests/format_refused.txt
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Cf minus ZWNJ/ZWJ, plus U+2028 and U+2029; Python 3.13 unicodedata 15.1.0 over 0..10FFFF
|
||||
AD
|
||||
600
|
||||
601
|
||||
602
|
||||
603
|
||||
604
|
||||
605
|
||||
61C
|
||||
6DD
|
||||
70F
|
||||
890
|
||||
891
|
||||
8E2
|
||||
180E
|
||||
200B
|
||||
200E
|
||||
200F
|
||||
2028
|
||||
2029
|
||||
202A
|
||||
202B
|
||||
202C
|
||||
202D
|
||||
202E
|
||||
2060
|
||||
2061
|
||||
2062
|
||||
2063
|
||||
2064
|
||||
2066
|
||||
2067
|
||||
2068
|
||||
2069
|
||||
206A
|
||||
206B
|
||||
206C
|
||||
206D
|
||||
206E
|
||||
206F
|
||||
FEFF
|
||||
FFF9
|
||||
FFFA
|
||||
FFFB
|
||||
110BD
|
||||
110CD
|
||||
13430
|
||||
13431
|
||||
13432
|
||||
13433
|
||||
13434
|
||||
13435
|
||||
13436
|
||||
13437
|
||||
13438
|
||||
13439
|
||||
1343A
|
||||
1343B
|
||||
1343C
|
||||
1343D
|
||||
1343E
|
||||
1343F
|
||||
1BCA0
|
||||
1BCA1
|
||||
1BCA2
|
||||
1BCA3
|
||||
1D173
|
||||
1D174
|
||||
1D175
|
||||
1D176
|
||||
1D177
|
||||
1D178
|
||||
1D179
|
||||
1D17A
|
||||
E0001
|
||||
E0020
|
||||
E0021
|
||||
E0022
|
||||
E0023
|
||||
E0024
|
||||
E0025
|
||||
E0026
|
||||
E0027
|
||||
E0028
|
||||
E0029
|
||||
E002A
|
||||
E002B
|
||||
E002C
|
||||
E002D
|
||||
E002E
|
||||
E002F
|
||||
E0030
|
||||
E0031
|
||||
E0032
|
||||
E0033
|
||||
E0034
|
||||
E0035
|
||||
E0036
|
||||
E0037
|
||||
E0038
|
||||
E0039
|
||||
E003A
|
||||
E003B
|
||||
E003C
|
||||
E003D
|
||||
E003E
|
||||
E003F
|
||||
E0040
|
||||
E0041
|
||||
E0042
|
||||
E0043
|
||||
E0044
|
||||
E0045
|
||||
E0046
|
||||
E0047
|
||||
E0048
|
||||
E0049
|
||||
E004A
|
||||
E004B
|
||||
E004C
|
||||
E004D
|
||||
E004E
|
||||
E004F
|
||||
E0050
|
||||
E0051
|
||||
E0052
|
||||
E0053
|
||||
E0054
|
||||
E0055
|
||||
E0056
|
||||
E0057
|
||||
E0058
|
||||
E0059
|
||||
E005A
|
||||
E005B
|
||||
E005C
|
||||
E005D
|
||||
E005E
|
||||
E005F
|
||||
E0060
|
||||
E0061
|
||||
E0062
|
||||
E0063
|
||||
E0064
|
||||
E0065
|
||||
E0066
|
||||
E0067
|
||||
E0068
|
||||
E0069
|
||||
E006A
|
||||
E006B
|
||||
E006C
|
||||
E006D
|
||||
E006E
|
||||
E006F
|
||||
E0070
|
||||
E0071
|
||||
E0072
|
||||
E0073
|
||||
E0074
|
||||
E0075
|
||||
E0076
|
||||
E0077
|
||||
E0078
|
||||
E0079
|
||||
E007A
|
||||
E007B
|
||||
E007C
|
||||
E007D
|
||||
E007E
|
||||
E007F
|
||||
|
|
@ -22,6 +22,7 @@ fn cast() -> Source {
|
|||
Source::CastAuthored {
|
||||
persona: "p".into(),
|
||||
fixture: "f".into(),
|
||||
author: "a".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,17 @@ fn corpus_keys_are_canonical() {
|
|||
}
|
||||
// An alias of a stored key is not a corpus item id at all.
|
||||
assert!(weksa::CorpusItemId::parse("item:nibu:01").is_err());
|
||||
// The last free number is handed out; past u32::MAX the slug has no free
|
||||
// key, and asking neither wraps nor panics.
|
||||
store
|
||||
.add(item("item:b:4294967294", "one before the last"), &ents(&d))
|
||||
.unwrap();
|
||||
assert_eq!(store.next_key("b").unwrap(), "item:b:4294967295");
|
||||
let full = "a".repeat(48);
|
||||
assert_eq!(
|
||||
refusal(store.next_key(&full)),
|
||||
malformed(&full, "slug has no free key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -263,6 +274,322 @@ fn corpus_admission_rules() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s6_reads_text_under_compatibility_case_folding() {
|
||||
let d = dir();
|
||||
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||
// Each pair is one text to the key: fullwidth, ligature, superscript and
|
||||
// circled-digit compatibility forms and full case folds (straße, STRASSE).
|
||||
let pairs = [
|
||||
("Brokkr", "\u{ff22}\u{ff52}\u{ff4f}\u{ff4b}\u{ff4b}\u{ff52}"),
|
||||
("fine", "\u{fb01}ne"),
|
||||
("room 2", "room \u{b2}"),
|
||||
("gate 1", "gate \u{2460}"),
|
||||
("stra\u{df}e", "STRASSE"),
|
||||
(
|
||||
"\u{3a3}\u{3af}\u{3c3}\u{3c5}\u{3c6}\u{3bf}\u{3c2}",
|
||||
"\u{3c3}\u{3af}\u{3c3}\u{3c5}\u{3c6}\u{3bf}\u{3c3}",
|
||||
),
|
||||
// The first NFKC step: a compatibility letter folds only after it is
|
||||
// mapped (math-bold S has no case fold of its own; black-letter H is
|
||||
// not a case variant of h until NFKC reads it as H).
|
||||
(
|
||||
"sigrun",
|
||||
"\u{1d412}\u{1d422}\u{1d420}\u{1d42b}\u{1d42e}\u{1d427}",
|
||||
),
|
||||
("h", "\u{210c}"),
|
||||
// The second NFKC step: a full case fold can leave a decomposed or
|
||||
// reordered sequence that only a second NFKC composes to the same key
|
||||
// (dialytika tonos, caron under line, macron below).
|
||||
("\u{390}", "\u{3aa}\u{301}"),
|
||||
("\u{3b0}", "\u{3ab}\u{301}"),
|
||||
("\u{1e96}\u{30c}", "\u{21e}\u{331}"),
|
||||
];
|
||||
for (n, (a, b)) in pairs.into_iter().enumerate() {
|
||||
let (first, second) = (
|
||||
format!("item:s6:{}", 2 * n + 1),
|
||||
format!("item:s6:{}", 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:?}"
|
||||
);
|
||||
assert!(store.get(&second).unwrap().is_none());
|
||||
}
|
||||
// A decomposed text never reaches the key: the door refuses it as not NFC.
|
||||
assert_eq!(
|
||||
refusal(store.add(item("item:s6:90", "Bro\u{308}kkr"), &ents(&d))),
|
||||
StoreRefusal::NotNfc {
|
||||
key: "item:s6:90".into()
|
||||
}
|
||||
);
|
||||
// Cyrillic a is not Latin a: homoglyphs across scripts stay apart.
|
||||
store.add(item("item:s6:91", "cat"), &ents(&d)).unwrap();
|
||||
store
|
||||
.add(item("item:s6:92", "c\u{430}t"), &ents(&d))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s6_keeps_tone_marks_and_viramas_apart() {
|
||||
let d = dir();
|
||||
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||
// Minimal pairs that differ only by a combining mark: Thai mai tho and mai
|
||||
// ek on one word, Thai tone on a short vowel, a Devanagari virama.
|
||||
let pairs = [
|
||||
("\u{e19}\u{e49}\u{e33}", "\u{e19}\u{e33}"),
|
||||
("\u{e44}\u{e21}\u{e49}", "\u{e44}\u{e21}\u{e48}"),
|
||||
("\u{915}\u{94d}\u{92f}", "\u{915}\u{92f}"),
|
||||
];
|
||||
for (n, (a, b)) in pairs.into_iter().enumerate() {
|
||||
store
|
||||
.add(item(&format!("item:mk:{}", 2 * n + 1), a), &ents(&d))
|
||||
.unwrap_or_else(|e| panic!("{a:?}: {e}"));
|
||||
store
|
||||
.add(item(&format!("item:mk:{}", 2 * n + 2), b), &ents(&d))
|
||||
.unwrap_or_else(|e| panic!("{b:?} held by {a:?}: {e}"));
|
||||
}
|
||||
// A mark alone is still no text.
|
||||
for (n, text) in ["\u{e49}", "\u{94d}"].into_iter().enumerate() {
|
||||
let key = format!("item:mk:{}", 90 + n);
|
||||
assert_eq!(
|
||||
refusal(store.add(item(&key, text), &ents(&d))),
|
||||
malformed(&key, "text has no alphanumeric character")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attestation_holds_something_readable() {
|
||||
let d = dir();
|
||||
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||
let s = |x: &str| x.to_string();
|
||||
type Build = fn(String) -> Source;
|
||||
let builders: [(Build, &'static str); 9] = [
|
||||
(
|
||||
|v| Source::CastAuthored {
|
||||
persona: v,
|
||||
fixture: "f".into(),
|
||||
author: "a".into(),
|
||||
},
|
||||
"a cast-authored source names its persona",
|
||||
),
|
||||
(
|
||||
|v| Source::CastAuthored {
|
||||
persona: "p".into(),
|
||||
fixture: v,
|
||||
author: "a".into(),
|
||||
},
|
||||
"a cast-authored source names its fixture",
|
||||
),
|
||||
(
|
||||
|v| Source::CastAuthored {
|
||||
persona: "p".into(),
|
||||
fixture: "f".into(),
|
||||
author: v,
|
||||
},
|
||||
"a cast-authored source names its author",
|
||||
),
|
||||
(
|
||||
|v| Source::OperatorApproved { note: v },
|
||||
"an operator-approved source carries its note",
|
||||
),
|
||||
(
|
||||
|v| Source::PublicDomain {
|
||||
work: v,
|
||||
author: "a".into(),
|
||||
edition_url: "u".into(),
|
||||
},
|
||||
"a public-domain source names its work",
|
||||
),
|
||||
(
|
||||
|v| Source::PublicDomain {
|
||||
work: "w".into(),
|
||||
author: v,
|
||||
edition_url: "u".into(),
|
||||
},
|
||||
"a public-domain source names its author",
|
||||
),
|
||||
(
|
||||
|v| Source::PublicDomain {
|
||||
work: "w".into(),
|
||||
author: "a".into(),
|
||||
edition_url: v,
|
||||
},
|
||||
"a public-domain source names its edition url",
|
||||
),
|
||||
(
|
||||
|v| Source::ModelWritten {
|
||||
family: ModelFamily::Unrecorded,
|
||||
generator: Some(v),
|
||||
prompt_digest: None,
|
||||
},
|
||||
"a model-written source names a non-empty generator",
|
||||
),
|
||||
(
|
||||
|v| Source::ModelWritten {
|
||||
family: ModelFamily::Unrecorded,
|
||||
generator: None,
|
||||
prompt_digest: Some(v),
|
||||
},
|
||||
"a model-written source carries a non-empty prompt digest",
|
||||
),
|
||||
];
|
||||
let unreadable = [
|
||||
s(" "),
|
||||
s("\t"),
|
||||
s("\n"),
|
||||
s("\u{200b}"),
|
||||
s("\u{200d}"),
|
||||
s("\0"),
|
||||
s("--"),
|
||||
s("\u{e49}"),
|
||||
s("ok\u{200b}ok"),
|
||||
s("ok\u{200d}ok"),
|
||||
s("ok\u{2028}ok"),
|
||||
s("ok\nok"),
|
||||
s("ok\0ok"),
|
||||
"x".repeat(201),
|
||||
"x".repeat(2 * 1024 * 1024),
|
||||
];
|
||||
let mut n = 0;
|
||||
for (build, why) in &builders {
|
||||
for bad in &unreadable {
|
||||
n += 1;
|
||||
let key = format!("item:att:{n}");
|
||||
let refused = item_in(&key, &format!("line {n}"), "scene-1", build(bad.clone()));
|
||||
assert_eq!(
|
||||
refusal(store.add(refused, &ents(&d))),
|
||||
malformed(&key, why),
|
||||
"{why}: {:?}",
|
||||
bad.chars().take(12).collect::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(store.all().unwrap().is_empty());
|
||||
// 200 bytes and plain words with spaces attest; 201 does not.
|
||||
for (m, (build, _)) in builders.iter().enumerate() {
|
||||
for (k, good) in ["x".repeat(200), s("two words, 2 more")]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let key = format!("item:att-ok:{}", 2 * m + k + 1);
|
||||
store
|
||||
.add(
|
||||
item_in(&key, &format!("fine {m} {k}"), "scene-1", build(good)),
|
||||
&ents(&d),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{key}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_with_no_alphanumeric_character_is_refused() {
|
||||
let d = dir();
|
||||
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||
for (n, text) in ["...", "?!", " ", "\u{2014}\u{2014}", "\u{ff01}"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let key = format!("item:p:{}", n + 1);
|
||||
assert_eq!(
|
||||
refusal(store.add(item(&key, text), &ents(&d))),
|
||||
malformed(&key, "text has no alphanumeric character"),
|
||||
"{text:?}"
|
||||
);
|
||||
}
|
||||
assert!(store.all().unwrap().is_empty());
|
||||
store.add(item("item:p:1", "a..."), &ents(&d)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_source_attests_with_non_empty_strings() {
|
||||
let d = dir();
|
||||
let mut store = CorpusStore::open(&d.path().join("c.cc")).unwrap();
|
||||
let s = |x: &str| x.to_string();
|
||||
let cast = |persona: &str, fixture: &str, author: &str| Source::CastAuthored {
|
||||
persona: s(persona),
|
||||
fixture: s(fixture),
|
||||
author: s(author),
|
||||
};
|
||||
let model = |generator: Option<&str>, digest: Option<&str>| Source::ModelWritten {
|
||||
family: ModelFamily::Unrecorded,
|
||||
generator: generator.map(s),
|
||||
prompt_digest: digest.map(s),
|
||||
};
|
||||
let public = |work: &str, author: &str, url: &str| Source::PublicDomain {
|
||||
work: s(work),
|
||||
author: s(author),
|
||||
edition_url: s(url),
|
||||
};
|
||||
let cases: [(Source, &'static str); 9] = [
|
||||
(
|
||||
cast("", "f", "a"),
|
||||
"a cast-authored source names its persona",
|
||||
),
|
||||
(
|
||||
cast("p", "", "a"),
|
||||
"a cast-authored source names its fixture",
|
||||
),
|
||||
(
|
||||
cast("p", "f", ""),
|
||||
"a cast-authored source names its author",
|
||||
),
|
||||
(
|
||||
Source::OperatorApproved { note: s("") },
|
||||
"an operator-approved source carries its note",
|
||||
),
|
||||
(
|
||||
public("", "a", "u"),
|
||||
"a public-domain source names its work",
|
||||
),
|
||||
(
|
||||
public("w", "", "u"),
|
||||
"a public-domain source names its author",
|
||||
),
|
||||
(
|
||||
public("w", "a", ""),
|
||||
"a public-domain source names its edition url",
|
||||
),
|
||||
(
|
||||
model(Some(""), None),
|
||||
"a model-written source names a non-empty generator",
|
||||
),
|
||||
(
|
||||
model(None, Some("")),
|
||||
"a model-written source carries a non-empty prompt digest",
|
||||
),
|
||||
];
|
||||
for (n, (source, why)) in cases.into_iter().enumerate() {
|
||||
let key = format!("item:prov:{}", n + 1);
|
||||
let refused = item_in(&key, &format!("line {n}"), "scene-1", source);
|
||||
assert_eq!(refusal(store.add(refused, &ents(&d))), malformed(&key, why));
|
||||
}
|
||||
assert!(store.all().unwrap().is_empty());
|
||||
// The same sources with every string present are admitted.
|
||||
for (n, source) in [
|
||||
cast("p", "f", "a"),
|
||||
Source::OperatorApproved { note: s("n") },
|
||||
Source::OperatorWritten,
|
||||
public("w", "a", "u"),
|
||||
model(Some("g"), Some("d")),
|
||||
model(None, None),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let key = format!("item:prov:{}", n + 1);
|
||||
let item = item_in(&key, &format!("line {n}"), "scene-1", source);
|
||||
assert_eq!(store.add(item, &ents(&d)).unwrap(), AddOutcome::Added);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s5_supersede_keeps_the_group() {
|
||||
let d = dir();
|
||||
|
|
@ -842,6 +1169,23 @@ fn the_committed_stores_load_and_hold_clean_data() {
|
|||
"no item claims a recorded human author"
|
||||
);
|
||||
assert_eq!(items.len(), 3);
|
||||
// Every committed entity attests, with non-empty strings, where it came
|
||||
// from, and a cast entity's fixture is a file of this repository.
|
||||
let entities = EntityStore::open(&root.join("data/entity/entity.cc"))
|
||||
.unwrap()
|
||||
.all()
|
||||
.unwrap();
|
||||
assert!(!entities.is_empty());
|
||||
for e in &entities {
|
||||
match &e.origin {
|
||||
weksa::entity::EntityOrigin::Cast { persona, fixture } => {
|
||||
assert!(!persona.is_empty(), "{}", e.id);
|
||||
assert!(root.join(fixture).is_file(), "{}: {fixture}", e.id);
|
||||
}
|
||||
weksa::entity::EntityOrigin::Live { persona } => assert!(!persona.is_empty()),
|
||||
weksa::entity::EntityOrigin::PublicDomain { work } => assert!(!work.is_empty()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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-29T14:59:18ZÄ-“entity:brokkr‘’¥en-US‘¦Brokkr<EFBFBD>¤Cast‘¦brokkr”«entity:nibu¬weksa.entity´2026-10-29T14:59:18ZÄ'“«entity:nibu‘’¥en-US‘¤Nibu<EFBFBD>¤Cast‘¤nibu
|
||||
“²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
|
||||
|
|
@ -180,7 +180,7 @@ operator-attested annotations alone. Who annotates is question
|
|||
carries its name spans (byte ranges of its text, each naming an entity and
|
||||
on extended grapheme cluster boundaries of its NFC text and equal to one of that
|
||||
entity's NFC forms in the item's language; an entity holds at most 8 languages
|
||||
of BCP 47-form tags, 8 forms each, a form at most 64 bytes and 4 words), and its speaker
|
||||
of BCP 47-form tags, 8 forms each, a form at most 64 bytes with no invisible format character), and its speaker
|
||||
and prior-turn speakers are entity ids. The packet store refuses a packet
|
||||
whose referent cites an entity the store lacks, and a packet with a
|
||||
`corpus_item` (required, typed `CorpusItemId`) that cites an entity neither annotated on that item nor one of
|
||||
|
|
|
|||
|
|
@ -121,12 +121,15 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"description": "A line from a cast member's hand-authored fixture.",
|
||||
"description": "A line from a cast member's hand-authored fixture, with the human who\nwrote it. A fixture that records no author is `ModelWritten` with family\n`Unrecorded`, never this (`corpus-labels-tell-truth`).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"CastAuthored": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"fixture": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -136,7 +139,8 @@
|
|||
},
|
||||
"required": [
|
||||
"persona",
|
||||
"fixture"
|
||||
"fixture",
|
||||
"author"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,18 +31,22 @@
|
|||
"description": "Where an entity was minted.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "From a cast member's fixture.",
|
||||
"description": "From a cast member's fixture: the persona and the fixture path the\nentity came from.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Cast": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fixture": {
|
||||
"type": "string"
|
||||
},
|
||||
"persona": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"persona"
|
||||
"persona",
|
||||
"fixture"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -73,9 +77,25 @@
|
|||
]
|
||||
},
|
||||
{
|
||||
"description": "Minted live by a persona before it names the entity.",
|
||||
"type": "string",
|
||||
"const": "Live"
|
||||
"description": "Minted live by the named persona before it names the entity.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Live": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"persona": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"persona"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Live"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -84,7 +104,7 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"forms": {
|
||||
"description": "1 to 64 bytes each, no control character, no edge whitespace, none repeated.",
|
||||
"description": "1 to 64 bytes each, no control or invisible format character, no edge\nwhitespace, none repeated.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
|
|
|
|||
Loading…
Reference in a new issue