Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ boundaries above remain the target modular MSA architecture.
| Rust crate | Initial responsibility |
|---|---|
| `evidence_core` | immutable evidence domain primitives |
| `semantic_core` | span-grounded semantic units; language is not identity |
| `temporal_core` | typed clocks, intervals, and temporal reasoning |
| `event_core` | event instances, mentions, roles, and provenance |
| `relation_graph` | typed relations and forward-transition validation |
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Added

- `semantic_core` binds exact `evidence_core` source spans as semantic units. Language profiles are `unresolved` or a primary ISO 639 subtag with an IANA-registered ISO 3166-1 alpha-2 or UN M.49 region (RFC 5646; IANA File-Date 2026-08-08); private-use and unknown regions fail closed. Unresolved metadata keeps the caller-supplied Korean `측정` span and does not retokenize. `SemanticIdentity::from_language_tag` fails closed. Korean and English report sentences remain distinct units. Not concept alignment, not invariance, not a topic estimator (ADR 0020; issue #168). The APA register cites RFC 5646 once, in the Unicode/language-tags section; the slice-specific note remains `docs/research/span-grounded-semantic-units.md`.
- `corpus_background` identity gate: corpus-level background wording is not unique latent content or a state transition; recovery tests distinguish background evidence from unique content.
- `modality_source` identity gate: non-lexical modality is not unique lexical content or a state transition; recovery tests keep modality evidence distinct from unique content.
- `copied_text` identity gate: copied-text residue is not unique latent content or a state transition; recovery tests distinguish copied-text evidence from genuinely new content.
Expand Down Expand Up @@ -155,6 +156,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Changed

- The docstring discovery test compares crate-root names to `EXPECTED_CRATES` instead of a hardcoded count of 10, so `semantic_core` is required and an unapproved extra crate fails closed.
- The LineageWeave temporal-context read exchange no longer emits a fabricated
`idempotency-key`; that header remains reserved for retryable write/export
operations with a caller-owned operation key.
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/evidence_core",
"crates/semantic_core",
"crates/temporal_core",
"crates/event_core",
"crates/relation_graph",
Expand Down Expand Up @@ -51,6 +52,7 @@ members = [
]
default-members = [
"crates/evidence_core",
"crates/semantic_core",
"crates/temporal_core",
"crates/event_core",
"crates/relation_graph",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ evidence identifiers and source records.

```text
crates/evidence_core
crates/semantic_core
crates/temporal_core
crates/event_core
crates/relation_graph
Expand Down
20 changes: 20 additions & 0 deletions crates/semantic_core/Cargo.toml
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
47 changes: 47 additions & 0 deletions crates/semantic_core/src/error.rs
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);
}
}
}
21 changes: 21 additions & 0 deletions crates/semantic_core/src/lib.rs
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;
194 changes: 194 additions & 0 deletions crates/semantic_core/src/profile.rs
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 })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// 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

@devin-ai-integration devin-ai-integration Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Language parser rejects script subtags

is_primary_language_tag accepts only a 2-3 letter primary subtag with an optional alpha-2 or 3-digit region. Tags carrying a script subtag such as zh-Hans return InvalidLanguageTag. ADR 0020 scopes this as an intentional limitation, so callers must supply region-only tags.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +70 to +77

@devin-ai-integration devin-ai-integration Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Language subtag accepted by shape, not registry

is_primary_language_tag validates the primary subtag only via is_letter_run(language, 2, 3), so zz, qq, or private-use qaa become resolved profiles. Regions are strictly registry-checked, but the language subtag is not. This contradicts ADR 0020's claim that private-use language tags fail closed, though its non-goal disclaiming complete language identification leaves intent ambiguous.

Open in Devin Review

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())
}
Comment thread
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(&region)
}
Comment on lines +70 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Language tag validation matches tests

parse_bcp47 in crates/semantic_core/src/profile.rs splits on the first hyphen, validates a 2-3 letter primary subtag, and checks the region against a registered alpha-2 list or a whitelisted M.49 numeric set. All listed test cases (including en-US-x-private and en-999 rejection) are consistent with this logic.

Open in Devin Review

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");
}
}
Loading
Loading