Skip to main content

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)]
85pub struct ContextKey<V: SensorVocabulary<N>, const N: usize> {
86    /// The sensor vocabulary snapshot for this context.
87    pub vocabulary: V,
88}
89
90impl<V: SensorVocabulary<N>, const N: usize> ContextKey<V, N> {
91    /// Create a new context key from a sensor vocabulary snapshot.
92    pub fn new(vocabulary: V) -> Self {
93        Self { vocabulary }
94    }
95
96    /// Deterministic FNV-1a hash of the feature vector.
97    ///
98    /// Used to key context entries in fixed-size arrays (no_std compatible).
99    /// Deterministic: same vocabulary produces the same hash across restarts.
100    pub fn context_hash_u32(&self) -> u32 {
101        let vec = self.vocabulary.to_feature_vec();
102        let mut h: u32 = 2_166_136_261;
103        for &f in vec.iter() {
104            // Quantise to u16 for stable hashing of float feature vectors.
105            let bits: u16 = (f.clamp(0.0, 1.0) * 65535.0) as u16;
106            h ^= bits as u32;
107            h = h.wrapping_mul(16_777_619);
108        }
109        h
110    }
111
112    /// Cosine similarity between two context keys via their feature vectors.
113    ///
114    /// Returns a value in [0.0, 1.0] (assumes non-negative feature vectors).
115    /// Used as the raw edge weight in the World Shape graph (Graph A).
116    pub fn cosine_similarity(&self, other: &Self) -> f32 {
117        let a = self.vocabulary.to_feature_vec();
118        let b = other.vocabulary.to_feature_vec();
119
120        let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
121        let sq_a: f32 = a.iter().map(|x| x * x).sum();
122        let sq_b: f32 = b.iter().map(|x| x * x).sum();
123        let norm_a: f32 = sqrt_nr(sq_a);
124        let norm_b: f32 = sqrt_nr(sq_b);
125
126        let epsilon: f32 = 1e-9;
127        let tiny_a: bool = norm_a < epsilon;
128        let tiny_b: bool = norm_b < epsilon;
129        if tiny_a || tiny_b {
130            0.0
131        } else {
132            let raw: f32 = dot / (norm_a * norm_b);
133            raw.clamp(0.0, 1.0)
134        }
135    }
136}
137
138/// mBot2 reference vocabulary — 6-dimensional sensor context.
139///
140/// Brightness, noise level, presence signature, motion context,
141/// orientation, and time period.
142///
143/// This is the concrete vocabulary used by the mBot2 demo robot.
144/// Any platform with a `SensorVocabulary` implementation can use
145/// the same `ContextKey<V, N>` and the full CCF stack.
146#[derive(Clone, Debug, PartialEq, Eq, Hash)]
147pub struct MbotSensors {
148    /// Ambient light level.
149    pub brightness: BrightnessBand,
150    /// Ambient sound level.
151    pub noise: NoiseBand,
152    /// Nearby presence signature.
153    pub presence: PresenceSignature,
154    /// Robot motion context.
155    pub motion: MotionContext,
156    /// Robot orientation relative to starting heading.
157    pub orientation: Orientation,
158    /// Time of day period.
159    pub time_period: TimePeriod,
160}
161
162/// Ambient light level.
163#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
164pub enum BrightnessBand {
165    /// Very low ambient light.
166    Dark,
167    /// Moderate ambient light.
168    Dim,
169    /// High ambient light.
170    Bright,
171}
172
173/// Ambient sound level.
174#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
175pub enum NoiseBand {
176    /// Very low ambient noise.
177    Quiet,
178    /// Moderate ambient noise.
179    Moderate,
180    /// High ambient noise.
181    Loud,
182}
183
184/// Nearby presence signature (person detection).
185#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
186pub enum PresenceSignature {
187    /// No person detected nearby.
188    Absent,
189    /// Person detected in close proximity.
190    Close,
191    /// Person detected at distance.
192    Far,
193}
194
195/// Robot motion context.
196#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
197pub enum MotionContext {
198    /// Robot is stationary.
199    Static,
200    /// Robot is moving slowly.
201    Slow,
202    /// Robot is moving quickly.
203    Fast,
204}
205
206/// Robot orientation relative to starting heading.
207#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
208pub enum Orientation {
209    /// Robot is upright (not tilted beyond threshold).
210    Upright,
211    /// Robot is tilted beyond the upright threshold.
212    Tilted,
213}
214
215/// Time of day period.
216#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
217pub enum TimePeriod {
218    /// Daytime hours.
219    Day,
220    /// Evening hours.
221    Evening,
222    /// Night-time hours.
223    Night,
224}
225
226impl SensorVocabulary<6> for MbotSensors {
227    fn to_feature_vec(&self) -> [f32; 6] {
228        let b = match self.brightness {
229            BrightnessBand::Dark => 0.0,
230            BrightnessBand::Dim => 0.5,
231            BrightnessBand::Bright => 1.0,
232        };
233        let n = match self.noise {
234            NoiseBand::Quiet => 0.0,
235            NoiseBand::Moderate => 0.5,
236            NoiseBand::Loud => 1.0,
237        };
238        let p = match self.presence {
239            PresenceSignature::Absent => 0.0,
240            PresenceSignature::Far => 0.5,
241            PresenceSignature::Close => 1.0,
242        };
243        let m = match self.motion {
244            MotionContext::Static => 0.0,
245            MotionContext::Slow => 0.5,
246            MotionContext::Fast => 1.0,
247        };
248        let o = match self.orientation {
249            Orientation::Upright => 0.0,
250            Orientation::Tilted => 1.0,
251        };
252        let t = match self.time_period {
253            TimePeriod::Day => 0.0,
254            TimePeriod::Evening => 0.5,
255            TimePeriod::Night => 1.0,
256        };
257        [b, n, p, m, o, t]
258    }
259}
260
261/// Type alias for the canonical mBot2 context key.
262pub type MbotContextKey = ContextKey<MbotSensors, 6>;
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn bright_quiet() -> MbotContextKey {
269        ContextKey::new(MbotSensors {
270            brightness: BrightnessBand::Bright,
271            noise: NoiseBand::Quiet,
272            presence: PresenceSignature::Absent,
273            motion: MotionContext::Static,
274            orientation: Orientation::Upright,
275            time_period: TimePeriod::Day,
276        })
277    }
278
279    fn dark_loud() -> MbotContextKey {
280        ContextKey::new(MbotSensors {
281            brightness: BrightnessBand::Dark,
282            noise: NoiseBand::Loud,
283            presence: PresenceSignature::Close,
284            motion: MotionContext::Fast,
285            orientation: Orientation::Tilted,
286            time_period: TimePeriod::Night,
287        })
288    }
289
290    #[test]
291    fn test_claim_1_context_key_is_deterministic() {
292        // Patent Claim 1: discrete context identifier from quantised sensor signals
293        let k1 = bright_quiet();
294        let k2 = bright_quiet();
295        assert_eq!(k1.context_hash_u32(), k2.context_hash_u32());
296    }
297
298    #[test]
299    fn test_claim_1_different_contexts_have_different_hashes() {
300        let k1 = bright_quiet();
301        let k2 = dark_loud();
302        assert_ne!(k1.context_hash_u32(), k2.context_hash_u32());
303    }
304
305    #[test]
306    fn test_claim_8_composite_sensor_context_key() {
307        // Patent Claim 8: composite sensor vocabulary trait
308        let k = bright_quiet();
309        let vec = k.vocabulary.to_feature_vec();
310        assert_eq!(vec.len(), MbotSensors::FEATURE_DIM);
311        // Bright = 1.0, Quiet = 0.0
312        assert!((vec[0] - 1.0_f32).abs() < 1e-6);
313        assert!((vec[1] - 0.0_f32).abs() < 1e-6);
314    }
315
316    #[test]
317    fn test_cosine_similarity_identical_contexts() {
318        let k = bright_quiet();
319        assert!((k.cosine_similarity(&k) - 1.0_f32).abs() < 1e-5);
320    }
321
322    #[test]
323    fn test_cosine_similarity_dissimilar_contexts() {
324        let k1 = bright_quiet();
325        let k2 = dark_loud();
326        let sim = k1.cosine_similarity(&k2);
327        // Bright+Quiet vs Dark+Loud should be low similarity
328        assert!(sim < 0.5_f32, "sim={}", sim);
329    }
330
331    #[test]
332    fn test_custom_vocabulary_works_without_modifying_ccf_core() {
333        // Acceptance criterion: custom vocabulary compiles without modifying ccf-core
334        #[derive(Clone, Debug, PartialEq, Eq, Hash)]
335        struct TwoSensor { a: u8, b: u8 }
336        impl SensorVocabulary<2> for TwoSensor {
337            fn to_feature_vec(&self) -> [f32; 2] {
338                [self.a as f32 / 255.0, self.b as f32 / 255.0]
339            }
340        }
341        let k = ContextKey::new(TwoSensor { a: 100, b: 200 });
342        let _hash = k.context_hash_u32(); // just needs to compile and not panic
343        let sim = k.cosine_similarity(&k);
344        assert!((sim - 1.0_f32).abs() < 1e-5, "self-similarity={}", sim);
345    }
346
347    #[test]
348    fn test_sqrt_nr_accuracy() {
349        // Verify our no_std sqrt helper is accurate enough for cosine similarity
350        let cases: &[(f32, f32)] = &[
351            (0.0, 0.0),
352            (1.0, 1.0),
353            (0.25, 0.5),
354            (0.5, 0.7071068),
355            (4.0, 2.0),
356        ];
357        for &(input, expected) in cases {
358            let got = sqrt_nr(input);
359            assert!(
360                (got - expected).abs() < 1e-5,
361                "sqrt_nr({}) = {}, expected {}",
362                input,
363                got,
364                expected
365            );
366        }
367    }
368}