-
Notifications
You must be signed in to change notification settings - Fork 0
feat(semantic): bind exact spans as units; refuse language as identity #201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
72732f7
5344729
6afd650
650ea4a
57be20f
c8abf32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| [package] | ||
| name = "semantic_core" | ||
| description = "Span-grounded semantic units whose identity is never a language tag." | ||
| version.workspace = true | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| authors.workspace = true | ||
| repository.workspace = true | ||
| homepage.workspace = true | ||
| readme.workspace = true | ||
| keywords.workspace = true | ||
| categories.workspace = true | ||
| publish = false | ||
|
|
||
| [dependencies] | ||
| evidence_core = { path = "../evidence_core", version = "0.1.0" } | ||
|
|
||
| [lints] | ||
| workspace = true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| //! Fail-closed semantic-unit validation errors. | ||
|
|
||
| use std::fmt; | ||
|
|
||
| /// A fail-closed semantic-unit error. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum SemanticError { | ||
| /// A language tag was offered as the unit identity. | ||
| LanguageIsNotIdentity, | ||
| /// A language tag was empty. | ||
| EmptyLanguageTag, | ||
| /// A language tag was not a primary ISO 639 subtag with optional region. | ||
| InvalidLanguageTag, | ||
| } | ||
|
|
||
| impl fmt::Display for SemanticError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| let message = match self { | ||
| Self::LanguageIsNotIdentity => "language tag is not semantic-unit identity", | ||
| Self::EmptyLanguageTag => "empty language tag", | ||
| Self::InvalidLanguageTag => "invalid language tag", | ||
| }; | ||
| formatter.write_str(message) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for SemanticError {} | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::SemanticError; | ||
|
|
||
| #[test] | ||
| fn error_messages_are_stable() { | ||
| for (error, message) in [ | ||
| ( | ||
| SemanticError::LanguageIsNotIdentity, | ||
| "language tag is not semantic-unit identity", | ||
| ), | ||
| (SemanticError::EmptyLanguageTag, "empty language tag"), | ||
| (SemanticError::InvalidLanguageTag, "invalid language tag"), | ||
| ] { | ||
| assert_eq!(error.to_string(), message); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| #![forbid(unsafe_code)] | ||
| #![deny(missing_docs)] | ||
| //! Span-grounded semantic units whose identity is never a language tag. | ||
| //! | ||
| //! Language metadata may mark a unit unresolved or with a primary ISO 639 | ||
| //! subtag. Unresolved metadata does not retokenize or move the exact source | ||
| //! span. Equivalent Korean and English surfaces remain distinct units until a | ||
| //! later concept-alignment layer (ADR 0004 / ADR 0012) is validated. | ||
|
|
||
| mod error; | ||
| mod profile; | ||
| mod unit; | ||
|
|
||
| /// Fail-closed semantic-unit validation errors. | ||
| pub use error::SemanticError; | ||
| /// Language profile metadata, never unit identity. | ||
| pub use profile::LanguageProfile; | ||
| /// Exact-span identity of one semantic unit. | ||
| pub use unit::SemanticIdentity; | ||
| /// One exact-span semantic unit. | ||
| pub use unit::SemanticUnit; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| //! Language profiles are metadata, not unit identity. | ||
|
|
||
| use crate::error::SemanticError; | ||
|
|
||
| /// A language profile that may select tailoring without becoming identity. | ||
| #[derive(Clone, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum LanguageProfile { | ||
| /// No language metadata; span bounds stay the supplied exact coordinates. | ||
| Unresolved, | ||
| /// A canonical primary ISO 639 subtag with optional ISO 3166-1 region. | ||
| Tagged { | ||
| /// Lowercase `language` or `language-region` label. | ||
| tag: String, | ||
| }, | ||
| } | ||
|
|
||
| impl LanguageProfile { | ||
| /// Return the unresolved profile. | ||
| #[must_use] | ||
| pub const fn unresolved() -> Self { | ||
| Self::Unresolved | ||
| } | ||
|
|
||
| /// Parse a primary language subtag with an optional region. | ||
| /// | ||
| /// Accepts a syntactically valid two- or three-letter primary language subtag, | ||
| /// followed by a hyphen and either an ISO 3166-1 alpha-2 region or a | ||
| /// three-digit UN M.49 numeric region (Phillips & Davis, 2009, section | ||
| /// 2.2.4; for example `es-419`). The stored form is lowercase. Empty tags | ||
| /// and any other shape fail closed. The tag never becomes a | ||
| /// [`crate::SemanticIdentity`]. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`SemanticError::EmptyLanguageTag`] or | ||
| /// [`SemanticError::InvalidLanguageTag`]. | ||
| pub fn parse_bcp47(tag: &str) -> Result<Self, SemanticError> { | ||
| if tag.is_empty() { | ||
| return Err(SemanticError::EmptyLanguageTag); | ||
| } | ||
| let canonical = tag.to_ascii_lowercase(); | ||
| if !is_primary_language_tag(&canonical) { | ||
| return Err(SemanticError::InvalidLanguageTag); | ||
| } | ||
| Ok(Self::Tagged { tag: canonical }) | ||
| } | ||
|
|
||
| /// Return whether this profile is unresolved. | ||
| #[must_use] | ||
| pub const fn is_unresolved(&self) -> bool { | ||
| matches!(self, Self::Unresolved) | ||
| } | ||
|
|
||
| /// Return the stable profile label. | ||
| #[must_use] | ||
| pub fn as_str(&self) -> &str { | ||
| match self { | ||
| Self::Unresolved => "unresolved", | ||
| Self::Tagged { tag } => tag, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Validate a primary language tag per RFC 5646 sections 2.2.1 and 2.2.4. | ||
| /// | ||
| /// A region may be an ISO 3166-1 alpha-2 code or a UN M.49 three-digit | ||
| /// numeric subtag; both are accepted here so regional variants such as | ||
| /// Latin-American Spanish resolve instead of failing closed. | ||
| fn is_primary_language_tag(tag: &str) -> bool { | ||
| match tag.split_once('-') { | ||
| None => is_letter_run(tag, 2, 3), | ||
| Some((language, region)) => { | ||
| is_letter_run(language, 2, 3) && is_registered_region_subtag(region) | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+70
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Language parser rejects script subtags
Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+70
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Language subtag accepted by shape, not registry
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| fn is_letter_run(value: &str, min: usize, max: usize) -> bool { | ||
| (min..=max).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_lowercase()) | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| // IANA Language Subtag Registry, File-Date 2026-08-08. Private-use region | ||
| // ranges and private-use records are intentionally excluded because this | ||
| // profile accepts only reproducible registered region metadata. | ||
| const REGISTERED_ALPHA2_REGION_SUBTAGS: &str = "ac ad ae af ag ai al am an ao aq ar as at au aw ax az ba bb bd be bf bg bh bi bj bl bm bn bo bq br bs bt bu bv bw by bz ca cc cd cf cg ch ci ck cl cm cn co cp cq cr cs cu cv cw cx cy cz dd de dg dj dk dm do dz ea ec ee eg eh er es et eu ez fi fj fk fm fo fr fx ga gb gd ge gf gg gh gi gl gm gn gp gq gr gs gt gu gw gy hk hm hn hr ht hu ic id ie il im in io iq ir is it je jm jo jp ke kg kh ki km kn kp kr kw ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mf mg mh mk ml mm mn mo mp mq mr ms mt mu mv mw mx my mz na nc ne nf ng ni nl no np nr nt nu nz om pa pe pf pg ph pk pl pm pn pr ps pt pw py qa re ro rs ru rw sa sb sc sd se sg sh si sj sk sl sm sn so sr ss st su sv sx sy sz ta tc td tf tg th tj tk tl tm tn to tp tr tt tv tw tz ua ug um un us uy uz va vc ve vg vi vn vu wf ws yd ye yt yu za zm zr zw"; | ||
|
|
||
| const REGISTERED_NUMERIC_REGION_SUBTAGS: &[&str] = &[ | ||
| "001", "002", "003", "005", "009", "011", "013", "014", "015", "017", "018", "019", "021", | ||
| "029", "030", "034", "035", "039", "053", "054", "057", "061", "142", "143", "145", "150", | ||
| "151", "154", "155", "202", "419", | ||
| ]; | ||
|
|
||
| fn is_registered_region_subtag(region: &str) -> bool { | ||
| if region.len() == 2 { | ||
| return REGISTERED_ALPHA2_REGION_SUBTAGS | ||
| .split_ascii_whitespace() | ||
| .any(|candidate| candidate == region); | ||
| } | ||
| region.len() == 3 | ||
| && region.bytes().all(|byte| byte.is_ascii_digit()) | ||
| && REGISTERED_NUMERIC_REGION_SUBTAGS.contains(®ion) | ||
| } | ||
|
Comment on lines
+70
to
+103
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Language tag validation matches tests
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::LanguageProfile; | ||
| use crate::error::SemanticError; | ||
|
|
||
| #[test] | ||
| fn parse_accepts_primary_and_region_and_rejects_noise() { | ||
| let korean = LanguageProfile::parse_bcp47("KO").expect("ko"); | ||
| assert_eq!(korean.as_str(), "ko"); | ||
| assert!(!korean.is_unresolved()); | ||
| let tagged = LanguageProfile::parse_bcp47("en-US").expect("en-us"); | ||
| assert_eq!(tagged.as_str(), "en-us"); | ||
| let numeric = LanguageProfile::parse_bcp47("es-419").expect("es-419"); | ||
| assert_eq!(numeric.as_str(), "es-419"); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("ES-419") | ||
| .expect("upper") | ||
| .as_str(), | ||
| "es-419" | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("en-GB") | ||
| .expect("registered alpha-2 region") | ||
| .as_str(), | ||
| "en-gb" | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("yue").expect("yue").as_str(), | ||
| "yue" | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("").unwrap_err(), | ||
| SemanticError::EmptyLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("english").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("en-US-x-private").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("e").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("en_us").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("e1").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("en-u1").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("en-u").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("e-us").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("es-41a").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("es-41").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47("es-4199").unwrap_err(), | ||
| SemanticError::InvalidLanguageTag | ||
| ); | ||
| for tag in ["en-aa", "en-XX", "en-QM", "en-abc", "en-999"] { | ||
| assert_eq!( | ||
| LanguageProfile::parse_bcp47(tag), | ||
| Err(SemanticError::InvalidLanguageTag), | ||
| "private or unknown region must fail closed: {tag}" | ||
| ); | ||
| } | ||
| let unresolved = LanguageProfile::unresolved(); | ||
| assert!(unresolved.is_unresolved()); | ||
| assert_eq!(unresolved.as_str(), "unresolved"); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.