From 3cfc75c1ae283ec001a1004faa88e199bdcd605f Mon Sep 17 00:00:00 2001 From: Weksa Date: Fri, 13 Nov 2026 15:56:28 +0000 Subject: [PATCH 1/4] form: language tag is BCP 47 well-formed on singletons and short subtags; pin private-use case Reject a singleton with nothing after it (x, en-x, en-u), singleton directly after singleton, one-digit and two-digit non-region subtags (en-0, en-12), and uppercase private-use subtags (x-AB). Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/form.rs | 63 +++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/crates/weksa/src/form.rs b/crates/weksa/src/form.rs index 2819ea6..28858e4 100644 --- a/crates/weksa/src/form.rs +++ b/crates/weksa/src/form.rs @@ -111,25 +111,53 @@ pub(crate) fn is_language_tag(s: &str) -> bool { if !first_ok { return false; } - let mut singleton = first == "x"; - subtags.all(|t| { + // `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 alpha = t.bytes().all(|b| b.is_ascii_alphabetic()); - let canonical = if singleton { - t.bytes().all(|b| !b.is_ascii_uppercase()) - } else if alpha && t.len() == 2 { - 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()) + 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 { - t.bytes().all(|b| !b.is_ascii_uppercase()) - }; - singleton |= t.len() == 1; - canonical - }) + 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 `-`. @@ -243,7 +271,7 @@ mod tests { 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", "en-u-nu-latn", "ja-JP-x-a1", + "x-private", "x-ab", "x-a", "en-u-nu-latn", "ja-JP-x-a1", ] { assert!(is_language_tag(good), "{good}"); } @@ -251,7 +279,8 @@ mod tests { "", "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}", + "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", ] { assert!(!is_language_tag(bad), "{bad}"); } -- 2.47.3 From dbe1038682732869c0b2358917ccda5ba83a5131 Mon Sep 17 00:00:00 2001 From: Weksa Date: Fri, 13 Nov 2026 15:56:53 +0000 Subject: [PATCH 2/4] context: pin indented() separators directly; mint prose matches the enforced entry cap indented() is asserted for CR, VT, FF, NEL, LS, PS and CRLF without str::lines. The mint instructions said 1 to 8 entries but only 64 per language is enforced; the prose now says what is enforced. PROMPT_SOURCE_DIGEST is computed from the source, so it moves with this edit and needs no literal update. Co-Authored-By: Claude Sonnet 5.5 --- crates/weksa/src/context.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/weksa/src/context.rs b/crates/weksa/src/context.rs index de2ff8f..93a274b 100644 --- a/crates/weksa/src/context.rs +++ b/crates/weksa/src/context.rs @@ -93,7 +93,7 @@ Answer with one JSON object and nothing else: {\"text\": \"...\"}, the continuat pub const MINT_ONTOLOGY_INSTRUCTIONS: &str = "\ Below are lines written in one language (the language line), the features an ontology condition may test, and the entries that language's ontology already has. Draft new entries: choices this language's culture makes, for a speaker in a stance and scene, about word, grammar, discourse, sound or script, that the lines show. Answer with one JSON object and nothing else: {\"entries\": [{\"layer\": \"...\", \"when\": [...], \"choice\": \"...\", \"examples\": [...]}]}. No code fence, no commentary. -Give 1 to 8 entries. layer is exactly one of Lexicon, Grammar, Discourse, Phonology, Orthography. when holds at most 4 conditions, each {\"slot\": \"Stance\" | \"Discourse\" | \"Scene\", \"feature\": \"\", \"test\": ...}, where is the id printed before the colon of a listed feature (\"feature:10\", never its name) and its slot is one that feature lists. test is {\"AtLeast\": }, {\"AtMost\": } or {\"AtMostOrUnset\": } on a grade feature, or {\"Is\": {\"Option\": \"