lib

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

commit 462565184d37737bbe608afc26e6ba3b4518b8fa
parent 2d419433ca8e2ed56aa5539eb4b7d5d2d3a80a2c
Author: triesap <tyson@radroots.org>
Date:   Sun,  5 Jul 2026 05:12:39 +0000

events: add knowledge event models

- add knowledge and NIP-54 feature gates with kind classifiers
- add knowledge event structs and wiki d-tag normalization
- expose contract tag metadata and DTO roots for knowledge models
- validate default, no-std, dto-bindgen, and contract lanes

Diffstat:
Mcrates/events/Cargo.toml | 10+++++++++-
Mcrates/events/src/contract.rs | 2++
Mcrates/events/src/generated/dto_roots.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/kinds.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events/src/knowledge.rs | 593+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/lib.rs | 4+++-
Mcrates/events/src/tags.rs | 2++
Mdto_bindgen.toml | 1+
8 files changed, 815 insertions(+), 2 deletions(-)

diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml @@ -13,7 +13,15 @@ readme = "README" [features] default = ["std", "serde"] -dto-bindgen = ["std", "serde", "dep:dto_bindgen", "radroots_core/dto-bindgen"] +dto-bindgen = [ + "std", + "serde", + "knowledge-nip54", + "dep:dto_bindgen", + "radroots_core/dto-bindgen", +] +knowledge = [] +knowledge-nip54 = ["knowledge"] std = ["radroots_core/std"] serde = ["dep:serde", "radroots_core/serde"] diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs @@ -30,6 +30,7 @@ pub enum RadrootsNostrStandard { Nip51, Nip52, Nip53, + Nip54, Nip56, Nip57, Nip78, @@ -87,6 +88,7 @@ pub enum RadrootsContentSchema { JsonObject, PlainText, Markdown, + Djot, Encrypted, BinaryReference, } diff --git a/crates/events/src/generated/dto_roots.rs b/crates/events/src/generated/dto_roots.rs @@ -33,6 +33,44 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip ::dto_bindgen::export::RootDescriptor::new::<crate::job_request::RadrootsJobParam>(), ::dto_bindgen::export::RootDescriptor::new::<crate::job_request::RadrootsJobRequest>(), ::dto_bindgen::export::RootDescriptor::new::<crate::job_result::RadrootsJobResult>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsAddressableRef>(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::knowledge::RadrootsContributionAttestation, + >(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsEvidenceBounty>(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::knowledge::RadrootsKnowledgeChangeProposal, + >(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeCitationSpan>( + ), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeClaim>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeFieldContext>( + ), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeFieldReport>( + ), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeLocation>(), + ::dto_bindgen::export::RootDescriptor::new::< + crate::knowledge::RadrootsKnowledgeLocationPrecision, + >(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeNodeRef>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeObservation>( + ), + ::dto_bindgen::export::RootDescriptor::new::< + crate::knowledge::RadrootsKnowledgeObservationValue, + >(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeRelation>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReview>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReviewScope>( + ), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReviewScore>( + ), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReviewTarget>( + ), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeSource>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsRightsAssertion>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiArticle>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiMergeRequest>(), + ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiRedirect>(), ::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsList>(), ::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsListEntry>(), ::dto_bindgen::export::RootDescriptor::new::<crate::list_set::RadrootsListSet>(), diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs @@ -43,6 +43,9 @@ pub const KIND_LIST_EMOJIS: u32 = 10030; pub const KIND_LIST_DM_RELAYS: u32 = 10050; pub const KIND_LIST_GOOD_WIKI_AUTHORS: u32 = 10101; pub const KIND_LIST_GOOD_WIKI_RELAYS: u32 = 10102; +pub const KIND_WIKI_MERGE_REQUEST: u32 = 818; +pub const KIND_WIKI_ARTICLE: u32 = 30818; +pub const KIND_WIKI_REDIRECT: u32 = 30819; pub const KIND_LIST_SET_FOLLOW: u32 = 30000; pub const KIND_LIST_SET_GENERIC: u32 = 30001; pub const KIND_LIST_SET_RELAY: u32 = 30002; @@ -98,9 +101,71 @@ pub const KIND_TRADE_FORBIDDEN_3431: u32 = 3431; pub const KIND_TRADE_FULFILLMENT_UPDATE: u32 = 3433; pub const KIND_TRADE_RECEIPT: u32 = 3434; pub const KIND_TRADE_VALIDATION_RECEIPT: u32 = 3440; +pub const KIND_KNOWLEDGE_CLAIM: u32 = 3460; +pub const KIND_KNOWLEDGE_RELATION: u32 = 3461; +pub const KIND_KNOWLEDGE_REVIEW: u32 = 3462; +pub const KIND_KNOWLEDGE_FIELD_REPORT: u32 = 3463; +pub const KIND_KNOWLEDGE_CHANGE_PROPOSAL: u32 = 3464; +pub const KIND_CONTRIBUTION_ATTESTATION: u32 = 3465; +pub const KIND_KNOWLEDGE_SOURCE: u32 = 30450; +pub const KIND_EVIDENCE_BOUNTY: u32 = 30451; pub const LISTING_EVENT_KINDS: [u32; 2] = [KIND_LISTING, KIND_LISTING_DRAFT]; +pub const WIKI_EVENT_KINDS: [u32; 3] = [ + KIND_WIKI_MERGE_REQUEST, + KIND_WIKI_ARTICLE, + KIND_WIKI_REDIRECT, +]; + +pub const KNOWLEDGE_MVP_EVENT_KINDS: [u32; 8] = [ + KIND_WIKI_MERGE_REQUEST, + KIND_WIKI_ARTICLE, + KIND_WIKI_REDIRECT, + KIND_KNOWLEDGE_SOURCE, + KIND_KNOWLEDGE_CLAIM, + KIND_KNOWLEDGE_RELATION, + KIND_KNOWLEDGE_REVIEW, + KIND_KNOWLEDGE_FIELD_REPORT, +]; + +pub const KNOWLEDGE_BETA_EVENT_KINDS: [u32; 3] = [ + KIND_EVIDENCE_BOUNTY, + KIND_KNOWLEDGE_CHANGE_PROPOSAL, + KIND_CONTRIBUTION_ATTESTATION, +]; + +pub const KNOWLEDGE_EVENT_KINDS: [u32; 11] = [ + KIND_WIKI_MERGE_REQUEST, + KIND_WIKI_ARTICLE, + KIND_WIKI_REDIRECT, + KIND_KNOWLEDGE_SOURCE, + KIND_KNOWLEDGE_CLAIM, + KIND_KNOWLEDGE_RELATION, + KIND_KNOWLEDGE_REVIEW, + KIND_KNOWLEDGE_FIELD_REPORT, + KIND_EVIDENCE_BOUNTY, + KIND_KNOWLEDGE_CHANGE_PROPOSAL, + KIND_CONTRIBUTION_ATTESTATION, +]; + +pub const KNOWLEDGE_ADDRESSABLE_EVENT_KINDS: [u32; 4] = [ + KIND_WIKI_ARTICLE, + KIND_WIKI_REDIRECT, + KIND_KNOWLEDGE_SOURCE, + KIND_EVIDENCE_BOUNTY, +]; + +pub const KNOWLEDGE_REGULAR_EVENT_KINDS: [u32; 7] = [ + KIND_WIKI_MERGE_REQUEST, + KIND_KNOWLEDGE_CLAIM, + KIND_KNOWLEDGE_RELATION, + KIND_KNOWLEDGE_REVIEW, + KIND_KNOWLEDGE_FIELD_REPORT, + KIND_KNOWLEDGE_CHANGE_PROPOSAL, + KIND_CONTRIBUTION_ATTESTATION, +]; + pub const ORDER_EVENT_KINDS: [u32; 5] = [ KIND_ORDER_REQUEST, KIND_ORDER_DECISION, @@ -331,6 +396,64 @@ pub const fn is_production_social_kind(kind: u32) -> bool { } #[inline] +pub const fn is_wiki_event_kind(kind: u32) -> bool { + matches!( + kind, + KIND_WIKI_MERGE_REQUEST | KIND_WIKI_ARTICLE | KIND_WIKI_REDIRECT + ) +} + +#[inline] +pub const fn is_knowledge_mvp_event_kind(kind: u32) -> bool { + matches!( + kind, + KIND_WIKI_MERGE_REQUEST + | KIND_WIKI_ARTICLE + | KIND_WIKI_REDIRECT + | KIND_KNOWLEDGE_SOURCE + | KIND_KNOWLEDGE_CLAIM + | KIND_KNOWLEDGE_RELATION + | KIND_KNOWLEDGE_REVIEW + | KIND_KNOWLEDGE_FIELD_REPORT + ) +} + +#[inline] +pub const fn is_knowledge_beta_event_kind(kind: u32) -> bool { + matches!( + kind, + KIND_EVIDENCE_BOUNTY | KIND_KNOWLEDGE_CHANGE_PROPOSAL | KIND_CONTRIBUTION_ATTESTATION + ) +} + +#[inline] +pub const fn is_knowledge_event_kind(kind: u32) -> bool { + is_knowledge_mvp_event_kind(kind) || is_knowledge_beta_event_kind(kind) +} + +#[inline] +pub const fn is_knowledge_addressable_event_kind(kind: u32) -> bool { + matches!( + kind, + KIND_WIKI_ARTICLE | KIND_WIKI_REDIRECT | KIND_KNOWLEDGE_SOURCE | KIND_EVIDENCE_BOUNTY + ) +} + +#[inline] +pub const fn is_knowledge_regular_event_kind(kind: u32) -> bool { + matches!( + kind, + KIND_WIKI_MERGE_REQUEST + | KIND_KNOWLEDGE_CLAIM + | KIND_KNOWLEDGE_RELATION + | KIND_KNOWLEDGE_REVIEW + | KIND_KNOWLEDGE_FIELD_REPORT + | KIND_KNOWLEDGE_CHANGE_PROPOSAL + | KIND_CONTRIBUTION_ATTESTATION + ) +} + +#[inline] pub const fn is_home_feed_candidate_kind(kind: u32) -> bool { matches!( kind, @@ -669,6 +792,50 @@ mod tests { } #[test] + fn classifies_knowledge_event_kinds() { + assert_eq!( + WIKI_EVENT_KINDS, + [ + KIND_WIKI_MERGE_REQUEST, + KIND_WIKI_ARTICLE, + KIND_WIKI_REDIRECT + ] + ); + assert_eq!(KNOWLEDGE_MVP_EVENT_KINDS.len(), 8); + assert_eq!(KNOWLEDGE_BETA_EVENT_KINDS.len(), 3); + assert_eq!(KNOWLEDGE_EVENT_KINDS.len(), 11); + assert_eq!(KNOWLEDGE_ADDRESSABLE_EVENT_KINDS.len(), 4); + assert_eq!(KNOWLEDGE_REGULAR_EVENT_KINDS.len(), 7); + + assert_eq!(KIND_WIKI_MERGE_REQUEST, 818); + assert_eq!(KIND_WIKI_ARTICLE, 30818); + assert_eq!(KIND_WIKI_REDIRECT, 30819); + assert_eq!(KIND_KNOWLEDGE_SOURCE, 30450); + assert_eq!(KIND_EVIDENCE_BOUNTY, 30451); + assert_eq!(KIND_KNOWLEDGE_CLAIM, 3460); + assert_eq!(KIND_KNOWLEDGE_RELATION, 3461); + assert_eq!(KIND_KNOWLEDGE_REVIEW, 3462); + assert_eq!(KIND_KNOWLEDGE_FIELD_REPORT, 3463); + assert_eq!(KIND_KNOWLEDGE_CHANGE_PROPOSAL, 3464); + assert_eq!(KIND_CONTRIBUTION_ATTESTATION, 3465); + + assert!(is_wiki_event_kind(KIND_WIKI_ARTICLE)); + assert!(is_knowledge_event_kind(KIND_WIKI_ARTICLE)); + assert!(is_knowledge_event_kind(KIND_KNOWLEDGE_CLAIM)); + assert!(is_knowledge_mvp_event_kind(KIND_KNOWLEDGE_FIELD_REPORT)); + assert!(is_knowledge_beta_event_kind(KIND_EVIDENCE_BOUNTY)); + assert!(is_knowledge_addressable_event_kind(KIND_KNOWLEDGE_SOURCE)); + assert!(is_knowledge_addressable_event_kind(KIND_EVIDENCE_BOUNTY)); + assert!(is_knowledge_regular_event_kind(KIND_WIKI_MERGE_REQUEST)); + assert!(is_knowledge_regular_event_kind( + KIND_CONTRIBUTION_ATTESTATION + )); + assert!(!is_knowledge_regular_event_kind(KIND_KNOWLEDGE_SOURCE)); + assert!(!is_knowledge_addressable_event_kind(KIND_KNOWLEDGE_CLAIM)); + assert!(!is_knowledge_event_kind(KIND_ARTICLE)); + } + + #[test] fn classifies_product_surface_candidate_kinds() { assert_eq!(HOME_FEED_CANDIDATE_KINDS.len(), 9); assert_eq!(EVENTS_CANDIDATE_KINDS.len(), 4); diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs @@ -0,0 +1,593 @@ +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use core::fmt; + +use crate::RadrootsNostrEventRef; + +pub const RADROOTS_KNOWLEDGE_SCHEMA_VERSION: u16 = 1; +pub const RADROOTS_WIKI_D_TAG_MAX_LEN: usize = 512; +pub const RADROOTS_KNOWLEDGE_SOURCE_SCHEMA: &str = "radroots.knowledge.source.v1"; +pub const RADROOTS_KNOWLEDGE_CLAIM_SCHEMA: &str = "radroots.knowledge.claim.v1"; +pub const RADROOTS_KNOWLEDGE_RELATION_SCHEMA: &str = "radroots.knowledge.relation.v1"; +pub const RADROOTS_KNOWLEDGE_REVIEW_SCHEMA: &str = "radroots.knowledge.review.v1"; +pub const RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA: &str = "radroots.knowledge.field_report.v1"; +pub const RADROOTS_EVIDENCE_BOUNTY_SCHEMA: &str = "radroots.knowledge.evidence_bounty.v1"; +pub const RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA: &str = "radroots.knowledge.change_proposal.v1"; +pub const RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA: &str = + "radroots.knowledge.contribution_attestation.v1"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsWikiDTagError { + Empty, + TooLong { max: usize, actual: usize }, + NotNormalized { normalized: String }, +} + +impl RadrootsWikiDTagError { + pub const fn code(&self) -> &'static str { + match self { + Self::Empty => "empty", + Self::TooLong { .. } => "too_long", + Self::NotNormalized { .. } => "not_normalized", + } + } +} + +impl fmt::Display for RadrootsWikiDTagError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("wiki d tag must not be empty"), + Self::TooLong { max, actual } => { + write!( + formatter, + "wiki d tag length {actual} exceeds maximum length {max}" + ) + } + Self::NotNormalized { normalized } => { + write!( + formatter, + "wiki d tag must match normalized value {normalized}" + ) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsWikiDTagError {} + +pub fn normalize_wiki_d_tag(input: &str) -> String { + let mut normalized = String::new(); + let mut pending_separator = false; + + for raw in input.trim().chars() { + for character in raw.to_lowercase() { + if character.is_alphanumeric() { + if pending_separator && !normalized.is_empty() { + normalized.push('-'); + } + normalized.push(character); + pending_separator = false; + } else if character.is_whitespace() || character == '-' { + pending_separator = true; + } + } + } + + normalized +} + +pub fn validate_wiki_d_tag(value: &str) -> Result<(), RadrootsWikiDTagError> { + if value.is_empty() { + return Err(RadrootsWikiDTagError::Empty); + } + + let actual = value.chars().count(); + if actual > RADROOTS_WIKI_D_TAG_MAX_LEN { + return Err(RadrootsWikiDTagError::TooLong { + max: RADROOTS_WIKI_D_TAG_MAX_LEN, + actual, + }); + } + + let normalized = normalize_wiki_d_tag(value); + if normalized != value { + return Err(RadrootsWikiDTagError::NotNormalized { normalized }); + } + + Ok(()) +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableRef { + pub kind: u32, + pub pubkey: String, + pub d_tag: String, + pub relays: Vec<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsRightsAssertion { + pub assertion: String, + pub holder: Option<String>, + pub license: Option<String>, + pub url: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsWikiArticle { + pub d_tag: String, + pub title: String, + pub content_djot: String, + pub summary: Option<String>, + pub topics: Vec<String>, + pub references: Vec<RadrootsNostrEventRef>, + pub forked_from: Vec<RadrootsNostrEventRef>, + pub deferred_to: Option<RadrootsNostrEventRef>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsWikiRedirect { + pub d_tag: String, + pub target: RadrootsNostrEventRef, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsWikiMergeRequest { + pub target_article: RadrootsAddressableRef, + pub destination_pubkey: String, + pub base_version_event_id: Option<String>, + pub source_version_event_id: String, + pub explanation: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeSource { + pub schema: String, + pub schema_version: u16, + pub d_tag: String, + pub title: String, + pub source_type: String, + pub authors: Vec<String>, + pub publisher: Option<String>, + pub publication_year: Option<u16>, + pub edition: Option<String>, + pub canonical_url: Option<String>, + pub artifact_refs: Vec<RadrootsNostrEventRef>, + pub author_asserted_rights: Option<RadrootsRightsAssertion>, + pub topics: Vec<String>, + pub summary: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeClaim { + pub schema: String, + pub schema_version: u16, + pub claim_type: String, + pub text: String, + pub citation_spans: Vec<RadrootsKnowledgeCitationSpan>, + pub topics: Vec<String>, + pub applies_to: Vec<String>, + pub author_asserted_confidence: Option<String>, + pub supersedes: Vec<RadrootsNostrEventRef>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeCitationSpan { + pub source_ref: RadrootsNostrEventRef, + pub artifact_ref: Option<RadrootsNostrEventRef>, + pub page_start: Option<u32>, + pub page_end: Option<u32>, + pub section_path: Vec<String>, + pub quote_hash: Option<String>, + pub chunk_id: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeNodeRef { + pub node_type: String, + pub event_ref: Option<RadrootsNostrEventRef>, + pub address_ref: Option<RadrootsAddressableRef>, + pub external_id: Option<String>, + pub label: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeRelation { + pub schema: String, + pub schema_version: u16, + pub subject: RadrootsKnowledgeNodeRef, + pub predicate: String, + pub object: RadrootsKnowledgeNodeRef, + pub support_refs: Vec<RadrootsNostrEventRef>, + pub author_asserted_confidence: Option<String>, + pub supersedes: Vec<RadrootsNostrEventRef>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeReview { + pub schema: String, + pub schema_version: u16, + pub target: RadrootsKnowledgeReviewTarget, + pub reviewer_role: String, + pub verdict: String, + pub scores: Vec<RadrootsKnowledgeReviewScore>, + pub notes: Option<String>, + pub evidence_refs: Vec<RadrootsNostrEventRef>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeReviewTarget { + pub event_id: String, + pub author_pubkey: String, + pub kind: u32, + pub address: Option<String>, + pub relays: Vec<String>, + pub review_scope: RadrootsKnowledgeReviewScope, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsKnowledgeReviewScope { + SpecificVersion, + AddressableCoordinateAtPublishedAt, + PolicyLatest, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeReviewScore { + pub dimension: String, + pub value: String, + pub note: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeFieldReport { + pub schema: String, + pub schema_version: u16, + pub report_type: String, + pub title: String, + pub summary: Option<String>, + pub context: RadrootsKnowledgeFieldContext, + pub observations: Vec<RadrootsKnowledgeObservation>, + pub artifact_refs: Vec<RadrootsNostrEventRef>, + pub related_refs: Vec<RadrootsNostrEventRef>, + pub limitations: Vec<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeFieldContext { + pub location_precision: RadrootsKnowledgeLocationPrecision, + pub public_location: Option<RadrootsKnowledgeLocation>, + pub private_location_ref: Option<RadrootsNostrEventRef>, + pub topics: Vec<String>, + pub context_tags: Vec<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsKnowledgeLocationPrecision { + None, + Region, + Locality, + CoarseGeohash, + ExactPublic, + ExactPrivateReference, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeLocation { + pub label: Option<String>, + pub region: Option<String>, + pub locality: Option<String>, + pub geohash: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeObservation { + pub observation_type: String, + pub text: String, + pub observed_at: Option<String>, + pub values: Vec<RadrootsKnowledgeObservationValue>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeObservationValue { + pub key: String, + pub value: String, + pub unit: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsEvidenceBounty { + pub schema: String, + pub schema_version: u16, + pub d_tag: String, + pub title: String, + pub summary: Option<String>, + pub topics: Vec<String>, + pub target_refs: Vec<RadrootsNostrEventRef>, + pub reward_note: Option<String>, + pub closes_at: Option<String>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsKnowledgeChangeProposal { + pub schema: String, + pub schema_version: u16, + pub target: RadrootsNostrEventRef, + pub proposal_type: String, + pub summary: String, + pub rationale: Option<String>, + pub evidence_refs: Vec<RadrootsNostrEventRef>, + pub supersedes: Vec<RadrootsNostrEventRef>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(export))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsContributionAttestation { + pub schema: String, + pub schema_version: u16, + pub contributor_pubkey: String, + pub contribution_type: String, + pub subject_refs: Vec<RadrootsNostrEventRef>, + pub summary: String, + pub evidence_refs: Vec<RadrootsNostrEventRef>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event_ref() -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: "0".repeat(64), + author: "1".repeat(64), + kind: 1, + d_tag: None, + relays: None, + } + } + + #[test] + fn normalizes_wiki_d_tags() { + assert_eq!( + normalize_wiki_d_tag(" Soil Health Basics "), + "soil-health-basics" + ); + assert_eq!( + normalize_wiki_d_tag("Crème Brûlée 2026"), + "crème-brûlée-2026" + ); + assert_eq!(normalize_wiki_d_tag("土壌 健康 101"), "土壌-健康-101"); + assert_eq!( + normalize_wiki_d_tag("Почва Здоровье 101"), + "почва-здоровье-101" + ); + assert_eq!(normalize_wiki_d_tag("El Niño y Suelo"), "el-niño-y-suelo"); + assert_eq!( + normalize_wiki_d_tag("Field: Water & Soil!"), + "field-water-soil" + ); + assert_eq!(normalize_wiki_d_tag("--Field---Notes--"), "field-notes"); + assert_eq!(normalize_wiki_d_tag("中文 農業 101"), "中文-農業-101"); + assert_eq!(normalize_wiki_d_tag("soil 🌱 health"), "soil-health"); + assert_eq!(normalize_wiki_d_tag("!!! 🌱 !!!"), ""); + assert_eq!( + normalize_wiki_d_tag(&normalize_wiki_d_tag("Soil Health Basics")), + "soil-health-basics" + ); + } + + #[test] + fn validates_normalized_wiki_d_tags() { + assert_eq!(validate_wiki_d_tag("soil-health-basics"), Ok(())); + assert_eq!(validate_wiki_d_tag(""), Err(RadrootsWikiDTagError::Empty)); + assert_eq!( + validate_wiki_d_tag("Soil Health"), + Err(RadrootsWikiDTagError::NotNormalized { + normalized: "soil-health".to_string() + }) + ); + } + + #[test] + fn rejects_oversized_wiki_d_tags() { + let value = "a".repeat(RADROOTS_WIKI_D_TAG_MAX_LEN + 1); + + assert_eq!( + validate_wiki_d_tag(&value), + Err(RadrootsWikiDTagError::TooLong { + max: RADROOTS_WIKI_D_TAG_MAX_LEN, + actual: RADROOTS_WIKI_D_TAG_MAX_LEN + 1, + }) + ); + } + + #[test] + fn exposes_knowledge_schema_ids() { + assert_eq!(RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1); + assert_eq!( + RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, + "radroots.knowledge.source.v1" + ); + assert_eq!( + RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, + "radroots.knowledge.claim.v1" + ); + assert_eq!( + RADROOTS_KNOWLEDGE_RELATION_SCHEMA, + "radroots.knowledge.relation.v1" + ); + assert_eq!( + RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, + "radroots.knowledge.review.v1" + ); + assert_eq!( + RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, + "radroots.knowledge.field_report.v1" + ); + assert_eq!( + RADROOTS_EVIDENCE_BOUNTY_SCHEMA, + "radroots.knowledge.evidence_bounty.v1" + ); + assert_eq!( + RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, + "radroots.knowledge.change_proposal.v1" + ); + assert_eq!( + RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, + "radroots.knowledge.contribution_attestation.v1" + ); + } + + #[test] + fn models_wiki_article_as_djot_payload() { + let article = RadrootsWikiArticle { + d_tag: "soil-health".to_string(), + title: "Soil health".to_string(), + content_djot: "# Soil health".to_string(), + summary: Some("Living soil basics".to_string()), + topics: vec!["soil".to_string()], + references: vec![event_ref()], + forked_from: Vec::new(), + deferred_to: None, + }; + + assert_eq!(article.content_djot, "# Soil health"); + assert_eq!(article.topics, vec!["soil"]); + assert_eq!(article.references.len(), 1); + } + + #[test] + fn models_author_asserted_rights_without_trusted_status() { + let source = RadrootsKnowledgeSource { + schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), + schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION, + d_tag: "soil-source".to_string(), + title: "Soil Source".to_string(), + source_type: "book".to_string(), + authors: vec!["Ada Example".to_string()], + publisher: None, + publication_year: Some(2026), + edition: None, + canonical_url: None, + artifact_refs: Vec::new(), + author_asserted_rights: Some(RadrootsRightsAssertion { + assertion: "author_asserted_public_domain".to_string(), + holder: None, + license: None, + url: None, + }), + topics: vec!["soil".to_string()], + summary: None, + }; + + assert_eq!(source.schema_version, 1); + assert_eq!( + source + .author_asserted_rights + .as_ref() + .expect("rights") + .assertion, + "author_asserted_public_domain" + ); + } + + #[test] + fn models_field_context_without_exact_private_coordinates() { + let context = RadrootsKnowledgeFieldContext { + location_precision: RadrootsKnowledgeLocationPrecision::ExactPrivateReference, + public_location: Some(RadrootsKnowledgeLocation { + label: Some("watershed edge".to_string()), + region: Some("sample-region".to_string()), + locality: None, + geohash: Some("c23".to_string()), + }), + private_location_ref: Some(event_ref()), + topics: vec!["water".to_string()], + context_tags: vec!["field".to_string()], + }; + + assert_eq!( + context.location_precision, + RadrootsKnowledgeLocationPrecision::ExactPrivateReference + ); + assert!(context.private_location_ref.is_some()); + assert_eq!( + context.public_location.as_ref().expect("location").geohash, + Some("c23".to_string()) + ); + } +} diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -35,6 +35,8 @@ pub mod job_feedback; pub mod job_request; pub mod job_result; pub mod kinds; +#[cfg(feature = "knowledge")] +pub mod knowledge; pub mod list; pub mod list_set; pub mod listing; @@ -75,7 +77,7 @@ pub struct RadrootsNostrEvent { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] #[cfg_attr(feature = "dto-bindgen", dto(export))] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsNostrEventRef { pub id: String, pub author: String, diff --git a/crates/events/src/tags.rs b/crates/events/src/tags.rs @@ -38,6 +38,7 @@ pub const TAG_MAGNET: &str = "magnet"; pub const TAG_SERVICE: &str = "service"; pub const TAG_RELAY: &str = "relay"; pub const TAG_CHALLENGE: &str = "challenge"; +pub const TAG_CONTRACT: &str = "contract"; pub const TAG_TITLE: &str = "title"; pub const TAG_PUBLISHED_AT: &str = "published_at"; pub const TAG_START: &str = "start"; @@ -103,6 +104,7 @@ mod tests { assert_eq!(TAG_SERVICE, "service"); assert_eq!(TAG_RELAY, "relay"); assert_eq!(TAG_CHALLENGE, "challenge"); + assert_eq!(TAG_CONTRACT, "contract"); } #[test] diff --git a/dto_bindgen.toml b/dto_bindgen.toml @@ -69,6 +69,7 @@ source_files = [ "crates/events/src/job_feedback.rs", "crates/events/src/job_request.rs", "crates/events/src/job_result.rs", + "crates/events/src/knowledge.rs", "crates/events/src/lib.rs", "crates/events/src/list.rs", "crates/events/src/list_set.rs",