Skip to main content

weavatrix_semantic/anchor/
matcher.rs

1use 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    /// Validates configuration and creates an anchor matcher.
10    ///
11    /// # Errors
12    ///
13    /// Returns an error for an invalid model, threshold, or suggestion count.
14    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    /// Matches caller-extracted source text to directed semantic link targets.
25    ///
26    /// # Errors
27    ///
28    /// Returns duplicate, missing-vector, dimension, model, or numeric errors.
29    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}