weavatrix_semantic/anchor/
matcher.rs1use super::{
2 AnchorCandidate, AnchorConfig, AnchorMatchReport, AnchorMatcher, AnchorSuggestion, AnchoredLink,
3};
4use crate::{Result, SemanticError, SemanticLinkReport, SemanticVector};
5use std::collections::{BTreeMap, BTreeSet};
6use weavatrix_graph::{AttributeValue, NodeId};
7
8impl AnchorMatcher {
9 pub fn new(config: AnchorConfig) -> Result<Self> {
15 config.validate()?;
16 Ok(Self { config })
17 }
18
19 #[must_use]
20 pub const fn config(&self) -> &AnchorConfig {
21 &self.config
22 }
23
24 pub fn match_links(
30 &self,
31 report: &SemanticLinkReport,
32 page_vectors: &[SemanticVector],
33 candidates: &[AnchorCandidate],
34 ) -> Result<AnchorMatchReport> {
35 let mut indexed_vectors = BTreeMap::new();
36 for vector in page_vectors {
37 if indexed_vectors.insert(vector.node_id(), vector).is_some() {
38 return Err(SemanticError::DuplicateNode {
39 node: vector.node_id().to_string(),
40 });
41 }
42 }
43 let by_source = index_candidates(candidates)?;
44 let mut comparisons = 0_u64;
45 let mut links = Vec::with_capacity(report.edge_count());
46 for edge in report.edges() {
47 validate_edge_model(edge, self.config.model())?;
48 let target = indexed_vectors.get(&edge.target).ok_or_else(|| {
49 SemanticError::MissingAnchorTargetVector {
50 target: edge.target.to_string(),
51 }
52 })?;
53 let mut suggestions = Vec::new();
54 for candidate in by_source
55 .get(&edge.source)
56 .into_iter()
57 .flat_map(|values| values.iter())
58 {
59 validate_dimension(candidate, target)?;
60 comparisons = comparisons
61 .checked_add(1)
62 .ok_or(SemanticError::NumericOverflow)?;
63 let similarity = cosine(&candidate.vector, target);
64 if similarity >= self.config.min_similarity() {
65 suggestions.push(AnchorSuggestion {
66 locator: candidate.locator.clone(),
67 anchor_text: candidate.anchor_text.clone(),
68 context: candidate.context.clone(),
69 similarity,
70 });
71 }
72 }
73 suggestions.sort_unstable_by(compare_suggestions);
74 suggestions.truncate(self.config.max_suggestions_per_link());
75 links.push(AnchoredLink {
76 source: edge.source.clone(),
77 target: edge.target.clone(),
78 link_similarity: edge_similarity(edge)?,
79 suggestions,
80 });
81 }
82 Ok(AnchorMatchReport {
83 candidate_count: candidates.len(),
84 comparisons,
85 links,
86 })
87 }
88}
89
90fn index_candidates(
91 candidates: &[AnchorCandidate],
92) -> Result<BTreeMap<&NodeId, Vec<&AnchorCandidate>>> {
93 let mut by_source = BTreeMap::<&NodeId, Vec<&AnchorCandidate>>::new();
94 let mut seen = BTreeSet::new();
95 for candidate in candidates {
96 let identity = (candidate.source(), candidate.locator());
97 if !seen.insert(identity) {
98 return Err(SemanticError::DuplicateAnchorCandidate {
99 source: candidate.source.to_string(),
100 locator: candidate.locator.clone(),
101 });
102 }
103 by_source
104 .entry(candidate.source())
105 .or_default()
106 .push(candidate);
107 }
108 Ok(by_source)
109}
110
111fn validate_dimension(candidate: &AnchorCandidate, target: &SemanticVector) -> Result<()> {
112 if candidate.vector.dimension() != target.dimension() {
113 return Err(SemanticError::AnchorDimensionMismatch {
114 source: candidate.source.to_string(),
115 locator: candidate.locator.clone(),
116 expected: target.dimension(),
117 actual: candidate.vector.dimension(),
118 });
119 }
120 Ok(())
121}
122
123pub(super) fn validate_candidate_text(
124 source: &NodeId,
125 locator: &str,
126 field: &'static str,
127 value: &str,
128) -> Result<()> {
129 if value.trim() != value {
130 return Err(SemanticError::AnchorTextHasSurroundingWhitespace {
131 source: source.to_string(),
132 locator: locator.to_owned(),
133 field,
134 });
135 }
136 Ok(())
137}
138
139fn validate_edge_model(edge: &weavatrix_graph::Edge, expected: &str) -> Result<()> {
140 let Some(AttributeValue::String(actual)) = edge.attributes.get("model") else {
141 return Err(SemanticError::MissingSemanticEdgeAttribute { attribute: "model" });
142 };
143 if actual != expected {
144 return Err(SemanticError::AnchorModelMismatch {
145 expected: expected.to_owned(),
146 actual: actual.to_owned(),
147 });
148 }
149 Ok(())
150}
151
152fn edge_similarity(edge: &weavatrix_graph::Edge) -> Result<f64> {
153 match edge.attributes.get("similarity") {
154 Some(AttributeValue::Float(score)) => Ok(score.get()),
155 _ => Err(SemanticError::MissingSemanticEdgeAttribute {
156 attribute: "similarity",
157 }),
158 }
159}
160
161fn cosine(left: &SemanticVector, right: &SemanticVector) -> f64 {
162 let dot = left
163 .values()
164 .iter()
165 .zip(right.values())
166 .map(|(&left, &right)| f64::from(left) * f64::from(right))
167 .sum::<f64>();
168 (dot / (left.norm() * right.norm())).clamp(-1.0, 1.0)
169}
170
171fn compare_suggestions(left: &AnchorSuggestion, right: &AnchorSuggestion) -> std::cmp::Ordering {
172 right
173 .similarity
174 .total_cmp(&left.similarity)
175 .then_with(|| left.locator.cmp(&right.locator))
176 .then_with(|| left.anchor_text.cmp(&right.anchor_text))
177}