Skip to main content

weavatrix_semantic/anchor/
mod.rs

1mod matcher;
2
3use crate::{Result, SemanticError, SemanticVector};
4use matcher::validate_candidate_text;
5use weavatrix_graph::NodeId;
6
7/// Configuration for matching extracted source text to link targets.
8#[derive(Debug, Clone, PartialEq)]
9pub struct AnchorConfig {
10    model: String,
11    min_similarity: f64,
12    max_suggestions_per_link: usize,
13}
14
15impl AnchorConfig {
16    /// Creates a model-specific anchor matching policy.
17    #[must_use]
18    pub fn new(
19        model: impl Into<String>,
20        min_similarity: f64,
21        max_suggestions_per_link: usize,
22    ) -> Self {
23        Self {
24            model: model.into(),
25            min_similarity,
26            max_suggestions_per_link,
27        }
28    }
29
30    /// Embedding model expected on both links and text fragments.
31    #[must_use]
32    pub fn model(&self) -> &str {
33        &self.model
34    }
35
36    /// Inclusive cosine threshold for a source text fragment.
37    #[must_use]
38    pub const fn min_similarity(&self) -> f64 {
39        self.min_similarity
40    }
41
42    /// Maximum ranked anchor placements returned per directed link.
43    #[must_use]
44    pub const fn max_suggestions_per_link(&self) -> usize {
45        self.max_suggestions_per_link
46    }
47
48    fn validate(&self) -> Result<()> {
49        if self.model.is_empty() {
50            return Err(SemanticError::EmptyAnchorModel);
51        }
52        if self.model.trim() != self.model {
53            return Err(SemanticError::AnchorModelHasSurroundingWhitespace);
54        }
55        if !self.min_similarity.is_finite() || !(0.0..=1.0).contains(&self.min_similarity) {
56            return Err(SemanticError::InvalidAnchorSimilarityThreshold);
57        }
58        if self.max_suggestions_per_link == 0 {
59            return Err(SemanticError::ZeroAnchorSuggestions);
60        }
61        Ok(())
62    }
63}
64
65/// Caller-extracted source text that could carry an internal link.
66///
67/// `locator` is an opaque stable location such as a DOM path or source span.
68/// The vector should represent `context`; the exact `anchor_text` is preserved
69/// for review or downstream HTML mutation.
70#[derive(Debug, Clone, PartialEq)]
71pub struct AnchorCandidate {
72    source: NodeId,
73    locator: String,
74    anchor_text: String,
75    context: String,
76    vector: SemanticVector,
77}
78
79impl AnchorCandidate {
80    /// Creates and validates an extracted anchor candidate.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error for empty metadata or an invalid semantic vector.
85    pub fn new(
86        source: NodeId,
87        locator: impl Into<String>,
88        anchor_text: impl Into<String>,
89        context: impl Into<String>,
90        values: Vec<f32>,
91    ) -> Result<Self> {
92        let locator = locator.into();
93        let anchor_text = anchor_text.into();
94        let context = context.into();
95        if locator.is_empty() {
96            return Err(SemanticError::EmptyAnchorLocator {
97                source: source.to_string(),
98            });
99        }
100        validate_candidate_text(&source, &locator, "locator", &locator)?;
101        if anchor_text.is_empty() {
102            return Err(SemanticError::EmptyAnchorText {
103                source: source.to_string(),
104                locator,
105            });
106        }
107        validate_candidate_text(&source, &locator, "anchor_text", &anchor_text)?;
108        if context.is_empty() {
109            return Err(SemanticError::EmptyAnchorContext {
110                source: source.to_string(),
111                locator,
112            });
113        }
114        validate_candidate_text(&source, &locator, "context", &context)?;
115        if !context.contains(&anchor_text) {
116            return Err(SemanticError::AnchorTextOutsideContext {
117                source: source.to_string(),
118                locator,
119            });
120        }
121        let vector = SemanticVector::new(source.to_string(), values)?;
122        Ok(Self {
123            source,
124            locator,
125            anchor_text,
126            context,
127            vector,
128        })
129    }
130
131    /// Source page containing the text.
132    #[must_use]
133    pub const fn source(&self) -> &NodeId {
134        &self.source
135    }
136
137    /// Opaque caller-defined text location.
138    #[must_use]
139    pub fn locator(&self) -> &str {
140        &self.locator
141    }
142
143    /// Exact existing source text proposed as the anchor.
144    #[must_use]
145    pub fn anchor_text(&self) -> &str {
146        &self.anchor_text
147    }
148
149    /// Surrounding text used to compute the candidate vector.
150    #[must_use]
151    pub fn context(&self) -> &str {
152        &self.context
153    }
154}
155
156/// One ranked placement for a directed internal-link recommendation.
157#[derive(Debug, Clone, PartialEq)]
158pub struct AnchorSuggestion {
159    locator: String,
160    anchor_text: String,
161    context: String,
162    similarity: f64,
163}
164
165impl AnchorSuggestion {
166    /// Opaque source location.
167    #[must_use]
168    pub fn locator(&self) -> &str {
169        &self.locator
170    }
171
172    /// Exact existing text proposed as anchor text.
173    #[must_use]
174    pub fn anchor_text(&self) -> &str {
175        &self.anchor_text
176    }
177
178    /// Surrounding source text used for semantic matching.
179    #[must_use]
180    pub fn context(&self) -> &str {
181        &self.context
182    }
183
184    /// Exact cosine similarity between source context and target page.
185    #[must_use]
186    pub const fn similarity(&self) -> f64 {
187        self.similarity
188    }
189}
190
191/// Ranked placements for one directed semantic link.
192#[derive(Debug, Clone, PartialEq)]
193pub struct AnchoredLink {
194    source: NodeId,
195    target: NodeId,
196    link_similarity: f64,
197    suggestions: Vec<AnchorSuggestion>,
198}
199
200impl AnchoredLink {
201    /// Source page.
202    #[must_use]
203    pub const fn source(&self) -> &NodeId {
204        &self.source
205    }
206
207    /// Target page.
208    #[must_use]
209    pub const fn target(&self) -> &NodeId {
210        &self.target
211    }
212
213    /// Page-level semantic similarity carried by the graph edge.
214    #[must_use]
215    pub const fn link_similarity(&self) -> f64 {
216        self.link_similarity
217    }
218
219    /// Ranked, exact source-text placements.
220    #[must_use]
221    pub fn suggestions(&self) -> &[AnchorSuggestion] {
222        &self.suggestions
223    }
224}
225
226/// Evidence summary for one anchor-placement matching run.
227#[derive(Debug, Clone, PartialEq)]
228pub struct AnchorMatchReport {
229    candidate_count: usize,
230    comparisons: u64,
231    links: Vec<AnchoredLink>,
232}
233
234impl AnchorMatchReport {
235    /// Number of input text candidates.
236    #[must_use]
237    pub const fn candidate_count(&self) -> usize {
238        self.candidate_count
239    }
240
241    /// Exact fragment-to-target comparisons performed.
242    #[must_use]
243    pub const fn comparisons(&self) -> u64 {
244        self.comparisons
245    }
246
247    /// Directed links, including links with no qualifying placement.
248    #[must_use]
249    pub fn links(&self) -> &[AnchoredLink] {
250        &self.links
251    }
252
253    /// Number of links with at least one qualifying source placement.
254    #[must_use]
255    pub fn matched_link_count(&self) -> usize {
256        self.links
257            .iter()
258            .filter(|link| !link.suggestions.is_empty())
259            .count()
260    }
261}
262
263/// Exact semantic matcher for source text placement and anchor review.
264#[derive(Debug, Clone, PartialEq)]
265pub struct AnchorMatcher {
266    config: AnchorConfig,
267}