Skip to main content

weavatrix_semantic/policy/
mod.rs

1mod seo_link;
2
3use crate::{Result, SemanticError, SemanticVector};
4pub use seo_link::SeoLinkPolicy;
5use std::collections::BTreeSet;
6use weavatrix_graph::{Edge, Graph, NodeId};
7
8/// Eligibility and evidence policy applied after semantic candidate discovery.
9///
10/// Policies never change cosine similarity. They decide which directed
11/// source-target recommendations are valid and annotate retained graph edges.
12pub trait LinkPolicy {
13    /// Stable policy identifier stored in reports and emitted edges.
14    fn id(&self) -> &str;
15
16    /// Validates policy coverage against the graph and semantic input.
17    ///
18    /// # Errors
19    ///
20    /// Returns an error when policy data is incomplete or inconsistent.
21    fn validate(&self, graph: &Graph, vectors: &[SemanticVector]) -> Result<()>;
22
23    /// Returns whether a directed source-target recommendation is eligible.
24    fn allows(&self, source: &NodeId, target: &NodeId) -> bool;
25
26    /// Adds policy-specific evidence to a retained edge.
27    ///
28    /// # Errors
29    ///
30    /// Implementations may reject inconsistent policy state.
31    fn annotate_edge(&self, edge: Edge, _source: &NodeId, _target: &NodeId) -> Result<Edge> {
32        Ok(edge.with_attribute("policy", self.id()))
33    }
34}
35
36/// Unrestricted policy used by the general-purpose semantic linker methods.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct AllowAllPolicy;
39
40impl LinkPolicy for AllowAllPolicy {
41    fn id(&self) -> &'static str {
42        "allow_all"
43    }
44
45    fn validate(&self, _graph: &Graph, _vectors: &[SemanticVector]) -> Result<()> {
46        Ok(())
47    }
48
49    fn allows(&self, source: &NodeId, target: &NodeId) -> bool {
50        source != target
51    }
52}
53
54/// Explicit page metadata used to decide whether an SEO link is valid.
55///
56/// Crawling, canonical resolution, indexability, language detection, existing
57/// link extraction, and authority analysis stay outside this crate. Their
58/// results enter the semantic layer through this deterministic profile.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct SeoPage {
61    node_id: NodeId,
62    site: String,
63    canonical: String,
64    language: Option<String>,
65    eligibility: SeoEligibility,
66    existing_targets: BTreeSet<NodeId>,
67    signals: SeoSignals,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71struct SeoEligibility {
72    source: bool,
73    target: bool,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77struct SeoSignals {
78    cornerstone: bool,
79    orphan: bool,
80    target_priority: u32,
81}
82
83impl SeoPage {
84    /// Creates an indexable source and target profile.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error for empty or whitespace-padded site/canonical values.
89    pub fn new(
90        node_id: NodeId,
91        site: impl Into<String>,
92        canonical: impl Into<String>,
93    ) -> Result<Self> {
94        let site = site.into();
95        let canonical = canonical.into();
96        validate_page_text(&node_id, "site", &site)?;
97        validate_page_text(&node_id, "canonical", &canonical)?;
98        Ok(Self {
99            node_id,
100            site,
101            canonical,
102            language: None,
103            eligibility: SeoEligibility {
104                source: true,
105                target: true,
106            },
107            existing_targets: BTreeSet::new(),
108            signals: SeoSignals {
109                cornerstone: false,
110                orphan: false,
111                target_priority: 0,
112            },
113        })
114    }
115
116    /// Graph node represented by this profile.
117    #[must_use]
118    pub const fn node_id(&self) -> &NodeId {
119        &self.node_id
120    }
121
122    /// Caller-normalized site identity used to prevent cross-site links.
123    #[must_use]
124    pub fn site(&self) -> &str {
125        &self.site
126    }
127
128    /// Caller-normalized canonical content identity.
129    #[must_use]
130    pub fn canonical(&self) -> &str {
131        &self.canonical
132    }
133
134    /// Optional caller-normalized content language.
135    #[must_use]
136    pub fn language(&self) -> Option<&str> {
137        self.language.as_deref()
138    }
139
140    /// Whether the page may be the source of a recommendation.
141    #[must_use]
142    pub const fn source_eligible(&self) -> bool {
143        self.eligibility.source
144    }
145
146    /// Whether the page may be the target of a recommendation.
147    #[must_use]
148    pub const fn target_eligible(&self) -> bool {
149        self.eligibility.target
150    }
151
152    /// Whether the target is designated as cornerstone content.
153    #[must_use]
154    pub const fn cornerstone(&self) -> bool {
155        self.signals.cornerstone
156    }
157
158    /// Whether the target currently has no known internal inbound links.
159    #[must_use]
160    pub const fn orphan(&self) -> bool {
161        self.signals.orphan
162    }
163
164    /// Caller-computed target priority exposed without changing similarity.
165    #[must_use]
166    pub const fn target_priority(&self) -> u32 {
167        self.signals.target_priority
168    }
169
170    /// Adds a normalized content language.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error for an empty or whitespace-padded language.
175    pub fn with_language(mut self, language: impl Into<String>) -> Result<Self> {
176        let language = language.into();
177        if language.is_empty() {
178            return Err(SemanticError::EmptySeoLanguage {
179                node: self.node_id.to_string(),
180            });
181        }
182        if language.trim() != language {
183            return Err(SemanticError::SeoLanguageHasSurroundingWhitespace {
184                node: self.node_id.to_string(),
185            });
186        }
187        self.language = Some(language);
188        Ok(self)
189    }
190
191    /// Enables or disables using the page as a recommendation source.
192    #[must_use]
193    pub const fn with_source_eligible(mut self, eligible: bool) -> Self {
194        self.eligibility.source = eligible;
195        self
196    }
197
198    /// Enables or disables using the page as a recommendation target.
199    #[must_use]
200    pub const fn with_target_eligible(mut self, eligible: bool) -> Self {
201        self.eligibility.target = eligible;
202        self
203    }
204
205    /// Records an already-existing directed internal link to suppress.
206    #[must_use]
207    pub fn with_existing_target(mut self, target: NodeId) -> Self {
208        self.existing_targets.insert(target);
209        self
210    }
211
212    /// Marks the page as cornerstone content for downstream prioritization.
213    #[must_use]
214    pub const fn with_cornerstone(mut self, cornerstone: bool) -> Self {
215        self.signals.cornerstone = cornerstone;
216        self
217    }
218
219    /// Marks the page as orphaned for downstream prioritization.
220    #[must_use]
221    pub const fn with_orphan(mut self, orphan: bool) -> Self {
222        self.signals.orphan = orphan;
223        self
224    }
225
226    /// Adds a caller-computed target priority without rewriting similarity.
227    #[must_use]
228    pub const fn with_target_priority(mut self, priority: u32) -> Self {
229        self.signals.target_priority = priority;
230        self
231    }
232}
233
234fn validate_page_text(node: &NodeId, field: &str, value: &str) -> Result<()> {
235    if value.is_empty() {
236        return Err(match field {
237            "site" => SemanticError::EmptySeoSite {
238                node: node.to_string(),
239            },
240            _ => SemanticError::EmptySeoCanonical {
241                node: node.to_string(),
242            },
243        });
244    }
245    if value.trim() != value {
246        return Err(match field {
247            "site" => SemanticError::SeoSiteHasSurroundingWhitespace {
248                node: node.to_string(),
249            },
250            _ => SemanticError::SeoCanonicalHasSurroundingWhitespace {
251                node: node.to_string(),
252            },
253        });
254    }
255    Ok(())
256}