Skip to main content

ccf_core/
boundary.rs

1//! Comfort-zone boundary via global minimum cut on the trust-weighted context graph.
2//!
3//! Patent Claims 9–12: automatic discovery of the comfort-zone boundary as a
4//! computed structural property of the trust manifold, not a threshold parameter.
5//!
6//! # Two-graph architecture
7//!
8//! **Graph A — World Shape** (cosine similarity only, stable)
9//! Edge weight = `cosine_similarity(vocab_A, vocab_B)` ∈ [0.0, 1.0]
10//!
11//! **Graph B — Trust Shape** (patent-faithful, dynamic)
12//! Activates once both endpoints have ≥ `MIN_TRUST_OBSERVATIONS` interactions.
13//! Edge weight = `sim × tanh(coh_A × TRUST_SCALE) × tanh(coh_B × TRUST_SCALE)`
14//!
15//! # Algorithm
16//!
17//! Stoer-Wagner global minimum cut, O(V·E + V²·log V).
18//! Exact for any graph ≤ MAX_CONTEXTS (64) nodes.
19//!
20//! # Invariants
21//! - **I-BNDRY-001** — Min-cut on context-key graph, not episode graph
22//! - **I-BNDRY-002** — Edge weight ∈ [0.0, 1.0]
23//! - **I-BNDRY-003** — Edges inserted only when cosine similarity > EDGE_THRESHOLD (0.1)
24//! - **I-TRUST-001** — Trust component activates only after MIN_TRUST_OBSERVATIONS (50)
25//! - **I-DIST-001** — no_std compatible; uses hashbrown HashMap
26//! - **I-DIST-005** — Zero unsafe code
27
28use crate::vocabulary::{ContextKey, SensorVocabulary};
29
30/// Maximum number of contexts tracked in the boundary graph.
31pub const MAX_CONTEXTS: usize = 64;
32
33/// Minimum cosine similarity for an edge to be inserted (I-BNDRY-003).
34const EDGE_THRESHOLD: f32 = 0.1;
35
36/// Minimum positive interactions before the trust component activates (I-TRUST-001).
37pub const MIN_TRUST_OBSERVATIONS: u32 = 50;
38
39/// Trust scale factor in the Graph B edge weight formula.
40const TRUST_SCALE: f32 = 2.0;
41
42/// Result of a minimum cut computation.
43#[derive(Clone, Debug)]
44pub struct MinCutResult {
45    /// Weight of the minimum cut (thinnest bridge in the trust manifold).
46    pub min_cut_value: f32,
47    /// Number of entries in `partition_s`.
48    pub partition_s_count: usize,
49    /// Context hashes on the "safe" (high-trust) side.
50    pub partition_s: [u32; MAX_CONTEXTS],
51    /// Number of entries in `partition_complement`.
52    pub partition_complement_count: usize,
53    /// Context hashes on the "unfamiliar" side.
54    pub partition_complement: [u32; MAX_CONTEXTS],
55}
56
57/// Per-context node data stored in the boundary graph.
58#[derive(Clone, Debug)]
59struct NodeData {
60    /// FNV hash of the context key (used as stable node ID).
61    hash: u32,
62    /// Current coherence value [0.0, 1.0].
63    coherence: f32,
64    /// Positive interactions in this context.
65    observations: u32,
66}
67
68/// Comfort-zone boundary via global minimum cut on the trust-weighted context graph.
69///
70/// Patent Claims 9–12.
71pub struct MinCutBoundary<V: SensorVocabulary<N>, const N: usize> {
72    /// Node list (up to MAX_CONTEXTS).
73    nodes: [Option<NodeData>; MAX_CONTEXTS],
74    /// Number of active nodes.
75    node_count: usize,
76    /// Adjacency matrix: edge weights between node indices.
77    /// `adj[i][j]` is the Graph B (or Graph A fallback) weight between nodes i and j.
78    adj: [[f32; MAX_CONTEXTS]; MAX_CONTEXTS],
79    /// Phantom for the vocabulary type.
80    _vocab: core::marker::PhantomData<V>,
81}
82
83impl<V: SensorVocabulary<N>, const N: usize> MinCutBoundary<V, N> {
84    /// Create an empty boundary graph.
85    pub fn new() -> Self {
86        Self {
87            nodes: [
88                None, None, None, None, None, None, None, None,
89                None, None, None, None, None, None, None, None,
90                None, None, None, None, None, None, None, None,
91                None, None, None, None, None, None, None, None,
92                None, None, None, None, None, None, None, None,
93                None, None, None, None, None, None, None, None,
94                None, None, None, None, None, None, None, None,
95                None, None, None, None, None, None, None, None,
96            ],
97            node_count: 0,
98            adj: [[0.0; MAX_CONTEXTS]; MAX_CONTEXTS],
99            _vocab: core::marker::PhantomData,
100        }
101    }
102
103    /// Register a context key as a node, providing all existing keys for edge insertion.
104    ///
105    /// If the context is already known, this is O(1). If new, inserts edges to all
106    /// existing nodes with cosine similarity > EDGE_THRESHOLD (I-BNDRY-003).
107    pub fn report_context_with_key(
108        &mut self,
109        key: &ContextKey<V, N>,
110        all_keys: &[(ContextKey<V, N>, u32)],
111    ) {
112        let hash = key.context_hash_u32();
113
114        // Linear scan to check if already known (no HashMap needed for ≤64 nodes)
115        if self.find_idx(hash).is_some() {
116            return;
117        }
118
119        if self.node_count >= MAX_CONTEXTS {
120            return;
121        }
122
123        let new_idx = self.node_count;
124        self.nodes[new_idx] = Some(NodeData { hash, coherence: 0.0, observations: 0 });
125
126        // Insert Graph A edges to all existing nodes
127        for (other_key, other_hash) in all_keys {
128            if *other_hash == hash {
129                continue;
130            }
131            if let Some(other_idx) = self.find_idx(*other_hash) {
132                let sim = key.cosine_similarity(other_key);
133                if sim > EDGE_THRESHOLD {
134                    self.adj[new_idx][other_idx] = sim;
135                    self.adj[other_idx][new_idx] = sim;
136                }
137            }
138        }
139
140        self.node_count += 1;
141    }
142
143    /// Update trust-weighted edges for a context after a coherence change.
144    ///
145    /// Recomputes Graph B weights for all edges incident to this context.
146    /// If either endpoint has fewer than MIN_TRUST_OBSERVATIONS, keeps Graph A weight.
147    pub fn update_trust(&mut self, key: &ContextKey<V, N>, coherence: f32, observations: u32) {
148        let hash = key.context_hash_u32();
149        let Some(idx) = self.find_idx(hash) else { return; };
150
151        // Update this node's trust data
152        if let Some(ref mut node) = self.nodes[idx] {
153            node.coherence = coherence;
154            node.observations = observations;
155        }
156
157        // Snapshot coherence/obs for the current node before iterating
158        let self_coh = coherence;
159        let self_obs = observations;
160
161        // Reweight edges: for each neighbour with a Graph A edge, compute Graph B if eligible
162        for other_idx in 0..self.node_count {
163            if other_idx == idx {
164                continue;
165            }
166
167            // We need the baseline similarity (Graph A weight).
168            // adj currently holds whichever weight was last written.
169            // To detect whether an edge exists, check if weight > 0 OR check via the
170            // original Graph A weight. We store Graph A weight initially, so if the
171            // current weight is > 0 after update_trust was called, the edge exists.
172            // However on repeated calls we may have overwritten with Graph B.
173            // Safe approach: only reweight if either Graph A or current weight > EDGE_THRESHOLD.
174            let current_weight = self.adj[idx][other_idx];
175            if current_weight <= EDGE_THRESHOLD {
176                continue;
177            }
178
179            let other_coh;
180            let other_obs;
181            if let Some(ref other) = self.nodes[other_idx] {
182                other_coh = other.coherence;
183                other_obs = other.observations;
184            } else {
185                continue;
186            }
187
188            // Use Graph B if both endpoints have sufficient observations (I-TRUST-001)
189            let weight = if self_obs >= MIN_TRUST_OBSERVATIONS
190                && other_obs >= MIN_TRUST_OBSERVATIONS
191            {
192                // Graph B: trust-weighted
193                let t_self = boundary_tanh(self_coh * TRUST_SCALE);
194                let t_other = boundary_tanh(other_coh * TRUST_SCALE);
195                (current_weight * t_self * t_other).clamp(0.0, 1.0)
196            } else {
197                // Graph A: similarity only — leave unchanged
198                current_weight
199            };
200
201            self.adj[idx][other_idx] = weight;
202            self.adj[other_idx][idx] = weight;
203        }
204    }
205
206    /// Linear scan to find the node index for a given hash (O(n), n ≤ 64).
207    fn find_idx(&self, hash: u32) -> Option<usize> {
208        for i in 0..self.node_count {
209            if let Some(ref node) = self.nodes[i] {
210                if node.hash == hash {
211                    return Some(i);
212                }
213            }
214        }
215        None
216    }
217
218    /// Current minimum cut value of the trust manifold.
219    ///
220    /// Returns 0.0 if fewer than 2 nodes are registered.
221    /// Patent Claim 9: boundary is computed, not configured.
222    pub fn min_cut_value(&self) -> f32 {
223        if self.node_count < 2 {
224            return 0.0;
225        }
226        self.stoer_wagner().min_cut_value
227    }
228
229    /// Full minimum cut result: value and partition.
230    ///
231    /// Patent Claim 10: partition is observable.
232    pub fn partition(&self) -> MinCutResult {
233        if self.node_count < 2 {
234            let mut complement = [0u32; MAX_CONTEXTS];
235            for i in 0..self.node_count {
236                if let Some(ref n) = self.nodes[i] {
237                    complement[i] = n.hash;
238                }
239            }
240            return MinCutResult {
241                min_cut_value: 0.0,
242                partition_s_count: 0,
243                partition_s: [0; MAX_CONTEXTS],
244                partition_complement_count: self.node_count,
245                partition_complement: complement,
246            };
247        }
248        self.stoer_wagner()
249    }
250
251    /// Number of registered context nodes.
252    pub fn node_count(&self) -> usize {
253        self.node_count
254    }
255
256    // ─── Stoer-Wagner algorithm ──────────────────────────────────────────────
257
258    /// Stoer-Wagner global minimum cut.
259    ///
260    /// Returns the minimum cut value and the partition (S, V\S).
261    /// O(V·E + V²·log V), exact for all inputs.
262    fn stoer_wagner(&self) -> MinCutResult {
263        let n = self.node_count;
264
265        // Working copy of adjacency weights
266        let mut w = [[0.0_f32; MAX_CONTEXTS]; MAX_CONTEXTS];
267        for i in 0..n {
268            for j in 0..n {
269                w[i][j] = self.adj[i][j];
270            }
271        }
272
273        // Track which original nodes are merged into each super-node via bitmask.
274        // u64 supports up to 64 bits, matching MAX_CONTEXTS = 64.
275        let mut merged = [0u64; MAX_CONTEXTS];
276        for i in 0..n {
277            merged[i] = 1u64 << i;
278        }
279
280        let mut active = [false; MAX_CONTEXTS];
281        for i in 0..n {
282            active[i] = true;
283        }
284
285        let mut best_cut = f32::MAX;
286        let mut best_partition_mask: u64 = 0;
287
288        // Run n-1 phases
289        for _phase in 0..(n - 1) {
290            let (s, t, cut_val) = self.min_cut_phase(&w, &active, n);
291            if cut_val < best_cut {
292                best_cut = cut_val;
293                best_partition_mask = merged[t];
294            }
295            // Merge t into s
296            for i in 0..n {
297                if active[i] {
298                    w[s][i] += w[t][i];
299                    w[i][s] += w[i][t];
300                }
301            }
302            merged[s] |= merged[t];
303            active[t] = false;
304        }
305
306        // Build partition from best_partition_mask
307        let mut result = MinCutResult {
308            min_cut_value: if best_cut == f32::MAX { 0.0 } else { best_cut },
309            partition_s_count: 0,
310            partition_s: [0; MAX_CONTEXTS],
311            partition_complement_count: 0,
312            partition_complement: [0; MAX_CONTEXTS],
313        };
314
315        for i in 0..n {
316            if let Some(ref node) = self.nodes[i] {
317                if (best_partition_mask >> i) & 1 == 1 {
318                    result.partition_s[result.partition_s_count] = node.hash;
319                    result.partition_s_count += 1;
320                } else {
321                    result.partition_complement[result.partition_complement_count] = node.hash;
322                    result.partition_complement_count += 1;
323                }
324            }
325        }
326
327        result
328    }
329
330    /// One phase of Stoer-Wagner: find the s-t pair with maximum adjacency cut.
331    ///
332    /// Returns `(s_idx, t_idx, cut_value_of_t)`.
333    fn min_cut_phase(
334        &self,
335        w: &[[f32; MAX_CONTEXTS]; MAX_CONTEXTS],
336        active: &[bool; MAX_CONTEXTS],
337        n: usize,
338    ) -> (usize, usize, f32) {
339        let mut in_a = [false; MAX_CONTEXTS];
340        let mut key = [0.0_f32; MAX_CONTEXTS];
341
342        let mut prev = 0usize;
343        let mut last = 0usize;
344
345        // Find first active node to use as the starting point
346        let mut initialised = false;
347        for i in 0..n {
348            if active[i] {
349                prev = i;
350                last = i;
351                initialised = true;
352                break;
353            }
354        }
355        if !initialised {
356            return (0, 0, 0.0);
357        }
358
359        let active_count = (0..n).filter(|&i| active[i]).count();
360
361        for step in 0..active_count {
362            // Find active node not in A with maximum key
363            let u_opt = (0..n)
364                .filter(|&i| active[i] && !in_a[i])
365                .max_by(|&a, &b| {
366                    key[a]
367                        .partial_cmp(&key[b])
368                        .unwrap_or(core::cmp::Ordering::Equal)
369                });
370
371            let u = match u_opt {
372                Some(u) => u,
373                None => break,
374            };
375
376            if step > 0 {
377                prev = last;
378            }
379            last = u;
380            in_a[u] = true;
381
382            // Update keys of neighbours of u
383            for v in 0..n {
384                if active[v] && !in_a[v] {
385                    key[v] += w[u][v];
386                }
387            }
388        }
389
390        // s = prev (second-to-last added), t = last added
391        // cut value = key[last] = total weight of edges from last to rest of A
392        (prev, last, key[last])
393    }
394}
395
396impl<V: SensorVocabulary<N>, const N: usize> Default for MinCutBoundary<V, N> {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402/// Approximate tanh for no_std environments.
403///
404/// Uses `tanh(x) = 1 - 2/(exp(2x) + 1)` with a minimax polynomial for exp.
405/// Accurate to < 0.001 for |x| ≤ 4, which covers the full trust scale range.
406fn boundary_tanh(x: f32) -> f32 {
407    if x > 9.0 {
408        return 1.0;
409    }
410    if x < -9.0 {
411        return -1.0;
412    }
413    // exp(y) via minimax polynomial on [-0.5*ln2, 0.5*ln2] with range reduction.
414    // tanh(x) = 1 - 2/(exp(2x) + 1)
415    let y = 2.0 * x;
416    let e = exp_approx(y);
417    1.0 - 2.0 / (e + 1.0)
418}
419
420/// Minimax polynomial approximation to exp(x), no_std compatible.
421///
422/// Uses range reduction: exp(x) = exp(k*ln2) * exp(r) = 2^k * exp(r)
423/// where r = x - k*ln2, |r| ≤ 0.5*ln2.
424/// The polynomial for exp(r) is accurate to < 1e-6 for |r| ≤ 0.347.
425fn exp_approx(x: f32) -> f32 {
426    // Clamp to avoid overflow: exp(88) > f32::MAX
427    let x = x.clamp(-87.0, 88.0);
428    // Range reduction: x = k*ln2 + r, k = round(x / ln2)
429    const LN2: f32 = 0.693_147_18;
430    const INV_LN2: f32 = 1.442_695_04;
431    let k = (x * INV_LN2 + 0.5) as i32 - (if x < 0.0 { 1 } else { 0 });
432    let r = x - k as f32 * LN2;
433    // Polynomial: exp(r) ≈ 1 + r + r²/2 + r³/6 + r⁴/24 + r⁵/120
434    // Accurate to < 1e-7 for |r| ≤ 0.347 (half ln2)
435    let r2 = r * r;
436    let r4 = r2 * r2;
437    let poly = 1.0 + r + 0.5 * r2 + (1.0 / 6.0) * r * r2
438        + (1.0 / 24.0) * r4
439        + (1.0 / 120.0) * r * r4;
440    // Multiply by 2^k via bit manipulation on f32
441    // f32 exponent field is biased by 127; add k to it
442    let clamped_k = k.clamp(-126, 127);
443    let scale_bits: u32 = ((127 + clamped_k) as u32) << 23;
444    let scale = f32::from_bits(scale_bits);
445    poly * scale
446}
447
448// ─── Tests ────────────────────────────────────────────────────────────────
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::mbot::{
454        BrightnessBand, MbotSensors, MotionContext, NoiseBand, Orientation, PresenceSignature,
455        TimePeriod,
456    };
457
458    fn make_key(b: BrightnessBand, n: NoiseBand) -> ContextKey<MbotSensors, 6> {
459        ContextKey::new(MbotSensors {
460            brightness: b,
461            noise: n,
462            presence: PresenceSignature::Absent,
463            motion: MotionContext::Static,
464            orientation: Orientation::Upright,
465            time_period: TimePeriod::Day,
466        })
467    }
468
469    fn bright_quiet() -> ContextKey<MbotSensors, 6> {
470        make_key(BrightnessBand::Bright, NoiseBand::Quiet)
471    }
472    fn bright_loud() -> ContextKey<MbotSensors, 6> {
473        make_key(BrightnessBand::Bright, NoiseBand::Loud)
474    }
475    fn dark_quiet() -> ContextKey<MbotSensors, 6> {
476        make_key(BrightnessBand::Dark, NoiseBand::Quiet)
477    }
478    fn dark_loud() -> ContextKey<MbotSensors, 6> {
479        make_key(BrightnessBand::Dark, NoiseBand::Loud)
480    }
481
482    #[test]
483    fn test_claim_9_min_cut_is_computed_not_configured() {
484        // Patent Claim 9: boundary is a computed structural property, not a threshold
485        let mut b: MinCutBoundary<MbotSensors, 6> = MinCutBoundary::new();
486        let k1 = bright_quiet();
487        let k2 = dark_loud();
488        b.report_context_with_key(&k1, &[]);
489        let existing = [(k1.clone(), k1.context_hash_u32())];
490        b.report_context_with_key(&k2, &existing);
491        // No threshold was set — min_cut_value is emergent from graph topology
492        let cut = b.min_cut_value();
493        // Two dissimilar contexts should have a low but non-negative cut weight
494        assert!(cut >= 0.0, "min_cut_value must be non-negative");
495    }
496
497    #[test]
498    fn test_claim_10_partition_is_observable() {
499        // Patent Claim 10: the two sides of the boundary are enumerable
500        let mut b: MinCutBoundary<MbotSensors, 6> = MinCutBoundary::new();
501        let k1 = bright_quiet();
502        let k2 = dark_loud();
503        b.report_context_with_key(&k1, &[]);
504        let existing = [(k1.clone(), k1.context_hash_u32())];
505        b.report_context_with_key(&k2, &existing);
506        let result = b.partition();
507        // Both partitions together contain all nodes
508        assert_eq!(
509            result.partition_s_count + result.partition_complement_count,
510            2
511        );
512    }
513
514    #[test]
515    fn test_claim_11_thin_bridge_detected() {
516        // Patent Claim 11: boundary discovers thin bridges between context clusters
517        let mut b: MinCutBoundary<MbotSensors, 6> = MinCutBoundary::new();
518        let k1 = bright_quiet();
519        let k2 = bright_loud(); // similar to k1 (both bright)
520        let k3 = dark_quiet(); // similar to k4 (both dark)
521        let k4 = dark_loud(); // dissimilar to k1/k2
522
523        b.report_context_with_key(&k1, &[]);
524        let e1 = [(k1.clone(), k1.context_hash_u32())];
525        b.report_context_with_key(&k2, &e1);
526        let e2 = [
527            (k1.clone(), k1.context_hash_u32()),
528            (k2.clone(), k2.context_hash_u32()),
529        ];
530        b.report_context_with_key(&k3, &e2);
531        let e3 = [
532            (k1.clone(), k1.context_hash_u32()),
533            (k2.clone(), k2.context_hash_u32()),
534            (k3.clone(), k3.context_hash_u32()),
535        ];
536        b.report_context_with_key(&k4, &e3);
537
538        assert_eq!(b.node_count(), 4);
539        let cut = b.min_cut_value();
540        assert!(cut >= 0.0);
541        // The cut between {bright} and {dark} clusters should be low
542    }
543
544    #[test]
545    fn test_claim_12_boundary_moves_when_trust_changes() {
546        // Patent Claim 12: boundary is dynamic — it changes as trust is earned or lost
547        let mut b: MinCutBoundary<MbotSensors, 6> = MinCutBoundary::new();
548        let k1 = bright_quiet();
549        let k2 = bright_loud();
550        b.report_context_with_key(&k1, &[]);
551        let existing = [(k1.clone(), k1.context_hash_u32())];
552        b.report_context_with_key(&k2, &existing);
553
554        let cut_before = b.min_cut_value();
555
556        // Simulate trust being earned in both contexts (above MIN_TRUST_OBSERVATIONS)
557        b.update_trust(&k1, 0.8, MIN_TRUST_OBSERVATIONS);
558        b.update_trust(&k2, 0.8, MIN_TRUST_OBSERVATIONS);
559        let cut_after_trust = b.min_cut_value();
560
561        // Simulate trust degrading in k2
562        b.update_trust(&k2, 0.1, MIN_TRUST_OBSERVATIONS);
563        let cut_after_degradation = b.min_cut_value();
564
565        // All cuts are valid non-negative values
566        assert!(cut_before >= 0.0);
567        assert!(cut_after_trust >= 0.0);
568        assert!(cut_after_degradation >= 0.0);
569        // After trust earned, Graph B activates, weights change
570        // (exact values depend on tanh — just verify it ran without panic)
571    }
572
573    #[test]
574    fn test_empty_graph_returns_zero() {
575        let b: MinCutBoundary<MbotSensors, 6> = MinCutBoundary::new();
576        assert_eq!(b.min_cut_value(), 0.0);
577    }
578
579    #[test]
580    fn test_single_node_returns_zero() {
581        let mut b: MinCutBoundary<MbotSensors, 6> = MinCutBoundary::new();
582        b.report_context_with_key(&bright_quiet(), &[]);
583        assert_eq!(b.min_cut_value(), 0.0);
584    }
585
586    #[test]
587    fn test_tanh_values() {
588        // tanh(0) = 0, tanh(2) ≈ 0.964, tanh(-2) ≈ -0.964
589        assert!(
590            boundary_tanh(0.0).abs() < 0.01,
591            "tanh(0) = {}",
592            boundary_tanh(0.0)
593        );
594        assert!(
595            (boundary_tanh(2.0) - 0.964_f32).abs() < 0.01,
596            "tanh(2) = {}",
597            boundary_tanh(2.0)
598        );
599        assert!(
600            (boundary_tanh(-2.0) + 0.964_f32).abs() < 0.01,
601            "tanh(-2) = {}",
602            boundary_tanh(-2.0)
603        );
604    }
605}