1use crate::vocabulary::{ContextKey, SensorVocabulary};
29
30pub const MAX_CONTEXTS: usize = 64;
32
33const EDGE_THRESHOLD: f32 = 0.1;
35
36pub const MIN_TRUST_OBSERVATIONS: u32 = 50;
38
39const TRUST_SCALE: f32 = 2.0;
41
42#[derive(Clone, Debug)]
44pub struct MinCutResult {
45 pub min_cut_value: f32,
47 pub partition_s_count: usize,
49 pub partition_s: [u32; MAX_CONTEXTS],
51 pub partition_complement_count: usize,
53 pub partition_complement: [u32; MAX_CONTEXTS],
55}
56
57#[derive(Clone, Debug)]
59struct NodeData {
60 hash: u32,
62 coherence: f32,
64 observations: u32,
66}
67
68pub struct MinCutBoundary<V: SensorVocabulary<N>, const N: usize> {
72 nodes: [Option<NodeData>; MAX_CONTEXTS],
74 node_count: usize,
76 adj: [[f32; MAX_CONTEXTS]; MAX_CONTEXTS],
79 _vocab: core::marker::PhantomData<V>,
81}
82
83impl<V: SensorVocabulary<N>, const N: usize> MinCutBoundary<V, N> {
84 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 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 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 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 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 if let Some(ref mut node) = self.nodes[idx] {
153 node.coherence = coherence;
154 node.observations = observations;
155 }
156
157 let self_coh = coherence;
159 let self_obs = observations;
160
161 for other_idx in 0..self.node_count {
163 if other_idx == idx {
164 continue;
165 }
166
167 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 let weight = if self_obs >= MIN_TRUST_OBSERVATIONS
190 && other_obs >= MIN_TRUST_OBSERVATIONS
191 {
192 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 current_weight
199 };
200
201 self.adj[idx][other_idx] = weight;
202 self.adj[other_idx][idx] = weight;
203 }
204 }
205
206 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 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 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 pub fn node_count(&self) -> usize {
253 self.node_count
254 }
255
256 fn stoer_wagner(&self) -> MinCutResult {
263 let n = self.node_count;
264
265 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 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 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 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 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 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 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 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 for v in 0..n {
384 if active[v] && !in_a[v] {
385 key[v] += w[u][v];
386 }
387 }
388 }
389
390 (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
402fn 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 let y = 2.0 * x;
416 let e = exp_approx(y);
417 1.0 - 2.0 / (e + 1.0)
418}
419
420fn exp_approx(x: f32) -> f32 {
426 let x = x.clamp(-87.0, 88.0);
428 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 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 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#[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 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 let cut = b.min_cut_value();
493 assert!(cut >= 0.0, "min_cut_value must be non-negative");
495 }
496
497 #[test]
498 fn test_claim_10_partition_is_observable() {
499 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 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 let mut b: MinCutBoundary<MbotSensors, 6> = MinCutBoundary::new();
518 let k1 = bright_quiet();
519 let k2 = bright_loud(); let k3 = dark_quiet(); let k4 = dark_loud(); 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 }
543
544 #[test]
545 fn test_claim_12_boundary_moves_when_trust_changes() {
546 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 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 b.update_trust(&k2, 0.1, MIN_TRUST_OBSERVATIONS);
563 let cut_after_degradation = b.min_cut_value();
564
565 assert!(cut_before >= 0.0);
567 assert!(cut_after_trust >= 0.0);
568 assert!(cut_after_degradation >= 0.0);
569 }
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 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}