weavatrix_semantic/anchor/
mod.rs1mod matcher;
2
3use crate::{Result, SemanticError, SemanticVector};
4use matcher::validate_candidate_text;
5use weavatrix_graph::NodeId;
6
7#[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 #[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 #[must_use]
32 pub fn model(&self) -> &str {
33 &self.model
34 }
35
36 #[must_use]
38 pub const fn min_similarity(&self) -> f64 {
39 self.min_similarity
40 }
41
42 #[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#[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 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 #[must_use]
133 pub const fn source(&self) -> &NodeId {
134 &self.source
135 }
136
137 #[must_use]
139 pub fn locator(&self) -> &str {
140 &self.locator
141 }
142
143 #[must_use]
145 pub fn anchor_text(&self) -> &str {
146 &self.anchor_text
147 }
148
149 #[must_use]
151 pub fn context(&self) -> &str {
152 &self.context
153 }
154}
155
156#[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 #[must_use]
168 pub fn locator(&self) -> &str {
169 &self.locator
170 }
171
172 #[must_use]
174 pub fn anchor_text(&self) -> &str {
175 &self.anchor_text
176 }
177
178 #[must_use]
180 pub fn context(&self) -> &str {
181 &self.context
182 }
183
184 #[must_use]
186 pub const fn similarity(&self) -> f64 {
187 self.similarity
188 }
189}
190
191#[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 #[must_use]
203 pub const fn source(&self) -> &NodeId {
204 &self.source
205 }
206
207 #[must_use]
209 pub const fn target(&self) -> &NodeId {
210 &self.target
211 }
212
213 #[must_use]
215 pub const fn link_similarity(&self) -> f64 {
216 self.link_similarity
217 }
218
219 #[must_use]
221 pub fn suggestions(&self) -> &[AnchorSuggestion] {
222 &self.suggestions
223 }
224}
225
226#[derive(Debug, Clone, PartialEq)]
228pub struct AnchorMatchReport {
229 candidate_count: usize,
230 comparisons: u64,
231 links: Vec<AnchoredLink>,
232}
233
234impl AnchorMatchReport {
235 #[must_use]
237 pub const fn candidate_count(&self) -> usize {
238 self.candidate_count
239 }
240
241 #[must_use]
243 pub const fn comparisons(&self) -> u64 {
244 self.comparisons
245 }
246
247 #[must_use]
249 pub fn links(&self) -> &[AnchoredLink] {
250 &self.links
251 }
252
253 #[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#[derive(Debug, Clone, PartialEq)]
265pub struct AnchorMatcher {
266 config: AnchorConfig,
267}