lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 6aa22321792f6ed53e326d5a442d5598a64236f8
parent db0a8bef6b95befe80ac61d39179473077076fff
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Jul 2026 01:33:18 +0000

events: enforce claim citation rules

Diffstat:
Mcrates/events/src/knowledge.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/tests/knowledge.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 92 insertions(+), 0 deletions(-)

diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs @@ -349,6 +349,10 @@ pub fn validate_knowledge_source( validate_event_refs(&source.artifact_refs, "artifact_refs") } +pub fn is_uncited_knowledge_claim_type(claim_type: &str) -> bool { + matches!(claim_type, "hypothesis" | "observation" | "question") +} + pub fn validate_knowledge_claim( claim: &RadrootsKnowledgeClaim, ) -> Result<(), RadrootsKnowledgeValidationError> { @@ -359,6 +363,13 @@ pub fn validate_knowledge_claim( )?; require_non_empty(claim.claim_type.as_str(), "claim_type")?; require_non_empty(claim.text.as_str(), "text")?; + if claim.citation_spans.is_empty() + && !is_uncited_knowledge_claim_type(claim.claim_type.as_str()) + { + return Err(RadrootsKnowledgeValidationError::EmptyField( + "citation_spans", + )); + } for citation in &claim.citation_spans { if citation.source_ref.kind != crate::kinds::KIND_KNOWLEDGE_SOURCE { return Err(RadrootsKnowledgeValidationError::InvalidField( @@ -1249,6 +1260,36 @@ mod tests { } #[test] + fn knowledge_claims_require_citations_except_exact_uncited_types() { + let mut claim = knowledge_claim(); + claim.citation_spans.clear(); + assert_validation_error( + validate_knowledge_claim(&claim), + RadrootsKnowledgeValidationError::EmptyField("citation_spans"), + ); + + claim.citation_spans.push(citation_span()); + assert_eq!(validate_knowledge_claim(&claim), Ok(())); + + for claim_type in ["hypothesis", "observation", "question"] { + let mut uncited = knowledge_claim(); + uncited.claim_type = claim_type.to_string(); + uncited.citation_spans.clear(); + assert_eq!(validate_knowledge_claim(&uncited), Ok(())); + assert!(is_uncited_knowledge_claim_type(claim_type)); + } + + let mut capitalized = knowledge_claim(); + capitalized.claim_type = "Hypothesis".to_string(); + capitalized.citation_spans.clear(); + assert_validation_error( + validate_knowledge_claim(&capitalized), + RadrootsKnowledgeValidationError::EmptyField("citation_spans"), + ); + assert!(!is_uncited_knowledge_claim_type("Hypothesis")); + } + + #[test] fn knowledge_validators_reject_representative_invalid_models() { let mut article = RadrootsWikiArticle { d_tag: "soil-health".to_string(), diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs @@ -776,6 +776,33 @@ fn semantic_validation_rejects_invalid_encode_models() { } #[test] +fn knowledge_claim_encode_enforces_citation_rules() { + let mut model = claim(); + model.citation_spans.clear(); + assert_encode_error( + knowledge_claim_to_wire_parts(&model).unwrap_err(), + EventEncodeError::EmptyRequiredField("citation_spans"), + ); + + assert!(knowledge_claim_to_wire_parts(&claim()).is_ok()); + + for claim_type in ["hypothesis", "observation", "question"] { + let mut uncited = claim(); + uncited.claim_type = claim_type.to_string(); + uncited.citation_spans.clear(); + assert!(knowledge_claim_to_wire_parts(&uncited).is_ok()); + } + + let mut capitalized = claim(); + capitalized.claim_type = "Hypothesis".to_string(); + capitalized.citation_spans.clear(); + assert_encode_error( + knowledge_claim_to_wire_parts(&capitalized).unwrap_err(), + EventEncodeError::EmptyRequiredField("citation_spans"), + ); +} + +#[test] fn semantic_validation_rejects_invalid_decoded_content() { let mut article = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); article.content = String::new(); @@ -890,3 +917,27 @@ fn semantic_validation_rejects_invalid_decoded_content() { EventParseError::InvalidJson("subject_refs"), ); } + +#[test] +fn knowledge_claim_decode_enforces_citation_rules() { + let mut claim_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap()); + let mut value: serde_json::Value = serde_json::from_str(&claim_event.content).unwrap(); + value["citation_spans"] = serde_json::Value::Array(Vec::new()); + claim_event.content = serde_json::to_string(&value).unwrap(); + assert_parse_error( + knowledge_claim_from_event(claim_event).unwrap_err(), + EventParseError::InvalidJson("citation_spans"), + ); + + for claim_type in ["hypothesis", "observation", "question"] { + let mut uncited = claim(); + uncited.claim_type = claim_type.to_string(); + uncited.citation_spans.clear(); + let decoded = knowledge_claim_from_event(event_from_parts( + knowledge_claim_to_wire_parts(&uncited).unwrap(), + )) + .unwrap(); + assert_eq!(decoded.data.data.claim_type, claim_type); + assert!(decoded.data.data.citation_spans.is_empty()); + } +}