Skip to main content

wm_memory/
semantic.rs

1//! Semantic coordinate encoding — Tantivy TF-IDF bridge.
2//!
3//! Replaces the SHA-256 hash-based `Coordinate5D::encode()` with semantically
4//! meaningful coordinates derived from anchor-based term frequency analysis.
5//!
6//! Three semantic axes (ported from v2's anchor embedding + PCA concept):
7//! - **x**: Logic ↔ Emotion
8//! - **y**: Micro ↔ Macro
9//! - **z**: Time ↔ Space
10//!
11//! For each axis, two poles of anchor terms are defined. The encoder tokenizes
12//! the input text using Tantivy's `SimpleTokenizer` + `LowerCaser` (consistent
13//! with the search index), computes term frequencies, and projects to [0, 1]
14//! per axis using a smoothed ratio of pole scores.
15
16use ahash::AHashMap;
17use tantivy::tokenizer::{LowerCaser, SimpleTokenizer, TextAnalyzer, TokenStream};
18
19use wm_core::Coordinate5D;
20
21/// Semantic scores for the three content-derived axes.
22#[derive(Debug, Clone, PartialEq)]
23pub struct SemanticScores {
24    /// Logic (0.0) ↔ Emotion (1.0)
25    pub x: f32,
26    /// Micro (0.0) ↔ Macro (1.0)
27    pub y: f32,
28    /// Time (0.0) ↔ Space (1.0)
29    pub z: f32,
30}
31
32impl SemanticScores {
33    /// Neutral scores (all axes at 0.5 — no semantic signal).
34    #[must_use]
35    pub const fn neutral() -> Self {
36        Self {
37            x: 0.5,
38            y: 0.5,
39            z: 0.5,
40        }
41    }
42}
43
44/// Anchor term sets for the three semantic axes.
45#[derive(Debug, Clone)]
46struct SemanticAnchors {
47    logic: &'static [&'static str],
48    emotion: &'static [&'static str],
49    micro: &'static [&'static str],
50    macro_: &'static [&'static str],
51    time: &'static [&'static str],
52    space: &'static [&'static str],
53}
54
55impl Default for SemanticAnchors {
56    fn default() -> Self {
57        Self {
58            logic: &[
59                "algorithm",
60                "code",
61                "data",
62                "function",
63                "method",
64                "system",
65                "process",
66                "structure",
67                "analysis",
68                "compute",
69                "parameter",
70                "model",
71                "formula",
72                "theorem",
73                "proof",
74                "derive",
75                "calculate",
76                "measure",
77                "metric",
78                "logic",
79                "rational",
80                "objective",
81                "systematic",
82                "technical",
83                "engineering",
84            ],
85            emotion: &[
86                "feel",
87                "feeling",
88                "emotion",
89                "love",
90                "fear",
91                "joy",
92                "sad",
93                "happy",
94                "angry",
95                "hope",
96                "care",
97                "beauty",
98                "art",
99                "soul",
100                "heart",
101                "passion",
102                "dream",
103                "wonder",
104                "intuition",
105                "empathy",
106                "spirit",
107                "subjective",
108                "personal",
109                "emotional",
110                "expressive",
111            ],
112            micro: &[
113                "detail",
114                "specific",
115                "small",
116                "local",
117                "individual",
118                "element",
119                "atom",
120                "bit",
121                "byte",
122                "cell",
123                "node",
124                "token",
125                "word",
126                "line",
127                "step",
128                "tiny",
129                "precise",
130                "exact",
131                "narrow",
132                "component",
133                "unit",
134                "instance",
135            ],
136            macro_: &[
137                "global",
138                "universe",
139                "network",
140                "architecture",
141                "framework",
142                "theory",
143                "paradigm",
144                "concept",
145                "abstract",
146                "broad",
147                "general",
148                "whole",
149                "total",
150                "infinite",
151                "cosmic",
152                "universal",
153                "grand",
154                "scale",
155                "overview",
156                "ecosystem",
157                "pattern",
158                "horizon",
159            ],
160            time: &[
161                "time",
162                "when",
163                "before",
164                "after",
165                "now",
166                "then",
167                "past",
168                "future",
169                "present",
170                "moment",
171                "duration",
172                "temporal",
173                "chronological",
174                "history",
175                "timeline",
176                "schedule",
177                "deadline",
178                "period",
179                "phase",
180                "cycle",
181                "event",
182                "sequence",
183            ],
184            space: &[
185                "space",
186                "where",
187                "here",
188                "there",
189                "location",
190                "position",
191                "area",
192                "region",
193                "zone",
194                "place",
195                "distance",
196                "spatial",
197                "coordinate",
198                "map",
199                "geometry",
200                "layout",
201                "boundary",
202                "field",
203                "domain",
204                "environment",
205                "context",
206            ],
207        }
208    }
209}
210
211/// Semantic encoder using Tantivy tokenization and anchor-based TF projection.
212///
213/// Tokenizes text with `SimpleTokenizer` + `LowerCaser` (same pipeline as the
214/// Tantivy search index), then computes term frequencies against anchor term
215/// sets for each semantic axis. Produces `SemanticScores` in [0, 1] per axis.
216pub struct SemanticEncoder {
217    anchors: SemanticAnchors,
218}
219
220impl Default for SemanticEncoder {
221    fn default() -> Self {
222        Self::new()
223    }
224}
225
226impl SemanticEncoder {
227    /// Create a new encoder with default anchor terms and Tantivy tokenization.
228    #[must_use]
229    pub fn new() -> Self {
230        Self {
231            anchors: SemanticAnchors::default(),
232        }
233    }
234
235    /// Encode text into semantic scores (x, y, z) in [0, 1].
236    ///
237    /// Each axis is computed as:
238    /// `axis = (pos_pole + smoothing) / (neg_pole + pos_pole + 2 * smoothing)`
239    ///
240    /// With smoothing = 0.5, neutral text (no anchor terms) returns 0.5.
241    #[must_use]
242    pub fn encode(&self, text: &str) -> SemanticScores {
243        let freqs = self.term_frequencies(text);
244        let x = self.axis_score(&freqs, self.anchors.logic, self.anchors.emotion);
245        let y = self.axis_score(&freqs, self.anchors.micro, self.anchors.macro_);
246        let z = self.axis_score(&freqs, self.anchors.time, self.anchors.space);
247        SemanticScores { x, y, z }
248    }
249
250    /// Encode text into a full `Coordinate5D` with temporal and importance context.
251    #[must_use]
252    pub fn encode_coordinate(
253        &self,
254        text: &str,
255        temporal_weight: f32,
256        importance: f32,
257    ) -> Coordinate5D {
258        let scores = self.encode(text);
259        Coordinate5D::from_semantic(scores.x, scores.y, scores.z, temporal_weight, importance)
260    }
261
262    /// Compute term frequencies from text using Tantivy tokenization.
263    fn term_frequencies(&self, text: &str) -> AHashMap<String, f32> {
264        let mut freqs: AHashMap<String, f32> = AHashMap::new();
265        let mut analyzer = TextAnalyzer::builder(SimpleTokenizer::default())
266            .filter(LowerCaser)
267            .build();
268        let mut stream = analyzer.token_stream(text);
269        while stream.advance() {
270            *freqs.entry(stream.token().text.clone()).or_insert(0.0) += 1.0;
271        }
272        freqs
273    }
274
275    /// Compute axis score from term frequencies and two anchor poles.
276    fn axis_score(
277        &self,
278        freqs: &AHashMap<String, f32>,
279        neg_pole: &[&str],
280        pos_pole: &[&str],
281    ) -> f32 {
282        let neg = self.pole_score(freqs, neg_pole);
283        let pos = self.pole_score(freqs, pos_pole);
284        let smoothing = 0.5;
285        (pos + smoothing) / 2.0f32.mul_add(smoothing, neg + pos)
286    }
287
288    /// Sum of sublinearly-scaled term frequencies for anchor terms.
289    fn pole_score(&self, freqs: &AHashMap<String, f32>, terms: &[&str]) -> f32 {
290        let mut score = 0.0f32;
291        for term in terms {
292            if let Some(&freq) = freqs.get(*term) {
293                // Sublinear scaling: 1 + ln(freq) to avoid dominance by repeated terms
294                score += 1.0 + freq.ln();
295            }
296        }
297        score
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn neutral_text_returns_midpoint() {
307        let encoder = SemanticEncoder::new();
308        let scores = encoder.encode("the quick brown fox jumps over the lazy dog");
309        // No anchor terms in this text — all axes should be near 0.5
310        assert!((scores.x - 0.5).abs() < 0.01);
311        assert!((scores.y - 0.5).abs() < 0.01);
312        assert!((scores.z - 0.5).abs() < 0.01);
313    }
314
315    #[test]
316    fn empty_text_returns_neutral() {
317        let encoder = SemanticEncoder::new();
318        let scores = encoder.encode("");
319        assert_eq!(scores, SemanticScores::neutral());
320    }
321
322    #[test]
323    fn logic_text_scores_toward_zero_x() {
324        let encoder = SemanticEncoder::new();
325        let scores = encoder.encode(
326            "The algorithm computes data using a systematic method with precise parameters",
327        );
328        // Logic-heavy text → x should be below 0.5
329        assert!(
330            scores.x < 0.5,
331            "x = {} should be < 0.5 for logic text",
332            scores.x
333        );
334    }
335
336    #[test]
337    fn emotion_text_scores_toward_one_x() {
338        let encoder = SemanticEncoder::new();
339        let scores = encoder
340            .encode("I feel love and joy in my heart, a deep passion and empathy for beauty");
341        // Emotion-heavy text → x should be above 0.5
342        assert!(
343            scores.x > 0.5,
344            "x = {} should be > 0.5 for emotion text",
345            scores.x
346        );
347    }
348
349    #[test]
350    fn micro_text_scores_toward_zero_y() {
351        let encoder = SemanticEncoder::new();
352        let scores = encoder
353            .encode("Each individual element and tiny detail of the specific component matters");
354        // Micro-heavy text → y should be below 0.5
355        assert!(
356            scores.y < 0.5,
357            "y = {} should be < 0.5 for micro text",
358            scores.y
359        );
360    }
361
362    #[test]
363    fn macro_text_scores_toward_one_y() {
364        let encoder = SemanticEncoder::new();
365        let scores =
366            encoder.encode("The global architecture is a universal framework on a cosmic scale");
367        // Macro-heavy text → y should be above 0.5
368        assert!(
369            scores.y > 0.5,
370            "y = {} should be > 0.5 for macro text",
371            scores.y
372        );
373    }
374
375    #[test]
376    fn time_text_scores_toward_zero_z() {
377        let encoder = SemanticEncoder::new();
378        let scores = encoder.encode(
379            "Before and after that moment, the timeline showed a chronological sequence of events",
380        );
381        // Time-heavy text → z should be below 0.5
382        assert!(
383            scores.z < 0.5,
384            "z = {} should be < 0.5 for time text",
385            scores.z
386        );
387    }
388
389    #[test]
390    fn space_text_scores_toward_one_z() {
391        let encoder = SemanticEncoder::new();
392        let scores = encoder.encode(
393            "The spatial layout of the region defines the boundary and geometry of the area",
394        );
395        // Space-heavy text → z should be above 0.5
396        assert!(
397            scores.z > 0.5,
398            "z = {} should be > 0.5 for space text",
399            scores.z
400        );
401    }
402
403    #[test]
404    fn encode_is_deterministic() {
405        let encoder = SemanticEncoder::new();
406        let a = encoder.encode("The algorithm processes data with logic and analysis");
407        let b = encoder.encode("The algorithm processes data with logic and analysis");
408        assert_eq!(a, b);
409    }
410
411    #[test]
412    fn similar_texts_produce_similar_coordinates() {
413        let encoder = SemanticEncoder::new();
414        let a = encoder.encode_coordinate(
415            "The algorithm computes data using a systematic method",
416            0.5,
417            0.5,
418        );
419        let b = encoder.encode_coordinate(
420            "The algorithm processes data using a systematic approach",
421            0.5,
422            0.5,
423        );
424        let c = encoder.encode_coordinate(
425            "I feel love and joy in my heart with deep passion",
426            0.5,
427            0.5,
428        );
429
430        let dist_ab = a.semantic_distance_to(&b);
431        let dist_ac = a.semantic_distance_to(&c);
432
433        // Similar texts should be closer than dissimilar texts
434        assert!(
435            dist_ab < dist_ac,
436            "dist(a,b)={dist_ab:.4} should be < dist(a,c)={dist_ac:.4}"
437        );
438    }
439
440    #[test]
441    fn encode_coordinate_produces_valid_range() {
442        let encoder = SemanticEncoder::new();
443        let coord = encoder.encode_coordinate("test content", 0.7, 0.9);
444        assert!(coord.x >= 0.0 && coord.x <= 1.0);
445        assert!(coord.y >= 0.0 && coord.y <= 1.0);
446        assert!(coord.z >= 0.0 && coord.z <= 1.0);
447        assert!((coord.w - 0.7).abs() < f32::EPSILON);
448        assert!((coord.v - 0.9).abs() < f32::EPSILON);
449    }
450
451    #[test]
452    fn mixed_content_produces_intermediate_scores() {
453        let encoder = SemanticEncoder::new();
454        let scores = encoder
455            .encode("The algorithm processes data with emotional passion and systematic beauty");
456        // Mixed logic + emotion → x should be somewhere in the middle
457        assert!(
458            (0.3..=0.7).contains(&scores.x),
459            "x = {} should be in [0.3, 0.7] for mixed text",
460            scores.x
461        );
462    }
463
464    #[test]
465    fn case_insensitive_matching() {
466        let encoder = SemanticEncoder::new();
467        let lower = encoder.encode("the algorithm computes data");
468        let upper = encoder.encode("The ALGORITHM COMPUTES DATA");
469        assert_eq!(lower, upper);
470    }
471
472    #[test]
473    fn semantic_scores_neutral() {
474        assert_eq!(
475            SemanticScores::neutral(),
476            SemanticScores {
477                x: 0.5,
478                y: 0.5,
479                z: 0.5
480            }
481        );
482    }
483}