ccf_core/vocabulary.rs
1//! Generic sensor vocabulary — the platform-independent context key system.
2//!
3//! Patent Claims 1 and 8: composite sensor context key as the fundamental unit
4//! of situational awareness.
5//!
6//! # Implementing for a new platform
7//!
8//! ```rust,ignore
9//! use ccf_core::vocabulary::{SensorVocabulary, ContextKey};
10//!
11//! #[derive(Clone, Debug, PartialEq, Eq, Hash)]
12//! pub struct ThreeSensorBot {
13//! pub light: u8, // 0=dark, 1=dim, 2=bright
14//! pub sound: u8, // 0=quiet, 1=loud
15//! pub motion: u8, // 0=still, 1=moving
16//! }
17//!
18//! impl SensorVocabulary<3> for ThreeSensorBot {
19//! fn to_feature_vec(&self) -> [f32; 3] {
20//! [self.light as f32 / 2.0, self.sound as f32, self.motion as f32]
21//! }
22//! }
23//! // Now ContextKey::<ThreeSensorBot, 3> works with the full CCF stack.
24//! ```
25//!
26//! # Invariants
27//! - **I-DIST-001** — no_std compatible; no heap allocation required
28//! - **I-DIST-002** — zero platform-specific bounds on the trait
29//! - **I-DIST-005** — zero unsafe code
30
31use core::hash::Hash;
32
33// ---------------------------------------------------------------------------
34// no_std sqrt via Newton-Raphson (8 iterations, accurate to ~1e-7 for [0, 1])
35// ---------------------------------------------------------------------------
36
37/// Compute the square root of a non-negative f32 using Newton-Raphson iteration.
38/// This is `no_std` compatible and avoids any platform intrinsics.
39fn sqrt_nr(x: f32) -> f32 {
40 if x <= 0.0 {
41 return 0.0;
42 }
43 // Initial guess using integer bit manipulation (fast inverse sqrt seed)
44 let bits = x.to_bits();
45 let guess_bits = 0x1fbd_1df5u32.wrapping_add(bits >> 1);
46 let mut s = f32::from_bits(guess_bits);
47 // Eight Newton-Raphson iterations: s = (s + x/s) / 2
48 for _ in 0..8 {
49 s = 0.5 * (s + x / s);
50 }
51 s
52}
53
54/// Platform-independent sensor vocabulary trait.
55///
56/// Implementors define the discrete sensory space the robot operates in.
57/// CCF is generic over this trait — the same trust accumulation logic
58/// works for any hardware as long as it can produce a discrete, hashable
59/// context key and a float feature vector.
60///
61/// The const generic `N` is the dimensionality of the feature vector.
62/// It must match `FEATURE_DIM` on the implementing type for the full CCF stack.
63///
64/// Patent Claims 1 and 8.
65pub trait SensorVocabulary<const N: usize>: Eq + Hash + Clone + core::fmt::Debug {
66 /// Dimensionality of the feature vector encoding (equal to the const generic `N`).
67 /// Provided as an associated constant for ergonomic access at the type level.
68 const FEATURE_DIM: usize = N;
69
70 /// Encode this vocabulary instance as a normalised float feature vector.
71 ///
72 /// Each element should be in [0.0, 1.0] for cosine similarity to be meaningful.
73 /// The order of dimensions must be consistent across calls.
74 fn to_feature_vec(&self) -> [f32; N];
75}
76
77/// Composite context key — generic over sensor vocabulary.
78///
79/// Wraps any `SensorVocabulary` implementation and adds:
80/// - Deterministic `context_hash_u32()` for HashMap keying
81/// - `cosine_similarity()` for graph edge weights
82///
83/// Patent Claims 1 and 8.
84#[derive(Clone, Debug, PartialEq, Eq, Hash)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct ContextKey<V: SensorVocabulary<N>, const N: usize> {
87 /// The sensor vocabulary snapshot for this context.
88 pub vocabulary: V,
89}
90
91impl<V: SensorVocabulary<N>, const N: usize> ContextKey<V, N> {
92 /// Create a new context key from a sensor vocabulary snapshot.
93 pub fn new(vocabulary: V) -> Self {
94 Self { vocabulary }
95 }
96
97 /// Deterministic FNV-1a hash of the feature vector.
98 ///
99 /// Used to key context entries in fixed-size arrays (no_std compatible).
100 /// Deterministic: same vocabulary produces the same hash across restarts.
101 pub fn context_hash_u32(&self) -> u32 {
102 let vec = self.vocabulary.to_feature_vec();
103 let mut h: u32 = 2_166_136_261;
104 for &f in vec.iter() {
105 // Quantise to u16 for stable hashing of float feature vectors.
106 let bits: u16 = (f.clamp(0.0, 1.0) * 65535.0) as u16;
107 h ^= bits as u32;
108 h = h.wrapping_mul(16_777_619);
109 }
110 h
111 }
112
113 /// Cosine similarity between two context keys via their feature vectors.
114 ///
115 /// Returns a value in [0.0, 1.0] (assumes non-negative feature vectors).
116 /// Used as the raw edge weight in the World Shape graph (Graph A).
117 pub fn cosine_similarity(&self, other: &Self) -> f32 {
118 let a = self.vocabulary.to_feature_vec();
119 let b = other.vocabulary.to_feature_vec();
120
121 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
122 let sq_a: f32 = a.iter().map(|x| x * x).sum();
123 let sq_b: f32 = b.iter().map(|x| x * x).sum();
124 let norm_a: f32 = sqrt_nr(sq_a);
125 let norm_b: f32 = sqrt_nr(sq_b);
126
127 let epsilon: f32 = 1e-9;
128 let tiny_a: bool = norm_a < epsilon;
129 let tiny_b: bool = norm_b < epsilon;
130 if tiny_a || tiny_b {
131 0.0
132 } else {
133 let raw: f32 = dot / (norm_a * norm_b);
134 raw.clamp(0.0, 1.0)
135 }
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 // Simple two-dimensional test vocabulary used throughout the unit tests.
144 // For a production 6-dimensional vocabulary see `ccf_core::mbot::MbotSensors`.
145 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
146 struct TwoSensor { light: u8, noise: u8 } // 0=low, 1=mid, 2=high
147
148 impl SensorVocabulary<2> for TwoSensor {
149 fn to_feature_vec(&self) -> [f32; 2] {
150 [self.light as f32 / 2.0, self.noise as f32 / 2.0]
151 }
152 }
153
154 fn bright_quiet() -> ContextKey<TwoSensor, 2> {
155 ContextKey::new(TwoSensor { light: 2, noise: 0 })
156 }
157
158 fn dark_loud() -> ContextKey<TwoSensor, 2> {
159 ContextKey::new(TwoSensor { light: 0, noise: 2 })
160 }
161
162 #[test]
163 fn test_claim_1_context_key_is_deterministic() {
164 // Patent Claim 1: discrete context identifier from quantised sensor signals
165 let k1 = bright_quiet();
166 let k2 = bright_quiet();
167 assert_eq!(k1.context_hash_u32(), k2.context_hash_u32());
168 }
169
170 #[test]
171 fn test_claim_1_different_contexts_have_different_hashes() {
172 let k1 = bright_quiet();
173 let k2 = dark_loud();
174 assert_ne!(k1.context_hash_u32(), k2.context_hash_u32());
175 }
176
177 #[test]
178 fn test_claim_8_composite_sensor_context_key() {
179 // Patent Claim 8: composite sensor vocabulary trait
180 let k = bright_quiet();
181 let vec = k.vocabulary.to_feature_vec();
182 assert_eq!(vec.len(), TwoSensor::FEATURE_DIM);
183 // light=2 → 1.0, noise=0 → 0.0
184 assert!((vec[0] - 1.0_f32).abs() < 1e-6);
185 assert!((vec[1] - 0.0_f32).abs() < 1e-6);
186 }
187
188 #[test]
189 fn test_cosine_similarity_identical_contexts() {
190 let k = bright_quiet();
191 assert!((k.cosine_similarity(&k) - 1.0_f32).abs() < 1e-5);
192 }
193
194 #[test]
195 fn test_cosine_similarity_dissimilar_contexts() {
196 let k1 = bright_quiet();
197 let k2 = dark_loud();
198 let sim = k1.cosine_similarity(&k2);
199 // Bright+Quiet vs Dark+Loud should be low similarity
200 assert!(sim < 0.5_f32, "sim={}", sim);
201 }
202
203 #[test]
204 fn test_custom_vocabulary_works_without_modifying_ccf_core() {
205 // Acceptance criterion: custom vocabulary compiles without modifying ccf-core
206 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
207 struct TwoSensor { a: u8, b: u8 }
208 impl SensorVocabulary<2> for TwoSensor {
209 fn to_feature_vec(&self) -> [f32; 2] {
210 [self.a as f32 / 255.0, self.b as f32 / 255.0]
211 }
212 }
213 let k = ContextKey::new(TwoSensor { a: 100, b: 200 });
214 let _hash = k.context_hash_u32(); // just needs to compile and not panic
215 let sim = k.cosine_similarity(&k);
216 assert!((sim - 1.0_f32).abs() < 1e-5, "self-similarity={}", sim);
217 }
218
219 #[test]
220 fn test_sqrt_nr_accuracy() {
221 // Verify our no_std sqrt helper is accurate enough for cosine similarity
222 let cases: &[(f32, f32)] = &[
223 (0.0, 0.0),
224 (1.0, 1.0),
225 (0.25, 0.5),
226 (0.5, 0.7071068),
227 (4.0, 2.0),
228 ];
229 for &(input, expected) in cases {
230 let got = sqrt_nr(input);
231 assert!(
232 (got - expected).abs() < 1e-5,
233 "sqrt_nr({}) = {}, expected {}",
234 input,
235 got,
236 expected
237 );
238 }
239 }
240}