knowledge.rs (54669B)
1 #![forbid(unsafe_code)] 2 3 #[cfg(not(feature = "std"))] 4 use alloc::{string::String, vec::Vec}; 5 6 use core::fmt; 7 8 use crate::envelope::kind::KIND_WIKI_ARTICLE; 9 use crate::id::{AddressableCoordinate, DTag, EventId, RelayUrl, parse_public_key}; 10 use crate::tag::EventRef; 11 12 pub const RADROOTS_KNOWLEDGE_SCHEMA_VERSION: u16 = 1; 13 pub const RADROOTS_WIKI_D_TAG_MAX_LEN: usize = 512; 14 pub const RADROOTS_KNOWLEDGE_SOURCE_SCHEMA: &str = "radroots.knowledge.source.v1"; 15 pub const RADROOTS_KNOWLEDGE_CLAIM_SCHEMA: &str = "radroots.knowledge.claim.v1"; 16 pub const RADROOTS_KNOWLEDGE_RELATION_SCHEMA: &str = "radroots.knowledge.relation.v1"; 17 pub const RADROOTS_KNOWLEDGE_REVIEW_SCHEMA: &str = "radroots.knowledge.review.v1"; 18 pub const RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA: &str = "radroots.knowledge.field_report.v1"; 19 pub const RADROOTS_EVIDENCE_BOUNTY_SCHEMA: &str = "radroots.knowledge.evidence_bounty.v1"; 20 pub const RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA: &str = "radroots.knowledge.change_proposal.v1"; 21 pub const RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA: &str = 22 "radroots.knowledge.contribution_attestation.v1"; 23 24 #[derive(Clone, Debug, PartialEq, Eq)] 25 pub enum WikiDTagError { 26 Empty, 27 TooLong { max: usize, actual: usize }, 28 NotNormalized { normalized: String }, 29 } 30 31 impl WikiDTagError { 32 pub const fn code(&self) -> &'static str { 33 match self { 34 Self::Empty => "empty", 35 Self::TooLong { .. } => "too_long", 36 Self::NotNormalized { .. } => "not_normalized", 37 } 38 } 39 } 40 41 impl fmt::Display for WikiDTagError { 42 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 43 match self { 44 Self::Empty => formatter.write_str("wiki d tag must not be empty"), 45 Self::TooLong { max, actual } => { 46 write!( 47 formatter, 48 "wiki d tag length {actual} exceeds maximum length {max}" 49 ) 50 } 51 Self::NotNormalized { normalized } => { 52 write!( 53 formatter, 54 "wiki d tag must match normalized value {normalized}" 55 ) 56 } 57 } 58 } 59 } 60 61 #[cfg(feature = "std")] 62 impl std::error::Error for WikiDTagError {} 63 64 pub fn normalize_wiki_d_tag(input: &str) -> String { 65 let mut normalized = String::new(); 66 let mut pending_separator = false; 67 68 for raw in input.trim().chars() { 69 for character in raw.to_lowercase() { 70 if character.is_alphanumeric() { 71 if pending_separator && !normalized.is_empty() { 72 normalized.push('-'); 73 } 74 normalized.push(character); 75 pending_separator = false; 76 } else if character.is_whitespace() || character == '-' { 77 pending_separator = true; 78 } 79 } 80 } 81 82 normalized 83 } 84 85 pub fn validate_wiki_d_tag(value: &str) -> Result<(), WikiDTagError> { 86 if value.is_empty() { 87 return Err(WikiDTagError::Empty); 88 } 89 90 let actual = value.chars().count(); 91 if actual > RADROOTS_WIKI_D_TAG_MAX_LEN { 92 return Err(WikiDTagError::TooLong { 93 max: RADROOTS_WIKI_D_TAG_MAX_LEN, 94 actual, 95 }); 96 } 97 98 let normalized = normalize_wiki_d_tag(value); 99 if normalized != value { 100 return Err(WikiDTagError::NotNormalized { normalized }); 101 } 102 103 Ok(()) 104 } 105 106 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 107 pub enum KnowledgeValidationError { 108 EmptyField(&'static str), 109 InvalidField(&'static str), 110 } 111 112 impl KnowledgeValidationError { 113 pub const fn code(self) -> &'static str { 114 match self { 115 Self::EmptyField(_) => "empty_field", 116 Self::InvalidField(_) => "invalid_field", 117 } 118 } 119 120 pub const fn field(self) -> &'static str { 121 match self { 122 Self::EmptyField(field) | Self::InvalidField(field) => field, 123 } 124 } 125 } 126 127 impl fmt::Display for KnowledgeValidationError { 128 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 129 match self { 130 Self::EmptyField(field) => write!(formatter, "empty knowledge field: {field}"), 131 Self::InvalidField(field) => write!(formatter, "invalid knowledge field: {field}"), 132 } 133 } 134 } 135 136 #[cfg(feature = "std")] 137 impl std::error::Error for KnowledgeValidationError {} 138 139 fn require_non_empty(value: &str, field: &'static str) -> Result<(), KnowledgeValidationError> { 140 if value.trim().is_empty() { 141 Err(KnowledgeValidationError::EmptyField(field)) 142 } else { 143 Ok(()) 144 } 145 } 146 147 fn require_schema( 148 schema: &str, 149 schema_version: u16, 150 expected_schema: &'static str, 151 ) -> Result<(), KnowledgeValidationError> { 152 if schema != expected_schema { 153 return Err(KnowledgeValidationError::InvalidField("schema")); 154 } 155 if schema_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION { 156 return Err(KnowledgeValidationError::InvalidField("schema_version")); 157 } 158 Ok(()) 159 } 160 161 fn validate_event_id(value: &str, field: &'static str) -> Result<(), KnowledgeValidationError> { 162 if value.trim().is_empty() { 163 return Err(KnowledgeValidationError::EmptyField(field)); 164 } 165 EventId::parse(value) 166 .map(|_| ()) 167 .map_err(|_| KnowledgeValidationError::InvalidField(field)) 168 } 169 170 fn validate_pubkey(value: &str, field: &'static str) -> Result<(), KnowledgeValidationError> { 171 if value.trim().is_empty() { 172 return Err(KnowledgeValidationError::EmptyField(field)); 173 } 174 parse_public_key(value) 175 .map(|_| ()) 176 .map_err(|_| KnowledgeValidationError::InvalidField(field)) 177 } 178 179 fn validate_relays(relays: &[String], field: &'static str) -> Result<(), KnowledgeValidationError> { 180 for relay in relays { 181 RelayUrl::parse(relay) 182 .map(|_| ()) 183 .map_err(|_| KnowledgeValidationError::InvalidField(field))?; 184 } 185 Ok(()) 186 } 187 188 fn validate_event_ref( 189 event_ref: &EventRef, 190 field: &'static str, 191 ) -> Result<(), KnowledgeValidationError> { 192 validate_event_id(&event_ref.id, field)?; 193 if let Some(d_tag) = event_ref.d_tag.as_deref() { 194 DTag::parse(d_tag) 195 .map(|_| ()) 196 .map_err(|_| KnowledgeValidationError::InvalidField(field))?; 197 } 198 if let Some(relays) = event_ref.relays.as_deref() { 199 validate_relays(relays, field)?; 200 } 201 Ok(()) 202 } 203 204 fn validate_event_refs( 205 refs: &[EventRef], 206 field: &'static str, 207 ) -> Result<(), KnowledgeValidationError> { 208 for event_ref in refs { 209 validate_event_ref(event_ref, field)?; 210 } 211 Ok(()) 212 } 213 214 fn validate_address_ref( 215 address: &AddressableRef, 216 field: &'static str, 217 ) -> Result<(), KnowledgeValidationError> { 218 if address.kind == 0 { 219 return Err(KnowledgeValidationError::InvalidField(field)); 220 } 221 validate_pubkey(&address.pubkey, field)?; 222 DTag::parse(address.d_tag.as_str()) 223 .map(|_| ()) 224 .map_err(|_| KnowledgeValidationError::InvalidField(field))?; 225 validate_relays(&address.relays, field) 226 } 227 228 fn validate_wiki_article_address_ref( 229 address: &AddressableRef, 230 field: &'static str, 231 ) -> Result<(), KnowledgeValidationError> { 232 if address.kind != KIND_WIKI_ARTICLE { 233 return Err(KnowledgeValidationError::InvalidField(field)); 234 } 235 validate_pubkey(&address.pubkey, field)?; 236 validate_wiki_d_tag(address.d_tag.as_str()) 237 .map(|_| ()) 238 .map_err(|_| KnowledgeValidationError::InvalidField(field))?; 239 validate_relays(&address.relays, field) 240 } 241 242 fn validate_wiki_article_version_ref( 243 version_ref: &WikiArticleVersionRef, 244 field: &'static str, 245 ) -> Result<(), KnowledgeValidationError> { 246 validate_event_id(&version_ref.event_id, field)?; 247 validate_wiki_article_address_ref(&version_ref.address_ref, field) 248 } 249 250 fn validate_node_ref( 251 node_ref: &KnowledgeNodeRef, 252 field: &'static str, 253 ) -> Result<(), KnowledgeValidationError> { 254 let mut populated = 0u8; 255 if let Some(event_ref) = &node_ref.event_ref { 256 populated += 1; 257 validate_event_ref(event_ref, field)?; 258 } 259 if let Some(address_ref) = &node_ref.address_ref { 260 populated += 1; 261 validate_address_ref(address_ref, field)?; 262 } 263 if let Some(external_id) = node_ref.external_id.as_deref() { 264 populated += 1; 265 require_non_empty(external_id, field)?; 266 } 267 if populated == 1 { 268 Ok(()) 269 } else { 270 Err(KnowledgeValidationError::InvalidField(field)) 271 } 272 } 273 274 pub fn validate_wiki_article(article: &WikiArticle) -> Result<(), KnowledgeValidationError> { 275 validate_wiki_d_tag(article.d_tag.as_str()) 276 .map(|_| ()) 277 .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?; 278 if let Some(title) = article.title.as_deref() { 279 require_non_empty(title, "title")?; 280 } 281 require_non_empty(article.content_djot.as_str(), "content_djot")?; 282 validate_event_refs(&article.references, "references")?; 283 for version_ref in &article.forked_from { 284 validate_wiki_article_version_ref(version_ref, "forked_from")?; 285 } 286 if let Some(version_ref) = &article.deferred_to { 287 validate_wiki_article_version_ref(version_ref, "deferred_to")?; 288 } 289 Ok(()) 290 } 291 292 pub fn validate_wiki_redirect(redirect: &WikiRedirect) -> Result<(), KnowledgeValidationError> { 293 validate_wiki_d_tag(redirect.d_tag.as_str()) 294 .map(|_| ()) 295 .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?; 296 validate_wiki_article_address_ref(&redirect.target, "wiki_redirect.target") 297 } 298 299 pub fn validate_wiki_merge_request( 300 request: &WikiMergeRequest, 301 ) -> Result<(), KnowledgeValidationError> { 302 validate_wiki_article_address_ref(&request.target_article, "target_article")?; 303 validate_pubkey(&request.destination_pubkey, "destination_pubkey")?; 304 if let Some(base) = request.base_version_event_id.as_deref() { 305 validate_event_id(base, "base_version_event_id")?; 306 } 307 validate_event_id( 308 request.source_version_event_id.as_str(), 309 "source_version_event_id", 310 ) 311 } 312 313 pub fn validate_knowledge_source(source: &KnowledgeSource) -> Result<(), KnowledgeValidationError> { 314 require_schema( 315 source.schema.as_str(), 316 source.schema_version, 317 RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, 318 )?; 319 validate_wiki_d_tag(source.d_tag.as_str()) 320 .map(|_| ()) 321 .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?; 322 require_non_empty(source.title.as_str(), "title")?; 323 require_non_empty(source.source_type.as_str(), "source_type")?; 324 for author in &source.authors { 325 require_non_empty(author.as_str(), "authors")?; 326 } 327 validate_event_refs(&source.artifact_refs, "artifact_refs") 328 } 329 330 pub fn is_uncited_knowledge_claim_type(claim_type: &str) -> bool { 331 matches!(claim_type, "hypothesis" | "observation" | "question") 332 } 333 334 pub fn validate_knowledge_claim(claim: &KnowledgeClaim) -> Result<(), KnowledgeValidationError> { 335 require_schema( 336 claim.schema.as_str(), 337 claim.schema_version, 338 RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, 339 )?; 340 require_non_empty(claim.claim_type.as_str(), "claim_type")?; 341 require_non_empty(claim.text.as_str(), "text")?; 342 if claim.citation_spans.is_empty() 343 && !is_uncited_knowledge_claim_type(claim.claim_type.as_str()) 344 { 345 return Err(KnowledgeValidationError::EmptyField("citation_spans")); 346 } 347 for citation in &claim.citation_spans { 348 if citation.source_ref.kind != crate::envelope::kind::KIND_KNOWLEDGE_SOURCE { 349 return Err(KnowledgeValidationError::InvalidField("citation_spans")); 350 } 351 validate_event_ref(&citation.source_ref, "citation_spans")?; 352 if let Some(artifact_ref) = &citation.artifact_ref { 353 validate_event_ref(artifact_ref, "citation_spans")?; 354 } 355 if let Some(quote_hash) = citation.quote_hash.as_deref() { 356 validate_event_id(quote_hash, "citation_spans")?; 357 } 358 } 359 validate_event_refs(&claim.supersedes, "supersedes") 360 } 361 362 pub fn validate_knowledge_relation( 363 relation: &KnowledgeRelation, 364 ) -> Result<(), KnowledgeValidationError> { 365 require_schema( 366 relation.schema.as_str(), 367 relation.schema_version, 368 RADROOTS_KNOWLEDGE_RELATION_SCHEMA, 369 )?; 370 validate_node_ref(&relation.subject, "subject")?; 371 require_non_empty(relation.predicate.as_str(), "predicate")?; 372 validate_node_ref(&relation.object, "object")?; 373 validate_event_refs(&relation.support_refs, "support_refs")?; 374 validate_event_refs(&relation.supersedes, "supersedes") 375 } 376 377 pub fn validate_knowledge_review(review: &KnowledgeReview) -> Result<(), KnowledgeValidationError> { 378 require_schema( 379 review.schema.as_str(), 380 review.schema_version, 381 RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, 382 )?; 383 validate_event_id(review.target.event_id.as_str(), "review_target")?; 384 validate_pubkey(review.target.author_pubkey.as_str(), "review_target")?; 385 if review.target.kind == 0 { 386 return Err(KnowledgeValidationError::InvalidField("review_target")); 387 } 388 if let Some(address) = review.target.address.as_deref() { 389 AddressableCoordinate::parse(address) 390 .map(|_| ()) 391 .map_err(|_| KnowledgeValidationError::InvalidField("review_target"))?; 392 } 393 validate_relays(&review.target.relays, "review_target")?; 394 require_non_empty(review.reviewer_role.as_str(), "reviewer_role")?; 395 require_non_empty(review.verdict.as_str(), "verdict")?; 396 for score in &review.scores { 397 require_non_empty(score.dimension.as_str(), "scores")?; 398 require_non_empty(score.value.as_str(), "scores")?; 399 } 400 validate_event_refs(&review.evidence_refs, "evidence_refs") 401 } 402 403 pub fn validate_knowledge_field_report( 404 report: &KnowledgeFieldReport, 405 ) -> Result<(), KnowledgeValidationError> { 406 require_schema( 407 report.schema.as_str(), 408 report.schema_version, 409 RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, 410 )?; 411 require_non_empty(report.report_type.as_str(), "report_type")?; 412 require_non_empty(report.title.as_str(), "title")?; 413 if report.context.location_precision == KnowledgeLocationPrecision::ExactPrivateReference 414 && report.context.private_location_ref.is_none() 415 { 416 return Err(KnowledgeValidationError::EmptyField("private_location_ref")); 417 } 418 if let Some(private_location_ref) = &report.context.private_location_ref { 419 validate_event_ref(private_location_ref, "private_location_ref")?; 420 } 421 if report.observations.is_empty() { 422 return Err(KnowledgeValidationError::EmptyField("observations")); 423 } 424 for observation in &report.observations { 425 require_non_empty(observation.observation_type.as_str(), "observations")?; 426 require_non_empty(observation.text.as_str(), "observations")?; 427 for value in &observation.values { 428 require_non_empty(value.key.as_str(), "observation_values")?; 429 require_non_empty(value.value.as_str(), "observation_values")?; 430 } 431 } 432 validate_event_refs(&report.artifact_refs, "artifact_refs")?; 433 validate_event_refs(&report.related_refs, "related_refs") 434 } 435 436 pub fn validate_evidence_bounty(bounty: &EvidenceBounty) -> Result<(), KnowledgeValidationError> { 437 require_schema( 438 bounty.schema.as_str(), 439 bounty.schema_version, 440 RADROOTS_EVIDENCE_BOUNTY_SCHEMA, 441 )?; 442 validate_wiki_d_tag(bounty.d_tag.as_str()) 443 .map(|_| ()) 444 .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?; 445 require_non_empty(bounty.title.as_str(), "title")?; 446 if bounty.target_refs.is_empty() { 447 return Err(KnowledgeValidationError::EmptyField("target_refs")); 448 } 449 validate_event_refs(&bounty.target_refs, "target_refs") 450 } 451 452 pub fn validate_knowledge_change_proposal( 453 proposal: &KnowledgeChangeProposal, 454 ) -> Result<(), KnowledgeValidationError> { 455 require_schema( 456 proposal.schema.as_str(), 457 proposal.schema_version, 458 RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, 459 )?; 460 validate_event_ref(&proposal.target, "target")?; 461 require_non_empty(proposal.proposal_type.as_str(), "proposal_type")?; 462 require_non_empty(proposal.summary.as_str(), "summary")?; 463 validate_event_refs(&proposal.evidence_refs, "evidence_refs")?; 464 validate_event_refs(&proposal.supersedes, "supersedes") 465 } 466 467 pub fn validate_contribution_attestation( 468 attestation: &ContributionAttestation, 469 ) -> Result<(), KnowledgeValidationError> { 470 require_schema( 471 attestation.schema.as_str(), 472 attestation.schema_version, 473 RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, 474 )?; 475 validate_pubkey( 476 attestation.contributor_pubkey.as_str(), 477 "contributor_pubkey", 478 )?; 479 require_non_empty(attestation.contribution_type.as_str(), "contribution_type")?; 480 require_non_empty(attestation.summary.as_str(), "summary")?; 481 if attestation.subject_refs.is_empty() { 482 return Err(KnowledgeValidationError::EmptyField("subject_refs")); 483 } 484 validate_event_refs(&attestation.subject_refs, "subject_refs")?; 485 validate_event_refs(&attestation.evidence_refs, "evidence_refs") 486 } 487 488 #[cfg_attr( 489 any(feature = "serde", test), 490 derive(serde::Serialize, serde::Deserialize) 491 )] 492 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 493 #[cfg_attr(all(test, feature = "std"), dto(export))] 494 #[derive(Clone, Debug, PartialEq, Eq)] 495 pub struct AddressableRef { 496 pub kind: u32, 497 pub pubkey: String, 498 pub d_tag: String, 499 pub relays: Vec<String>, 500 } 501 502 #[cfg_attr( 503 any(feature = "serde", test), 504 derive(serde::Serialize, serde::Deserialize) 505 )] 506 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 507 #[cfg_attr(all(test, feature = "std"), dto(export))] 508 #[derive(Clone, Debug, PartialEq, Eq)] 509 pub struct RightsAssertion { 510 pub assertion: String, 511 pub holder: Option<String>, 512 pub license: Option<String>, 513 pub url: Option<String>, 514 } 515 516 #[cfg_attr( 517 any(feature = "serde", test), 518 derive(serde::Serialize, serde::Deserialize) 519 )] 520 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 521 #[cfg_attr(all(test, feature = "std"), dto(export))] 522 #[derive(Clone, Debug, PartialEq, Eq)] 523 pub struct WikiArticle { 524 pub d_tag: String, 525 pub title: Option<String>, 526 pub content_djot: String, 527 pub summary: Option<String>, 528 pub topics: Vec<String>, 529 pub references: Vec<EventRef>, 530 pub forked_from: Vec<WikiArticleVersionRef>, 531 pub deferred_to: Option<WikiArticleVersionRef>, 532 } 533 534 #[cfg_attr( 535 any(feature = "serde", test), 536 derive(serde::Serialize, serde::Deserialize) 537 )] 538 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 539 #[cfg_attr(all(test, feature = "std"), dto(export))] 540 #[derive(Clone, Debug, PartialEq, Eq)] 541 pub struct WikiArticleVersionRef { 542 pub event_id: String, 543 pub address_ref: AddressableRef, 544 } 545 546 #[cfg_attr( 547 any(feature = "serde", test), 548 derive(serde::Serialize, serde::Deserialize) 549 )] 550 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 551 #[cfg_attr(all(test, feature = "std"), dto(export))] 552 #[derive(Clone, Debug, PartialEq, Eq)] 553 pub struct WikiRedirect { 554 pub d_tag: String, 555 pub target: AddressableRef, 556 } 557 558 #[cfg_attr( 559 any(feature = "serde", test), 560 derive(serde::Serialize, serde::Deserialize) 561 )] 562 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 563 #[cfg_attr(all(test, feature = "std"), dto(export))] 564 #[derive(Clone, Debug, PartialEq, Eq)] 565 pub struct WikiMergeRequest { 566 pub target_article: AddressableRef, 567 pub destination_pubkey: String, 568 pub base_version_event_id: Option<String>, 569 pub source_version_event_id: String, 570 pub explanation: Option<String>, 571 } 572 573 #[cfg_attr( 574 any(feature = "serde", test), 575 derive(serde::Serialize, serde::Deserialize) 576 )] 577 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 578 #[cfg_attr(all(test, feature = "std"), dto(export))] 579 #[derive(Clone, Debug, PartialEq, Eq)] 580 pub struct KnowledgeSource { 581 pub schema: String, 582 pub schema_version: u16, 583 pub d_tag: String, 584 pub title: String, 585 pub source_type: String, 586 pub authors: Vec<String>, 587 pub publisher: Option<String>, 588 pub publication_year: Option<u16>, 589 pub edition: Option<String>, 590 pub canonical_url: Option<String>, 591 pub artifact_refs: Vec<EventRef>, 592 pub author_asserted_rights: Option<RightsAssertion>, 593 pub topics: Vec<String>, 594 pub summary: Option<String>, 595 } 596 597 #[cfg_attr( 598 any(feature = "serde", test), 599 derive(serde::Serialize, serde::Deserialize) 600 )] 601 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 602 #[cfg_attr(all(test, feature = "std"), dto(export))] 603 #[derive(Clone, Debug, PartialEq, Eq)] 604 pub struct KnowledgeClaim { 605 pub schema: String, 606 pub schema_version: u16, 607 pub claim_type: String, 608 pub text: String, 609 pub citation_spans: Vec<KnowledgeCitationSpan>, 610 pub topics: Vec<String>, 611 pub applies_to: Vec<String>, 612 pub author_asserted_confidence: Option<String>, 613 pub supersedes: Vec<EventRef>, 614 } 615 616 #[cfg_attr( 617 any(feature = "serde", test), 618 derive(serde::Serialize, serde::Deserialize) 619 )] 620 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 621 #[cfg_attr(all(test, feature = "std"), dto(export))] 622 #[derive(Clone, Debug, PartialEq, Eq)] 623 pub struct KnowledgeCitationSpan { 624 pub source_ref: EventRef, 625 pub artifact_ref: Option<EventRef>, 626 pub page_start: Option<u32>, 627 pub page_end: Option<u32>, 628 pub section_path: Vec<String>, 629 pub quote_hash: Option<String>, 630 pub chunk_id: Option<String>, 631 } 632 633 #[cfg_attr( 634 any(feature = "serde", test), 635 derive(serde::Serialize, serde::Deserialize) 636 )] 637 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 638 #[cfg_attr(all(test, feature = "std"), dto(export))] 639 #[derive(Clone, Debug, PartialEq, Eq)] 640 pub struct KnowledgeNodeRef { 641 pub node_type: String, 642 pub event_ref: Option<EventRef>, 643 pub address_ref: Option<AddressableRef>, 644 pub external_id: Option<String>, 645 pub label: Option<String>, 646 } 647 648 #[cfg_attr( 649 any(feature = "serde", test), 650 derive(serde::Serialize, serde::Deserialize) 651 )] 652 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 653 #[cfg_attr(all(test, feature = "std"), dto(export))] 654 #[derive(Clone, Debug, PartialEq, Eq)] 655 pub struct KnowledgeRelation { 656 pub schema: String, 657 pub schema_version: u16, 658 pub subject: KnowledgeNodeRef, 659 pub predicate: String, 660 pub object: KnowledgeNodeRef, 661 pub support_refs: Vec<EventRef>, 662 pub author_asserted_confidence: Option<String>, 663 pub supersedes: Vec<EventRef>, 664 } 665 666 #[cfg_attr( 667 any(feature = "serde", test), 668 derive(serde::Serialize, serde::Deserialize) 669 )] 670 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 671 #[cfg_attr(all(test, feature = "std"), dto(export))] 672 #[derive(Clone, Debug, PartialEq, Eq)] 673 pub struct KnowledgeReview { 674 pub schema: String, 675 pub schema_version: u16, 676 pub target: KnowledgeReviewTarget, 677 pub reviewer_role: String, 678 pub verdict: String, 679 pub scores: Vec<KnowledgeReviewScore>, 680 pub notes: Option<String>, 681 pub evidence_refs: Vec<EventRef>, 682 } 683 684 #[cfg_attr( 685 any(feature = "serde", test), 686 derive(serde::Serialize, serde::Deserialize) 687 )] 688 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 689 #[cfg_attr(all(test, feature = "std"), dto(export))] 690 #[derive(Clone, Debug, PartialEq, Eq)] 691 pub struct KnowledgeReviewTarget { 692 pub event_id: String, 693 pub author_pubkey: String, 694 pub kind: u32, 695 pub address: Option<String>, 696 pub relays: Vec<String>, 697 pub review_scope: KnowledgeReviewScope, 698 } 699 700 #[cfg_attr( 701 any(feature = "serde", test), 702 derive(serde::Serialize, serde::Deserialize) 703 )] 704 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 705 #[cfg_attr(all(test, feature = "std"), dto(export))] 706 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))] 707 #[derive(Clone, Debug, PartialEq, Eq)] 708 pub enum KnowledgeReviewScope { 709 SpecificVersion, 710 AddressableCoordinateAtPublishedAt, 711 PolicyLatest, 712 } 713 714 #[cfg_attr( 715 any(feature = "serde", test), 716 derive(serde::Serialize, serde::Deserialize) 717 )] 718 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 719 #[cfg_attr(all(test, feature = "std"), dto(export))] 720 #[derive(Clone, Debug, PartialEq, Eq)] 721 pub struct KnowledgeReviewScore { 722 pub dimension: String, 723 pub value: String, 724 pub note: Option<String>, 725 } 726 727 #[cfg_attr( 728 any(feature = "serde", test), 729 derive(serde::Serialize, serde::Deserialize) 730 )] 731 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 732 #[cfg_attr(all(test, feature = "std"), dto(export))] 733 #[derive(Clone, Debug, PartialEq, Eq)] 734 pub struct KnowledgeFieldReport { 735 pub schema: String, 736 pub schema_version: u16, 737 pub report_type: String, 738 pub title: String, 739 pub summary: Option<String>, 740 pub context: KnowledgeFieldContext, 741 pub observations: Vec<KnowledgeObservation>, 742 pub artifact_refs: Vec<EventRef>, 743 pub related_refs: Vec<EventRef>, 744 pub limitations: Vec<String>, 745 } 746 747 #[cfg_attr( 748 any(feature = "serde", test), 749 derive(serde::Serialize, serde::Deserialize) 750 )] 751 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 752 #[cfg_attr(all(test, feature = "std"), dto(export))] 753 #[derive(Clone, Debug, PartialEq, Eq)] 754 pub struct KnowledgeFieldContext { 755 pub location_precision: KnowledgeLocationPrecision, 756 pub public_location: Option<KnowledgeLocation>, 757 pub private_location_ref: Option<EventRef>, 758 pub topics: Vec<String>, 759 pub context_tags: Vec<String>, 760 } 761 762 #[cfg_attr( 763 any(feature = "serde", test), 764 derive(serde::Serialize, serde::Deserialize) 765 )] 766 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 767 #[cfg_attr(all(test, feature = "std"), dto(export))] 768 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))] 769 #[derive(Clone, Debug, PartialEq, Eq)] 770 pub enum KnowledgeLocationPrecision { 771 None, 772 Region, 773 Locality, 774 CoarseGeohash, 775 ExactPublic, 776 ExactPrivateReference, 777 } 778 779 #[cfg_attr( 780 any(feature = "serde", test), 781 derive(serde::Serialize, serde::Deserialize) 782 )] 783 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 784 #[cfg_attr(all(test, feature = "std"), dto(export))] 785 #[derive(Clone, Debug, PartialEq, Eq)] 786 pub struct KnowledgeLocation { 787 pub label: Option<String>, 788 pub region: Option<String>, 789 pub locality: Option<String>, 790 pub geohash: Option<String>, 791 } 792 793 #[cfg_attr( 794 any(feature = "serde", test), 795 derive(serde::Serialize, serde::Deserialize) 796 )] 797 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 798 #[cfg_attr(all(test, feature = "std"), dto(export))] 799 #[derive(Clone, Debug, PartialEq, Eq)] 800 pub struct KnowledgeObservation { 801 pub observation_type: String, 802 pub text: String, 803 pub observed_at: Option<String>, 804 pub values: Vec<KnowledgeObservationValue>, 805 } 806 807 #[cfg_attr( 808 any(feature = "serde", test), 809 derive(serde::Serialize, serde::Deserialize) 810 )] 811 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 812 #[cfg_attr(all(test, feature = "std"), dto(export))] 813 #[derive(Clone, Debug, PartialEq, Eq)] 814 pub struct KnowledgeObservationValue { 815 pub key: String, 816 pub value: String, 817 pub unit: Option<String>, 818 } 819 820 #[cfg_attr( 821 any(feature = "serde", test), 822 derive(serde::Serialize, serde::Deserialize) 823 )] 824 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 825 #[cfg_attr(all(test, feature = "std"), dto(export))] 826 #[derive(Clone, Debug, PartialEq, Eq)] 827 pub struct EvidenceBounty { 828 pub schema: String, 829 pub schema_version: u16, 830 pub d_tag: String, 831 pub title: String, 832 pub summary: Option<String>, 833 pub topics: Vec<String>, 834 pub target_refs: Vec<EventRef>, 835 pub reward_note: Option<String>, 836 pub closes_at: Option<String>, 837 } 838 839 #[cfg_attr( 840 any(feature = "serde", test), 841 derive(serde::Serialize, serde::Deserialize) 842 )] 843 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 844 #[cfg_attr(all(test, feature = "std"), dto(export))] 845 #[derive(Clone, Debug, PartialEq, Eq)] 846 pub struct KnowledgeChangeProposal { 847 pub schema: String, 848 pub schema_version: u16, 849 pub target: EventRef, 850 pub proposal_type: String, 851 pub summary: String, 852 pub rationale: Option<String>, 853 pub evidence_refs: Vec<EventRef>, 854 pub supersedes: Vec<EventRef>, 855 } 856 857 #[cfg_attr( 858 any(feature = "serde", test), 859 derive(serde::Serialize, serde::Deserialize) 860 )] 861 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 862 #[cfg_attr(all(test, feature = "std"), dto(export))] 863 #[derive(Clone, Debug, PartialEq, Eq)] 864 pub struct ContributionAttestation { 865 pub schema: String, 866 pub schema_version: u16, 867 pub contributor_pubkey: String, 868 pub contribution_type: String, 869 pub subject_refs: Vec<EventRef>, 870 pub summary: String, 871 pub evidence_refs: Vec<EventRef>, 872 } 873 874 #[cfg(test)] 875 #[cfg_attr(coverage_nightly, coverage(off))] 876 mod tests { 877 use super::*; 878 879 fn hex_64(character: char) -> String { 880 crate::test_valid_hex_64(character) 881 } 882 883 fn event_ref() -> EventRef { 884 event_ref_with_kind(1) 885 } 886 887 fn event_ref_with_kind(kind: u32) -> EventRef { 888 EventRef { 889 id: "0".repeat(64), 890 author: radroots_identity::PublicKey::from_hex(&crate::test_valid_hex_64('1')) 891 .expect("fixture public key"), 892 kind, 893 d_tag: None, 894 relays: None, 895 } 896 } 897 898 fn article_address_ref() -> AddressableRef { 899 AddressableRef { 900 kind: KIND_WIKI_ARTICLE, 901 pubkey: hex_64('a'), 902 d_tag: "soil-health".to_string(), 903 relays: Vec::new(), 904 } 905 } 906 907 fn article_version_ref() -> WikiArticleVersionRef { 908 WikiArticleVersionRef { 909 event_id: hex_64('b'), 910 address_ref: article_address_ref(), 911 } 912 } 913 914 fn wiki_article() -> WikiArticle { 915 WikiArticle { 916 d_tag: "soil-health".to_string(), 917 title: Some("Soil health".to_string()), 918 content_djot: "# Soil health".to_string(), 919 summary: None, 920 topics: Vec::new(), 921 references: vec![event_ref()], 922 forked_from: vec![article_version_ref()], 923 deferred_to: Some(article_version_ref()), 924 } 925 } 926 927 fn invalid_relay() -> String { 928 "http://relay.radroots.example".to_string() 929 } 930 931 fn knowledge_source() -> KnowledgeSource { 932 KnowledgeSource { 933 schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), 934 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 935 d_tag: "soil-source".to_string(), 936 title: "Soil Source".to_string(), 937 source_type: "book".to_string(), 938 authors: vec!["Ada Example".to_string()], 939 publisher: None, 940 publication_year: Some(2026), 941 edition: None, 942 canonical_url: None, 943 artifact_refs: vec![event_ref()], 944 author_asserted_rights: None, 945 topics: vec!["soil".to_string()], 946 summary: None, 947 } 948 } 949 950 fn citation_span() -> KnowledgeCitationSpan { 951 KnowledgeCitationSpan { 952 source_ref: event_ref_with_kind(crate::envelope::kind::KIND_KNOWLEDGE_SOURCE), 953 artifact_ref: Some(event_ref()), 954 page_start: None, 955 page_end: None, 956 section_path: Vec::new(), 957 quote_hash: Some(hex_64('c')), 958 chunk_id: None, 959 } 960 } 961 962 fn knowledge_claim() -> KnowledgeClaim { 963 KnowledgeClaim { 964 schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(), 965 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 966 claim_type: "practice_effect".to_string(), 967 text: "Cover crops improve soil structure.".to_string(), 968 citation_spans: vec![citation_span()], 969 topics: Vec::new(), 970 applies_to: Vec::new(), 971 author_asserted_confidence: None, 972 supersedes: vec![event_ref()], 973 } 974 } 975 976 fn node_ref() -> KnowledgeNodeRef { 977 KnowledgeNodeRef { 978 node_type: "event".to_string(), 979 event_ref: Some(event_ref()), 980 address_ref: None, 981 external_id: None, 982 label: None, 983 } 984 } 985 986 fn knowledge_relation() -> KnowledgeRelation { 987 KnowledgeRelation { 988 schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(), 989 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 990 subject: node_ref(), 991 predicate: "supports".to_string(), 992 object: node_ref(), 993 support_refs: vec![event_ref()], 994 author_asserted_confidence: None, 995 supersedes: Vec::new(), 996 } 997 } 998 999 fn knowledge_review() -> KnowledgeReview { 1000 KnowledgeReview { 1001 schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(), 1002 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1003 target: KnowledgeReviewTarget { 1004 event_id: hex_64('d'), 1005 author_pubkey: hex_64('a'), 1006 kind: crate::envelope::kind::KIND_KNOWLEDGE_CLAIM, 1007 address: None, 1008 relays: Vec::new(), 1009 review_scope: KnowledgeReviewScope::SpecificVersion, 1010 }, 1011 reviewer_role: "peer".to_string(), 1012 verdict: "needs_more_evidence".to_string(), 1013 scores: vec![KnowledgeReviewScore { 1014 dimension: "evidence".to_string(), 1015 value: "partial".to_string(), 1016 note: None, 1017 }], 1018 notes: None, 1019 evidence_refs: vec![event_ref()], 1020 } 1021 } 1022 1023 fn field_report() -> KnowledgeFieldReport { 1024 KnowledgeFieldReport { 1025 schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(), 1026 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1027 report_type: "observation".to_string(), 1028 title: "Field observation".to_string(), 1029 summary: None, 1030 context: KnowledgeFieldContext { 1031 location_precision: KnowledgeLocationPrecision::CoarseGeohash, 1032 public_location: None, 1033 private_location_ref: None, 1034 topics: Vec::new(), 1035 context_tags: Vec::new(), 1036 }, 1037 observations: vec![KnowledgeObservation { 1038 observation_type: "residue".to_string(), 1039 text: "Residue was visible.".to_string(), 1040 observed_at: None, 1041 values: vec![KnowledgeObservationValue { 1042 key: "coverage".to_string(), 1043 value: "medium".to_string(), 1044 unit: None, 1045 }], 1046 }], 1047 artifact_refs: vec![event_ref()], 1048 related_refs: vec![event_ref()], 1049 limitations: Vec::new(), 1050 } 1051 } 1052 1053 fn evidence_bounty() -> EvidenceBounty { 1054 EvidenceBounty { 1055 schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(), 1056 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1057 d_tag: "soil-bounty".to_string(), 1058 title: "Soil bounty".to_string(), 1059 summary: None, 1060 topics: Vec::new(), 1061 target_refs: vec![event_ref()], 1062 reward_note: None, 1063 closes_at: None, 1064 } 1065 } 1066 1067 fn knowledge_change_proposal() -> KnowledgeChangeProposal { 1068 KnowledgeChangeProposal { 1069 schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(), 1070 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1071 target: event_ref(), 1072 proposal_type: "amend".to_string(), 1073 summary: "Clarify scope".to_string(), 1074 rationale: None, 1075 evidence_refs: vec![event_ref()], 1076 supersedes: Vec::new(), 1077 } 1078 } 1079 1080 fn contribution_attestation() -> ContributionAttestation { 1081 ContributionAttestation { 1082 schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(), 1083 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1084 contributor_pubkey: hex_64('a'), 1085 contribution_type: "review".to_string(), 1086 subject_refs: vec![event_ref()], 1087 summary: "Reviewed claim".to_string(), 1088 evidence_refs: vec![event_ref()], 1089 } 1090 } 1091 1092 fn assert_validation_error( 1093 result: Result<(), KnowledgeValidationError>, 1094 expected: KnowledgeValidationError, 1095 ) { 1096 assert_eq!(result, Err(expected)); 1097 } 1098 1099 #[test] 1100 fn normalizes_wiki_d_tags() { 1101 assert_eq!( 1102 normalize_wiki_d_tag(" Soil Health Basics "), 1103 "soil-health-basics" 1104 ); 1105 assert_eq!( 1106 normalize_wiki_d_tag("Crème Brûlée 2026"), 1107 "crème-brûlée-2026" 1108 ); 1109 assert_eq!(normalize_wiki_d_tag("土壌 健康 101"), "土壌-健康-101"); 1110 assert_eq!( 1111 normalize_wiki_d_tag("Почва Здоровье 101"), 1112 "почва-здоровье-101" 1113 ); 1114 assert_eq!(normalize_wiki_d_tag("El Niño y Suelo"), "el-niño-y-suelo"); 1115 assert_eq!( 1116 normalize_wiki_d_tag("Field: Water & Soil!"), 1117 "field-water-soil" 1118 ); 1119 assert_eq!(normalize_wiki_d_tag("--Field---Notes--"), "field-notes"); 1120 assert_eq!(normalize_wiki_d_tag("中文 農業 101"), "中文-農業-101"); 1121 assert_eq!(normalize_wiki_d_tag("soil 🌱 health"), "soil-health"); 1122 assert_eq!(normalize_wiki_d_tag("!!! 🌱 !!!"), ""); 1123 assert_eq!( 1124 normalize_wiki_d_tag(&normalize_wiki_d_tag("Soil Health Basics")), 1125 "soil-health-basics" 1126 ); 1127 } 1128 1129 #[test] 1130 fn validates_normalized_wiki_d_tags() { 1131 assert_eq!(validate_wiki_d_tag("soil-health-basics"), Ok(())); 1132 assert_eq!(validate_wiki_d_tag(""), Err(WikiDTagError::Empty)); 1133 assert_eq!( 1134 validate_wiki_d_tag("Soil Health"), 1135 Err(WikiDTagError::NotNormalized { 1136 normalized: "soil-health".to_string() 1137 }) 1138 ); 1139 } 1140 1141 #[test] 1142 fn rejects_oversized_wiki_d_tags() { 1143 let value = "a".repeat(RADROOTS_WIKI_D_TAG_MAX_LEN + 1); 1144 1145 assert_eq!( 1146 validate_wiki_d_tag(&value), 1147 Err(WikiDTagError::TooLong { 1148 max: RADROOTS_WIKI_D_TAG_MAX_LEN, 1149 actual: RADROOTS_WIKI_D_TAG_MAX_LEN + 1, 1150 }) 1151 ); 1152 } 1153 1154 #[test] 1155 fn exposes_knowledge_schema_ids() { 1156 assert_eq!(RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1); 1157 assert_eq!( 1158 RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, 1159 "radroots.knowledge.source.v1" 1160 ); 1161 assert_eq!( 1162 RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, 1163 "radroots.knowledge.claim.v1" 1164 ); 1165 assert_eq!( 1166 RADROOTS_KNOWLEDGE_RELATION_SCHEMA, 1167 "radroots.knowledge.relation.v1" 1168 ); 1169 assert_eq!( 1170 RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, 1171 "radroots.knowledge.review.v1" 1172 ); 1173 assert_eq!( 1174 RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, 1175 "radroots.knowledge.field_report.v1" 1176 ); 1177 assert_eq!( 1178 RADROOTS_EVIDENCE_BOUNTY_SCHEMA, 1179 "radroots.knowledge.evidence_bounty.v1" 1180 ); 1181 assert_eq!( 1182 RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, 1183 "radroots.knowledge.change_proposal.v1" 1184 ); 1185 assert_eq!( 1186 RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, 1187 "radroots.knowledge.contribution_attestation.v1" 1188 ); 1189 } 1190 1191 #[test] 1192 fn models_wiki_article_as_djot_payload() { 1193 let article = WikiArticle { 1194 d_tag: "soil-health".to_string(), 1195 title: Some("Soil health".to_string()), 1196 content_djot: "# Soil health".to_string(), 1197 summary: Some("Living soil basics".to_string()), 1198 topics: vec!["soil".to_string()], 1199 references: vec![event_ref()], 1200 forked_from: Vec::new(), 1201 deferred_to: None, 1202 }; 1203 1204 assert_eq!(article.content_djot, "# Soil health"); 1205 assert_eq!(article.topics, vec!["soil"]); 1206 assert_eq!(article.references.len(), 1); 1207 } 1208 1209 #[test] 1210 fn models_author_asserted_rights_without_trusted_status() { 1211 let source = KnowledgeSource { 1212 schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), 1213 schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1214 d_tag: "soil-source".to_string(), 1215 title: "Soil Source".to_string(), 1216 source_type: "book".to_string(), 1217 authors: vec!["Ada Example".to_string()], 1218 publisher: None, 1219 publication_year: Some(2026), 1220 edition: None, 1221 canonical_url: None, 1222 artifact_refs: Vec::new(), 1223 author_asserted_rights: Some(RightsAssertion { 1224 assertion: "author_asserted_public_domain".to_string(), 1225 holder: None, 1226 license: None, 1227 url: None, 1228 }), 1229 topics: vec!["soil".to_string()], 1230 summary: None, 1231 }; 1232 1233 assert_eq!(source.schema_version, 1); 1234 assert_eq!( 1235 source 1236 .author_asserted_rights 1237 .as_ref() 1238 .expect("rights") 1239 .assertion, 1240 "author_asserted_public_domain" 1241 ); 1242 } 1243 1244 #[test] 1245 fn models_field_context_without_exact_private_coordinates() { 1246 let context = KnowledgeFieldContext { 1247 location_precision: KnowledgeLocationPrecision::ExactPrivateReference, 1248 public_location: Some(KnowledgeLocation { 1249 label: Some("watershed edge".to_string()), 1250 region: Some("sample-region".to_string()), 1251 locality: None, 1252 geohash: Some("c23".to_string()), 1253 }), 1254 private_location_ref: Some(event_ref()), 1255 topics: vec!["water".to_string()], 1256 context_tags: vec!["field".to_string()], 1257 }; 1258 1259 assert_eq!( 1260 context.location_precision, 1261 KnowledgeLocationPrecision::ExactPrivateReference 1262 ); 1263 assert!(context.private_location_ref.is_some()); 1264 assert_eq!( 1265 context.public_location.as_ref().expect("location").geohash, 1266 Some("c23".to_string()) 1267 ); 1268 } 1269 1270 #[test] 1271 fn knowledge_validators_accept_valid_models() { 1272 assert_eq!(validate_wiki_article(&wiki_article()), Ok(())); 1273 assert_eq!( 1274 validate_wiki_redirect(&WikiRedirect { 1275 d_tag: "soil".to_string(), 1276 target: article_address_ref(), 1277 }), 1278 Ok(()) 1279 ); 1280 assert_eq!( 1281 validate_wiki_merge_request(&WikiMergeRequest { 1282 target_article: article_address_ref(), 1283 destination_pubkey: hex_64('a'), 1284 base_version_event_id: Some(hex_64('e')), 1285 source_version_event_id: hex_64('f'), 1286 explanation: None, 1287 }), 1288 Ok(()) 1289 ); 1290 assert_eq!(validate_knowledge_source(&knowledge_source()), Ok(())); 1291 assert_eq!(validate_knowledge_claim(&knowledge_claim()), Ok(())); 1292 assert_eq!(validate_knowledge_relation(&knowledge_relation()), Ok(())); 1293 assert_eq!(validate_knowledge_review(&knowledge_review()), Ok(())); 1294 assert_eq!(validate_knowledge_field_report(&field_report()), Ok(())); 1295 assert_eq!(validate_evidence_bounty(&evidence_bounty()), Ok(())); 1296 assert_eq!( 1297 validate_knowledge_change_proposal(&knowledge_change_proposal()), 1298 Ok(()) 1299 ); 1300 assert_eq!( 1301 validate_contribution_attestation(&contribution_attestation()), 1302 Ok(()) 1303 ); 1304 } 1305 1306 #[test] 1307 fn knowledge_validators_reject_noncanonical_relay_values() { 1308 let mut article = wiki_article(); 1309 article.references[0].relays = Some(vec![invalid_relay()]); 1310 assert_validation_error( 1311 validate_wiki_article(&article), 1312 KnowledgeValidationError::InvalidField("references"), 1313 ); 1314 1315 let mut article = wiki_article(); 1316 article.forked_from[0].address_ref.relays = vec![invalid_relay()]; 1317 assert_validation_error( 1318 validate_wiki_article(&article), 1319 KnowledgeValidationError::InvalidField("forked_from"), 1320 ); 1321 1322 let mut article = wiki_article(); 1323 article 1324 .deferred_to 1325 .as_mut() 1326 .expect("deferred") 1327 .address_ref 1328 .relays = vec![invalid_relay()]; 1329 assert_validation_error( 1330 validate_wiki_article(&article), 1331 KnowledgeValidationError::InvalidField("deferred_to"), 1332 ); 1333 1334 let mut redirect = WikiRedirect { 1335 d_tag: "soil".to_string(), 1336 target: article_address_ref(), 1337 }; 1338 redirect.target.relays = vec![invalid_relay()]; 1339 assert_validation_error( 1340 validate_wiki_redirect(&redirect), 1341 KnowledgeValidationError::InvalidField("wiki_redirect.target"), 1342 ); 1343 1344 let mut merge = WikiMergeRequest { 1345 target_article: article_address_ref(), 1346 destination_pubkey: hex_64('a'), 1347 base_version_event_id: Some(hex_64('e')), 1348 source_version_event_id: hex_64('f'), 1349 explanation: None, 1350 }; 1351 merge.target_article.relays = vec![invalid_relay()]; 1352 assert_validation_error( 1353 validate_wiki_merge_request(&merge), 1354 KnowledgeValidationError::InvalidField("target_article"), 1355 ); 1356 1357 let mut source = knowledge_source(); 1358 source.artifact_refs[0].relays = Some(vec![invalid_relay()]); 1359 assert_validation_error( 1360 validate_knowledge_source(&source), 1361 KnowledgeValidationError::InvalidField("artifact_refs"), 1362 ); 1363 1364 let mut claim = knowledge_claim(); 1365 claim.citation_spans[0].source_ref.relays = Some(vec![invalid_relay()]); 1366 assert_validation_error( 1367 validate_knowledge_claim(&claim), 1368 KnowledgeValidationError::InvalidField("citation_spans"), 1369 ); 1370 1371 let mut relation = knowledge_relation(); 1372 relation.support_refs[0].relays = Some(vec![invalid_relay()]); 1373 assert_validation_error( 1374 validate_knowledge_relation(&relation), 1375 KnowledgeValidationError::InvalidField("support_refs"), 1376 ); 1377 1378 let mut review = knowledge_review(); 1379 review.target.relays = vec![invalid_relay()]; 1380 assert_validation_error( 1381 validate_knowledge_review(&review), 1382 KnowledgeValidationError::InvalidField("review_target"), 1383 ); 1384 1385 let mut report = field_report(); 1386 report.context.location_precision = KnowledgeLocationPrecision::ExactPrivateReference; 1387 let mut private_location_ref = event_ref(); 1388 private_location_ref.relays = Some(vec![invalid_relay()]); 1389 report.context.private_location_ref = Some(private_location_ref); 1390 assert_validation_error( 1391 validate_knowledge_field_report(&report), 1392 KnowledgeValidationError::InvalidField("private_location_ref"), 1393 ); 1394 1395 let mut bounty = evidence_bounty(); 1396 bounty.target_refs[0].relays = Some(vec![invalid_relay()]); 1397 assert_validation_error( 1398 validate_evidence_bounty(&bounty), 1399 KnowledgeValidationError::InvalidField("target_refs"), 1400 ); 1401 1402 let mut proposal = knowledge_change_proposal(); 1403 proposal.target.relays = Some(vec![invalid_relay()]); 1404 assert_validation_error( 1405 validate_knowledge_change_proposal(&proposal), 1406 KnowledgeValidationError::InvalidField("target"), 1407 ); 1408 1409 let mut attestation = contribution_attestation(); 1410 attestation.subject_refs[0].relays = Some(vec![invalid_relay()]); 1411 assert_validation_error( 1412 validate_contribution_attestation(&attestation), 1413 KnowledgeValidationError::InvalidField("subject_refs"), 1414 ); 1415 } 1416 1417 #[test] 1418 fn wiki_article_title_is_optional_but_not_blank() { 1419 let mut article = WikiArticle { 1420 d_tag: "soil-health".to_string(), 1421 title: None, 1422 content_djot: "# Soil health".to_string(), 1423 summary: None, 1424 topics: Vec::new(), 1425 references: Vec::new(), 1426 forked_from: Vec::new(), 1427 deferred_to: None, 1428 }; 1429 assert_eq!(validate_wiki_article(&article), Ok(())); 1430 1431 article.title = Some(" ".to_string()); 1432 assert_validation_error( 1433 validate_wiki_article(&article), 1434 KnowledgeValidationError::EmptyField("title"), 1435 ); 1436 } 1437 1438 #[test] 1439 fn knowledge_claims_require_citations_except_exact_uncited_types() { 1440 let mut claim = knowledge_claim(); 1441 claim.citation_spans.clear(); 1442 assert_validation_error( 1443 validate_knowledge_claim(&claim), 1444 KnowledgeValidationError::EmptyField("citation_spans"), 1445 ); 1446 1447 claim.citation_spans.push(citation_span()); 1448 assert_eq!(validate_knowledge_claim(&claim), Ok(())); 1449 1450 for claim_type in ["hypothesis", "observation", "question"] { 1451 let mut uncited = knowledge_claim(); 1452 uncited.claim_type = claim_type.to_string(); 1453 uncited.citation_spans.clear(); 1454 assert_eq!(validate_knowledge_claim(&uncited), Ok(())); 1455 assert!(is_uncited_knowledge_claim_type(claim_type)); 1456 } 1457 1458 let mut capitalized = knowledge_claim(); 1459 capitalized.claim_type = "Hypothesis".to_string(); 1460 capitalized.citation_spans.clear(); 1461 assert_validation_error( 1462 validate_knowledge_claim(&capitalized), 1463 KnowledgeValidationError::EmptyField("citation_spans"), 1464 ); 1465 assert!(!is_uncited_knowledge_claim_type("Hypothesis")); 1466 } 1467 1468 #[test] 1469 fn knowledge_validators_reject_representative_invalid_models() { 1470 let mut article = WikiArticle { 1471 d_tag: "soil-health".to_string(), 1472 title: Some("Soil health".to_string()), 1473 content_djot: " ".to_string(), 1474 summary: None, 1475 topics: Vec::new(), 1476 references: Vec::new(), 1477 forked_from: Vec::new(), 1478 deferred_to: None, 1479 }; 1480 assert_validation_error( 1481 validate_wiki_article(&article), 1482 KnowledgeValidationError::EmptyField("content_djot"), 1483 ); 1484 article.content_djot = "# Soil health".to_string(); 1485 article.forked_from.push(WikiArticleVersionRef { 1486 event_id: "bad".to_string(), 1487 address_ref: article_address_ref(), 1488 }); 1489 assert_validation_error( 1490 validate_wiki_article(&article), 1491 KnowledgeValidationError::InvalidField("forked_from"), 1492 ); 1493 1494 let mut redirect = WikiRedirect { 1495 d_tag: "soil".to_string(), 1496 target: article_address_ref(), 1497 }; 1498 redirect.target.kind = 30023; 1499 assert_validation_error( 1500 validate_wiki_redirect(&redirect), 1501 KnowledgeValidationError::InvalidField("wiki_redirect.target"), 1502 ); 1503 1504 let mut merge = WikiMergeRequest { 1505 target_article: article_address_ref(), 1506 destination_pubkey: "bad".to_string(), 1507 base_version_event_id: None, 1508 source_version_event_id: hex_64('f'), 1509 explanation: None, 1510 }; 1511 assert_validation_error( 1512 validate_wiki_merge_request(&merge), 1513 KnowledgeValidationError::InvalidField("destination_pubkey"), 1514 ); 1515 merge.destination_pubkey = hex_64('a'); 1516 merge.source_version_event_id = String::new(); 1517 assert_validation_error( 1518 validate_wiki_merge_request(&merge), 1519 KnowledgeValidationError::EmptyField("source_version_event_id"), 1520 ); 1521 1522 let mut source = knowledge_source(); 1523 source.authors.push(" ".to_string()); 1524 assert_validation_error( 1525 validate_knowledge_source(&source), 1526 KnowledgeValidationError::EmptyField("authors"), 1527 ); 1528 1529 let mut claim = knowledge_claim(); 1530 claim.citation_spans[0].quote_hash = Some("bad".to_string()); 1531 assert_validation_error( 1532 validate_knowledge_claim(&claim), 1533 KnowledgeValidationError::InvalidField("citation_spans"), 1534 ); 1535 1536 let mut relation = knowledge_relation(); 1537 relation.subject.external_id = Some("external".to_string()); 1538 assert_validation_error( 1539 validate_knowledge_relation(&relation), 1540 KnowledgeValidationError::InvalidField("subject"), 1541 ); 1542 1543 let mut review = knowledge_review(); 1544 review.target.kind = 0; 1545 assert_validation_error( 1546 validate_knowledge_review(&review), 1547 KnowledgeValidationError::InvalidField("review_target"), 1548 ); 1549 1550 let mut report = field_report(); 1551 report.context.location_precision = KnowledgeLocationPrecision::ExactPrivateReference; 1552 assert_validation_error( 1553 validate_knowledge_field_report(&report), 1554 KnowledgeValidationError::EmptyField("private_location_ref"), 1555 ); 1556 report.context.location_precision = KnowledgeLocationPrecision::CoarseGeohash; 1557 report.observations[0].values[0].value = String::new(); 1558 assert_validation_error( 1559 validate_knowledge_field_report(&report), 1560 KnowledgeValidationError::EmptyField("observation_values"), 1561 ); 1562 1563 let mut bounty = evidence_bounty(); 1564 bounty.target_refs.clear(); 1565 assert_validation_error( 1566 validate_evidence_bounty(&bounty), 1567 KnowledgeValidationError::EmptyField("target_refs"), 1568 ); 1569 1570 let mut proposal = knowledge_change_proposal(); 1571 proposal.summary = " ".to_string(); 1572 assert_validation_error( 1573 validate_knowledge_change_proposal(&proposal), 1574 KnowledgeValidationError::EmptyField("summary"), 1575 ); 1576 1577 let mut attestation = contribution_attestation(); 1578 attestation.contributor_pubkey = "bad".to_string(); 1579 assert_validation_error( 1580 validate_contribution_attestation(&attestation), 1581 KnowledgeValidationError::InvalidField("contributor_pubkey"), 1582 ); 1583 } 1584 }