-
Notifications
You must be signed in to change notification settings - Fork 0
feat(evidence): define schema-bound extraction contract #209
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
Merged
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
1751f3f
test(evidence): define schema-bound extraction contract
seonghobae f088a84
test(evidence): canonicalize extraction schema regression formatting
seonghobae 9bd08ae
feat(evidence): add bounded extraction schema contracts
seonghobae a76a3bc
style(evidence): apply canonical rustfmt
seonghobae 2bf2166
test(evidence): satisfy fail-closed clippy contracts
seonghobae f7d0c04
style(evidence): apply canonical test formatting
seonghobae 512bc3e
docs(changelog): record extraction schema contract
seonghobae d56090a
docs(changelog): preserve revocation evidence wording
seonghobae 8fdb1b9
docs(changelog): keep TLS policy text unchanged
seonghobae b336a36
test(evidence): require explicit extraction normalization
seonghobae 0325ad3
feat(evidence): bind extraction normalization rules
seonghobae 04299d1
feat(evidence): export extraction normalization contract
seonghobae f6901af
style(evidence): apply normalization regression rustfmt
seonghobae a527c43
style(evidence): apply canonical normalization formatting
seonghobae 69bc738
style(evidence): apply canonical extraction export formatting
seonghobae 6e526fa
docs(adr): bind extraction schema version semantics
seonghobae fd34bb5
test(evidence): require extraction schema standard errors
seonghobae ef02ee8
fix(evidence): expose extraction schema standard errors
seonghobae 40988c3
docs(changelog): record extraction schema error contract
seonghobae 2fdc380
test(evidence): make identifier error contract scope-neutral
seonghobae 4b1164c
test(evidence): require canonical source-channel set identity
seonghobae f483af9
fix(evidence): canonicalize extraction source-channel sets
seonghobae a1535d8
docs(evidence): bind ExtractionSchema doctoring authority
seonghobae 36de84d
test(evidence): reject contradictory field cardinality
seonghobae c5a87a6
style(evidence): format cardinality regression
seonghobae 212d48d
fix(evidence): reject contradictory field cardinality
seonghobae 67545c6
test(evidence): bind cardinality failure type
seonghobae f2e6f2d
test(evidence): cover cardinality error contract
seonghobae c38b966
docs(evidence): bind cardinality presence semantics
seonghobae 61a89d7
chore(stack): realign extraction schema with current main
seonghobae 6a17e5f
test(docs): pin RFC 5280 author identity
seonghobae b35d739
fix(docs): correct RFC 5280 author identity
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,297 @@ | ||
| //! Versioned schema contracts for typed evidence extraction. | ||
| //! | ||
| //! These value objects describe what may be extracted and which reviewed | ||
| //! evidence channels may support each field. They do not read browser data, | ||
| //! disclose protected values, persist artifacts, execute models, or grant any | ||
| //! browser, network, secret, approval, or storage authority. | ||
|
|
||
| use std::{collections::BTreeSet, fmt}; | ||
|
|
||
| /// Maximum encoded byte length for an extraction schema or field identifier. | ||
| pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; | ||
| /// Maximum number of fields admitted by one extraction schema. | ||
| pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256; | ||
|
|
||
| /// The typed value contract for one extracted field. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] | ||
| pub enum ExtractionValueType { | ||
| /// Bounded textual data. | ||
| Text, | ||
| /// A whole-number value. | ||
| Integer, | ||
| /// A decimal numeric value. | ||
| Decimal, | ||
| /// A boolean value. | ||
| Boolean, | ||
| /// A timestamp value whose concrete normalization is defined by the schema version. | ||
| Timestamp, | ||
| } | ||
|
|
||
| /// The number of values admitted for one extracted field. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] | ||
| pub enum ExtractionCardinality { | ||
| /// Exactly one value is admitted. | ||
| One, | ||
| /// Zero or one value is admitted. | ||
| ZeroOrOne, | ||
| /// A bounded collection may be admitted by a later extraction runtime. | ||
| Many, | ||
| } | ||
|
|
||
| /// A reviewed evidence channel that may support an extracted value. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] | ||
| pub enum ExtractionSourceChannel { | ||
| /// A semantic browser node with an independently validated identity. | ||
| SemanticNode, | ||
| /// Embedded structured metadata such as JSON-LD, RDFa, or Microdata. | ||
| StructuredData, | ||
| /// A bounded table-cell observation. | ||
| TableCell, | ||
| /// A bounded network response whose origin and response identity are independently verified. | ||
| NetworkResponse, | ||
| /// A separately approved model interpretation backed by explicit evidence identifiers. | ||
| ModelInterpretation, | ||
| } | ||
|
|
||
| /// A deterministic normalization rule declared for one extracted field. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] | ||
| pub enum ExtractionNormalizationRule { | ||
| /// Preserve the typed source value without text normalization. | ||
| Verbatim, | ||
| /// Trim surrounding whitespace from a textual value. | ||
| TrimTextWhitespace, | ||
| /// Normalize a timestamp into an RFC 3339 UTC representation. | ||
| Rfc3339Utc, | ||
| } | ||
|
|
||
| /// A validation failure while constructing an extraction schema contract. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum ExtractionSchemaError { | ||
| /// A schema or field identifier was empty or outside the accepted identifier grammar. | ||
| InvalidIdentifier, | ||
| /// An identifier or field collection exceeded its bounded limit. | ||
| LimitExceeded, | ||
| /// A field's required flag contradicted its declared cardinality. | ||
| InvalidCardinalityRequirement, | ||
| /// A field did not declare any reviewed source channel. | ||
| MissingSourceChannel, | ||
| /// A field declared the same source channel more than once. | ||
| DuplicateSourceChannel, | ||
| /// The declared normalization rule was incompatible with the field value type. | ||
| InvalidNormalizationRule, | ||
| /// A schema did not contain any field definitions. | ||
| MissingField, | ||
| /// A schema declared the same field identifier more than once. | ||
| DuplicateField, | ||
| } | ||
|
|
||
| impl fmt::Display for ExtractionSchemaError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter.write_str(match self { | ||
| Self::InvalidIdentifier => "invalid extraction schema or field identifier", | ||
| Self::LimitExceeded => "extraction schema limit exceeded", | ||
| Self::InvalidCardinalityRequirement => { | ||
| "extraction field required flag is incompatible with the declared cardinality" | ||
| } | ||
| Self::MissingSourceChannel => "extraction field requires at least one source channel", | ||
| Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", | ||
| Self::InvalidNormalizationRule => { | ||
| "extraction normalization rule is incompatible with the field value type" | ||
| } | ||
| Self::MissingField => "extraction schema requires at least one field", | ||
| Self::DuplicateField => "extraction schema contains a duplicate field identifier", | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for ExtractionSchemaError {} | ||
|
|
||
| /// One typed field declared by a versioned extraction schema. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct ExtractionField { | ||
| identifier: String, | ||
| value_type: ExtractionValueType, | ||
| cardinality: ExtractionCardinality, | ||
| required: bool, | ||
| normalization_rule: ExtractionNormalizationRule, | ||
| source_channels: Vec<ExtractionSourceChannel>, | ||
| } | ||
|
|
||
| impl ExtractionField { | ||
| /// Validate and construct one extraction field contract with verbatim normalization. | ||
| pub fn new( | ||
| identifier: &str, | ||
| value_type: ExtractionValueType, | ||
| cardinality: ExtractionCardinality, | ||
| required: bool, | ||
| source_channels: &[ExtractionSourceChannel], | ||
| ) -> Result<Self, ExtractionSchemaError> { | ||
| Self::new_with_normalization( | ||
| identifier, | ||
| value_type, | ||
| cardinality, | ||
| required, | ||
| ExtractionNormalizationRule::Verbatim, | ||
| source_channels, | ||
| ) | ||
| } | ||
|
|
||
| /// Validate and construct one extraction field with an explicit normalization rule. | ||
| pub fn new_with_normalization( | ||
| identifier: &str, | ||
| value_type: ExtractionValueType, | ||
| cardinality: ExtractionCardinality, | ||
| required: bool, | ||
| normalization_rule: ExtractionNormalizationRule, | ||
| source_channels: &[ExtractionSourceChannel], | ||
| ) -> Result<Self, ExtractionSchemaError> { | ||
| validate_identifier(identifier)?; | ||
|
|
||
| let cardinality_requirement_is_compatible = match cardinality { | ||
| ExtractionCardinality::One => required, | ||
| ExtractionCardinality::ZeroOrOne => !required, | ||
| ExtractionCardinality::Many => true, | ||
| }; | ||
| if !cardinality_requirement_is_compatible { | ||
| return Err(ExtractionSchemaError::InvalidCardinalityRequirement); | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
|
|
||
| if source_channels.is_empty() { | ||
| return Err(ExtractionSchemaError::MissingSourceChannel); | ||
| } | ||
|
|
||
| let normalization_is_compatible = match normalization_rule { | ||
| ExtractionNormalizationRule::Verbatim => true, | ||
| ExtractionNormalizationRule::TrimTextWhitespace => { | ||
| value_type == ExtractionValueType::Text | ||
| } | ||
| ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp, | ||
| }; | ||
| if !normalization_is_compatible { | ||
| return Err(ExtractionSchemaError::InvalidNormalizationRule); | ||
| } | ||
|
|
||
| let mut seen_channels = BTreeSet::new(); | ||
| for source_channel in source_channels { | ||
| if !seen_channels.insert(*source_channel) { | ||
| return Err(ExtractionSchemaError::DuplicateSourceChannel); | ||
| } | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| Ok(Self { | ||
| identifier: identifier.to_owned(), | ||
| value_type, | ||
| cardinality, | ||
| required, | ||
| normalization_rule, | ||
| source_channels: seen_channels.into_iter().collect(), | ||
|
seonghobae marked this conversation as resolved.
|
||
| }) | ||
| } | ||
|
|
||
| /// Return the stable field identifier. | ||
| #[must_use] | ||
| pub fn identifier(&self) -> &str { | ||
| &self.identifier | ||
| } | ||
|
|
||
| /// Return the declared value type. | ||
| #[must_use] | ||
| pub const fn value_type(&self) -> ExtractionValueType { | ||
| self.value_type | ||
| } | ||
|
|
||
| /// Return the declared cardinality. | ||
| #[must_use] | ||
| pub const fn cardinality(&self) -> ExtractionCardinality { | ||
| self.cardinality | ||
| } | ||
|
|
||
| /// Return whether the field must be present in a conforming extraction result. | ||
| #[must_use] | ||
| pub const fn required(&self) -> bool { | ||
| self.required | ||
| } | ||
|
|
||
| /// Return the deterministic normalization rule declared for this field. | ||
| #[must_use] | ||
| pub const fn normalization_rule(&self) -> ExtractionNormalizationRule { | ||
| self.normalization_rule | ||
| } | ||
|
|
||
| /// Return the reviewed source channels that may support this field. | ||
| #[must_use] | ||
| pub fn source_channels(&self) -> &[ExtractionSourceChannel] { | ||
| &self.source_channels | ||
| } | ||
| } | ||
|
|
||
| /// A bounded versioned collection of typed extraction-field contracts. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct ExtractionSchema { | ||
| version: String, | ||
| fields: Vec<ExtractionField>, | ||
| } | ||
|
|
||
| impl ExtractionSchema { | ||
| /// Validate and construct one versioned extraction schema. | ||
| pub fn new(version: &str, fields: Vec<ExtractionField>) -> Result<Self, ExtractionSchemaError> { | ||
| validate_identifier(version)?; | ||
| if fields.is_empty() { | ||
| return Err(ExtractionSchemaError::MissingField); | ||
| } | ||
| if fields.len() > MAX_EXTRACTION_FIELD_COUNT { | ||
| return Err(ExtractionSchemaError::LimitExceeded); | ||
| } | ||
|
|
||
| let mut field_identifiers = BTreeSet::new(); | ||
| for field in &fields { | ||
| if !field_identifiers.insert(field.identifier()) { | ||
| return Err(ExtractionSchemaError::DuplicateField); | ||
| } | ||
| } | ||
|
|
||
| Ok(Self { | ||
| version: version.to_owned(), | ||
| fields, | ||
| }) | ||
| } | ||
|
|
||
| /// Return the immutable schema version identifier. | ||
| #[must_use] | ||
| pub fn version(&self) -> &str { | ||
| &self.version | ||
| } | ||
|
|
||
| /// Return the schema's ordered field definitions. | ||
| #[must_use] | ||
| pub fn fields(&self) -> &[ExtractionField] { | ||
| &self.fields | ||
| } | ||
|
|
||
| /// Find one field by its stable identifier. | ||
| #[must_use] | ||
| pub fn field(&self, identifier: &str) -> Option<&ExtractionField> { | ||
| self.fields | ||
| .iter() | ||
| .find(|field| field.identifier() == identifier) | ||
| } | ||
| } | ||
|
|
||
|
seonghobae marked this conversation as resolved.
|
||
| fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> { | ||
| if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES { | ||
| return Err(ExtractionSchemaError::LimitExceeded); | ||
| } | ||
|
|
||
| let mut bytes = identifier.bytes(); | ||
| let Some(first_byte) = bytes.next() else { | ||
| return Err(ExtractionSchemaError::InvalidIdentifier); | ||
| }; | ||
| if !first_byte.is_ascii_lowercase() { | ||
| return Err(ExtractionSchemaError::InvalidIdentifier); | ||
| } | ||
| if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) { | ||
| return Err(ExtractionSchemaError::InvalidIdentifier); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
77 changes: 77 additions & 0 deletions
77
crates/originweave-evidence/tests/extraction_normalization.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| use originweave_evidence::{ | ||
| ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError, | ||
| ExtractionSourceChannel, ExtractionValueType, | ||
| }; | ||
|
|
||
| #[test] | ||
| fn extraction_fields_require_an_explicit_typed_normalization_rule() | ||
| -> Result<(), ExtractionSchemaError> { | ||
| let text = ExtractionField::new_with_normalization( | ||
| "product_name", | ||
| ExtractionValueType::Text, | ||
| ExtractionCardinality::One, | ||
| true, | ||
| ExtractionNormalizationRule::TrimTextWhitespace, | ||
| &[ExtractionSourceChannel::SemanticNode], | ||
| )?; | ||
| assert_eq!( | ||
| text.normalization_rule(), | ||
| ExtractionNormalizationRule::TrimTextWhitespace | ||
| ); | ||
|
|
||
| let timestamp = ExtractionField::new_with_normalization( | ||
| "captured_at", | ||
| ExtractionValueType::Timestamp, | ||
| ExtractionCardinality::One, | ||
| true, | ||
| ExtractionNormalizationRule::Rfc3339Utc, | ||
| &[ExtractionSourceChannel::NetworkResponse], | ||
| )?; | ||
| assert_eq!( | ||
| timestamp.normalization_rule(), | ||
| ExtractionNormalizationRule::Rfc3339Utc | ||
| ); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn extraction_fields_fail_closed_on_type_incompatible_normalization() { | ||
| assert_eq!( | ||
| ExtractionField::new_with_normalization( | ||
| "captured_at", | ||
| ExtractionValueType::Timestamp, | ||
| ExtractionCardinality::One, | ||
| true, | ||
| ExtractionNormalizationRule::TrimTextWhitespace, | ||
| &[ExtractionSourceChannel::NetworkResponse], | ||
| ), | ||
| Err(ExtractionSchemaError::InvalidNormalizationRule) | ||
| ); | ||
| assert_eq!( | ||
| ExtractionField::new_with_normalization( | ||
| "product_name", | ||
| ExtractionValueType::Text, | ||
| ExtractionCardinality::One, | ||
| true, | ||
| ExtractionNormalizationRule::Rfc3339Utc, | ||
| &[ExtractionSourceChannel::SemanticNode], | ||
| ), | ||
| Err(ExtractionSchemaError::InvalidNormalizationRule) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> { | ||
| let field = ExtractionField::new( | ||
| "unit_price", | ||
| ExtractionValueType::Decimal, | ||
| ExtractionCardinality::ZeroOrOne, | ||
| false, | ||
| &[ExtractionSourceChannel::StructuredData], | ||
| )?; | ||
| assert_eq!( | ||
| field.normalization_rule(), | ||
| ExtractionNormalizationRule::Verbatim | ||
| ); | ||
| Ok(()) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.