engram/graph/coactivation.rs
1//! Temporal Coactivation / Hebbian Learning (RML-1218).
2//!
3//! "Neurons that fire together wire together" — memories retrieved in the same
4//! session get stronger connections each time they co-occur, and weaken when
5//! left unused.
6//!
7//! # Overview
8//!
9//! [`CoactivationTracker`] maintains a `coactivation_edges` table in SQLite.
10//! Each edge tracks:
11//!
12//! - `strength` — a value in `[0, 1]` that grows via the Hebbian update rule:
13//! `strength ← min(1.0, strength + lr × (1 − strength))`
14//! - `coactivation_count` — raw co-occurrence counter
15//! - `last_coactivated` — RFC 3339 timestamp of the most recent co-activation
16//!
17//! Edges are directional but for most queries both directions are combined so
18//! that the graph is effectively undirected.
19
20use chrono::Utc;
21use rusqlite::{params, Connection};
22use serde::{Deserialize, Serialize};
23
24use crate::error::{EngramError, Result};
25
26// =============================================================================
27// DDL
28// =============================================================================
29
30/// SQL that creates the `coactivation_edges` table and its supporting indexes.
31///
32/// Safe to run on an existing database — all statements use `IF NOT EXISTS`.
33pub const CREATE_COACTIVATION_EDGES_TABLE: &str = r#"
34CREATE TABLE IF NOT EXISTS coactivation_edges (
35 from_id INTEGER NOT NULL,
36 to_id INTEGER NOT NULL,
37 strength REAL NOT NULL DEFAULT 0.0,
38 coactivation_count INTEGER NOT NULL DEFAULT 0,
39 last_coactivated TEXT NOT NULL,
40 PRIMARY KEY (from_id, to_id)
41);
42CREATE INDEX IF NOT EXISTS idx_coact_from ON coactivation_edges(from_id);
43CREATE INDEX IF NOT EXISTS idx_coact_to ON coactivation_edges(to_id);
44CREATE INDEX IF NOT EXISTS idx_coact_str ON coactivation_edges(strength DESC);
45"#;
46
47// =============================================================================
48// Configuration
49// =============================================================================
50
51/// Tuning parameters for the Hebbian learning model.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct CoactivationConfig {
54 /// Hebbian learning rate — controls how quickly strength grows (0 < lr ≤ 1).
55 ///
56 /// Default: `0.1`
57 pub learning_rate: f64,
58
59 /// Decay multiplier applied to edges unused for `min_age_days` days.
60 ///
61 /// Each invocation of [`CoactivationTracker::weaken_unused`] multiplies
62 /// the strength of qualifying edges by `(1 − decay_rate)`.
63 ///
64 /// Default: `0.01`
65 pub decay_rate: f64,
66
67 /// Edges whose strength drops below this threshold are deleted.
68 ///
69 /// Default: `0.01`
70 pub min_strength: f64,
71}
72
73impl Default for CoactivationConfig {
74 fn default() -> Self {
75 Self {
76 learning_rate: 0.1,
77 decay_rate: 0.01,
78 min_strength: 0.01,
79 }
80 }
81}
82
83// =============================================================================
84// Data types
85// =============================================================================
86
87/// A single Hebbian edge between two memories.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CoactivationEdge {
90 /// Source memory ID.
91 pub from_id: i64,
92 /// Target memory ID.
93 pub to_id: i64,
94 /// Current connection strength in `[0, 1]`.
95 pub strength: f64,
96 /// Total number of co-activations recorded.
97 pub count: i64,
98 /// RFC 3339 timestamp of the most recent co-activation.
99 pub last_coactivated: String,
100}
101
102/// Aggregate statistics for the coactivation graph.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CoactivationReport {
105 /// Total number of edges in the graph.
106 pub total_edges: i64,
107 /// Mean strength across all edges (0.0 when graph is empty).
108 pub avg_strength: f64,
109 /// Top-10 strongest pairs as `(from_id, to_id, strength)`.
110 pub strongest_pairs: Vec<(i64, i64, f64)>,
111}
112
113// =============================================================================
114// Tracker
115// =============================================================================
116
117/// Stateless tracker — borrows a `rusqlite::Connection` for each operation.
118///
119/// All state lives in the `coactivation_edges` database table.
120pub struct CoactivationTracker {
121 /// Configuration controlling Hebbian dynamics.
122 pub config: CoactivationConfig,
123}
124
125impl CoactivationTracker {
126 /// Create a tracker with default configuration.
127 pub fn new() -> Self {
128 Self {
129 config: CoactivationConfig::default(),
130 }
131 }
132
133 /// Create a tracker with custom configuration.
134 pub fn with_config(config: CoactivationConfig) -> Self {
135 Self { config }
136 }
137
138 // -------------------------------------------------------------------------
139 // record_coactivation
140 // -------------------------------------------------------------------------
141
142 /// Record that a set of memories were retrieved together in one session.
143 ///
144 /// For every unordered pair `(a, b)` in `memory_ids` (where `a < b`) the
145 /// function upserts a `coactivation_edges` row, applying the Hebbian
146 /// update:
147 ///
148 /// ```text
149 /// strength ← min(1.0, strength + lr × (1 − strength))
150 /// ```
151 ///
152 /// Returns the number of edges updated (i.e. `n × (n−1) / 2` where `n` is
153 /// the number of unique IDs, assuming no self-loops).
154 ///
155 /// `session_id` is informational only — it is not stored but could be used
156 /// by callers for audit logging.
157 pub fn record_coactivation(
158 &self,
159 conn: &Connection,
160 memory_ids: &[i64],
161 _session_id: &str,
162 ) -> Result<usize> {
163 // Deduplicate and sort to ensure canonical ordering.
164 let mut ids: Vec<i64> = memory_ids.to_vec();
165 ids.sort_unstable();
166 ids.dedup();
167
168 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
169 let lr = self.config.learning_rate;
170 let mut updated = 0usize;
171
172 // Iterate over every unique unordered pair.
173 for i in 0..ids.len() {
174 for j in (i + 1)..ids.len() {
175 let from_id = ids[i];
176 let to_id = ids[j];
177 self.upsert_edge(conn, from_id, to_id, lr, &now)?;
178 updated += 1;
179 }
180 }
181
182 Ok(updated)
183 }
184
185 // -------------------------------------------------------------------------
186 // strengthen
187 // -------------------------------------------------------------------------
188
189 /// Apply a single Hebbian update to the edge `from_id → to_id`.
190 ///
191 /// Creates the edge if it does not exist yet. Returns the new strength.
192 pub fn strengthen(&self, conn: &Connection, from_id: i64, to_id: i64) -> Result<f64> {
193 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
194 self.upsert_edge(conn, from_id, to_id, self.config.learning_rate, &now)?;
195
196 // Read back the updated strength.
197 let strength: f64 = conn
198 .query_row(
199 "SELECT strength FROM coactivation_edges WHERE from_id = ?1 AND to_id = ?2",
200 params![from_id, to_id],
201 |row| row.get(0),
202 )
203 .map_err(EngramError::Database)?;
204
205 Ok(strength)
206 }
207
208 // -------------------------------------------------------------------------
209 // weaken_unused
210 // -------------------------------------------------------------------------
211
212 /// Decay edges that have not been co-activated in at least `min_age_days`.
213 ///
214 /// Each qualifying edge has its strength multiplied by `(1 − decay_rate)`.
215 /// Edges that fall below [`CoactivationConfig::min_strength`] are deleted.
216 ///
217 /// Returns the number of edges affected (updated **or** deleted).
218 pub fn weaken_unused(
219 &self,
220 conn: &Connection,
221 decay_rate: f64,
222 min_age_days: u32,
223 ) -> Result<usize> {
224 let cutoff = Utc::now() - chrono::Duration::days(min_age_days as i64);
225 let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
226 let min_strength = self.config.min_strength;
227
228 // Apply decay to edges that haven't been touched recently.
229 let updated = conn
230 .execute(
231 "UPDATE coactivation_edges
232 SET strength = strength * (1.0 - ?1)
233 WHERE last_coactivated < ?2",
234 params![decay_rate, cutoff_str],
235 )
236 .map_err(EngramError::Database)?;
237
238 // Delete edges that fell below the minimum strength threshold.
239 let deleted = conn
240 .execute(
241 "DELETE FROM coactivation_edges WHERE strength < ?1",
242 params![min_strength],
243 )
244 .map_err(EngramError::Database)?;
245
246 Ok(updated + deleted)
247 }
248
249 // -------------------------------------------------------------------------
250 // get_coactivation_graph
251 // -------------------------------------------------------------------------
252
253 /// Return all edges incident to `memory_id`, sorted by strength descending.
254 ///
255 /// Both `from_id = memory_id` and `to_id = memory_id` rows are returned,
256 /// normalized so that `from_id` is always `memory_id` in the result.
257 pub fn get_coactivation_graph(
258 &self,
259 conn: &Connection,
260 memory_id: i64,
261 ) -> Result<Vec<CoactivationEdge>> {
262 let mut stmt = conn
263 .prepare(
264 "SELECT from_id, to_id, strength, coactivation_count, last_coactivated
265 FROM coactivation_edges
266 WHERE from_id = ?1 OR to_id = ?1
267 ORDER BY strength DESC",
268 )
269 .map_err(EngramError::Database)?;
270
271 let edges = stmt
272 .query_map(params![memory_id], |row| {
273 Ok(CoactivationEdge {
274 from_id: row.get(0)?,
275 to_id: row.get(1)?,
276 strength: row.get(2)?,
277 count: row.get(3)?,
278 last_coactivated: row.get(4)?,
279 })
280 })
281 .map_err(EngramError::Database)?
282 .collect::<rusqlite::Result<Vec<_>>>()
283 .map_err(EngramError::Database)?;
284
285 Ok(edges)
286 }
287
288 // -------------------------------------------------------------------------
289 // suggest_related
290 // -------------------------------------------------------------------------
291
292 /// Return the `top_k` strongest co-activation partners for `memory_id`.
293 ///
294 /// Result tuples are `(neighbor_id, strength)` sorted by strength
295 /// descending.
296 pub fn suggest_related(
297 &self,
298 conn: &Connection,
299 memory_id: i64,
300 top_k: usize,
301 ) -> Result<Vec<(i64, f64)>> {
302 // Query both directions; expose the neighbor ID regardless of direction.
303 let mut stmt = conn
304 .prepare(
305 "SELECT
306 CASE WHEN from_id = ?1 THEN to_id ELSE from_id END AS neighbor,
307 strength
308 FROM coactivation_edges
309 WHERE from_id = ?1 OR to_id = ?1
310 ORDER BY strength DESC
311 LIMIT ?2",
312 )
313 .map_err(EngramError::Database)?;
314
315 let pairs = stmt
316 .query_map(params![memory_id, top_k as i64], |row| {
317 Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
318 })
319 .map_err(EngramError::Database)?
320 .collect::<rusqlite::Result<Vec<_>>>()
321 .map_err(EngramError::Database)?;
322
323 Ok(pairs)
324 }
325
326 // -------------------------------------------------------------------------
327 // report
328 // -------------------------------------------------------------------------
329
330 /// Compute aggregate statistics over the entire coactivation graph.
331 pub fn report(&self, conn: &Connection) -> Result<CoactivationReport> {
332 // Total edge count and average strength.
333 let (total_edges, avg_strength): (i64, f64) = conn
334 .query_row(
335 "SELECT COUNT(*), COALESCE(AVG(strength), 0.0) FROM coactivation_edges",
336 [],
337 |row| Ok((row.get(0)?, row.get(1)?)),
338 )
339 .map_err(EngramError::Database)?;
340
341 // Top-10 strongest pairs.
342 let mut stmt = conn
343 .prepare(
344 "SELECT from_id, to_id, strength
345 FROM coactivation_edges
346 ORDER BY strength DESC
347 LIMIT 10",
348 )
349 .map_err(EngramError::Database)?;
350
351 let strongest_pairs = stmt
352 .query_map([], |row| {
353 Ok((
354 row.get::<_, i64>(0)?,
355 row.get::<_, i64>(1)?,
356 row.get::<_, f64>(2)?,
357 ))
358 })
359 .map_err(EngramError::Database)?
360 .collect::<rusqlite::Result<Vec<_>>>()
361 .map_err(EngramError::Database)?;
362
363 Ok(CoactivationReport {
364 total_edges,
365 avg_strength,
366 strongest_pairs,
367 })
368 }
369
370 // -------------------------------------------------------------------------
371 // Private helpers
372 // -------------------------------------------------------------------------
373
374 /// Upsert the `(from_id, to_id)` edge with a Hebbian strength update.
375 ///
376 /// If the edge does not exist it is created with `strength = lr`.
377 /// If it already exists the strength is updated as:
378 /// `strength ← min(1.0, strength + lr × (1 − strength))`
379 fn upsert_edge(
380 &self,
381 conn: &Connection,
382 from_id: i64,
383 to_id: i64,
384 lr: f64,
385 now: &str,
386 ) -> Result<()> {
387 // On conflict (PRIMARY KEY collision) apply Hebbian update in-place.
388 conn.execute(
389 "INSERT INTO coactivation_edges (from_id, to_id, strength, coactivation_count, last_coactivated)
390 VALUES (?1, ?2, MIN(1.0, ?3), 1, ?4)
391 ON CONFLICT (from_id, to_id) DO UPDATE SET
392 strength = MIN(1.0, strength + ?3 * (1.0 - strength)),
393 coactivation_count = coactivation_count + 1,
394 last_coactivated = ?4",
395 params![from_id, to_id, lr, now],
396 )
397 .map_err(EngramError::Database)?;
398
399 Ok(())
400 }
401}
402
403impl Default for CoactivationTracker {
404 fn default() -> Self {
405 Self::new()
406 }
407}
408
409// =============================================================================
410// Tests
411// =============================================================================
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use rusqlite::Connection;
417
418 /// Open an in-memory SQLite database and create the coactivation table.
419 fn setup_db() -> Connection {
420 let conn = Connection::open_in_memory().expect("open in-memory DB");
421 conn.execute_batch(CREATE_COACTIVATION_EDGES_TABLE)
422 .expect("create table");
423 conn
424 }
425
426 fn tracker() -> CoactivationTracker {
427 CoactivationTracker::new()
428 }
429
430 // -------------------------------------------------------------------------
431 // Test 1: record_coactivation creates edges for every pair
432 // -------------------------------------------------------------------------
433 #[test]
434 fn test_record_coactivation_creates_edges() {
435 let conn = setup_db();
436 let t = tracker();
437
438 let n = t
439 .record_coactivation(&conn, &[1, 2, 3], "session-1")
440 .expect("record");
441
442 // 3 IDs → 3 pairs: (1,2), (1,3), (2,3)
443 assert_eq!(n, 3, "should create one edge per unique pair");
444
445 let report = t.report(&conn).expect("report");
446 assert_eq!(report.total_edges, 3);
447 }
448
449 // -------------------------------------------------------------------------
450 // Test 2: strength increases with repeated co-activation
451 // -------------------------------------------------------------------------
452 #[test]
453 fn test_strength_increases_with_repeated_coactivation() {
454 let conn = setup_db();
455 let t = tracker();
456
457 t.record_coactivation(&conn, &[10, 20], "s1")
458 .expect("first");
459 let s1 = get_strength(&conn, 10, 20);
460
461 t.record_coactivation(&conn, &[10, 20], "s2")
462 .expect("second");
463 let s2 = get_strength(&conn, 10, 20);
464
465 t.record_coactivation(&conn, &[10, 20], "s3")
466 .expect("third");
467 let s3 = get_strength(&conn, 10, 20);
468
469 assert!(s1 > 0.0, "first activation must produce positive strength");
470 assert!(s2 > s1, "second activation must increase strength");
471 assert!(s3 > s2, "third activation must increase strength further");
472 assert!(s3 <= 1.0, "strength must be capped at 1.0");
473 }
474
475 // -------------------------------------------------------------------------
476 // Test 3: weaken_unused decays old edges and removes sub-threshold ones
477 // -------------------------------------------------------------------------
478 #[test]
479 fn test_weaken_unused_decays_and_prunes() {
480 let conn = setup_db();
481 let t = CoactivationTracker::with_config(CoactivationConfig {
482 learning_rate: 0.1,
483 decay_rate: 0.5, // aggressive decay so we can see the effect
484 min_strength: 0.08, // threshold just above the decayed value
485 });
486
487 // Insert an edge directly with a very old timestamp so it qualifies
488 // for decay (min_age_days = 0 means any edge qualifies, but let's use
489 // a real old timestamp to be precise).
490 conn.execute(
491 "INSERT INTO coactivation_edges
492 (from_id, to_id, strength, coactivation_count, last_coactivated)
493 VALUES (100, 200, 0.10, 1, '2020-01-01T00:00:00.000Z')",
494 [],
495 )
496 .expect("insert old edge");
497
498 // Insert a fresh edge (should not be decayed with min_age_days=1).
499 let now_str = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
500 conn.execute(
501 "INSERT INTO coactivation_edges
502 (from_id, to_id, strength, coactivation_count, last_coactivated)
503 VALUES (100, 300, 0.50, 5, ?1)",
504 params![now_str],
505 )
506 .expect("insert fresh edge");
507
508 // Decay edges older than 1 day using 50% decay rate.
509 let affected = t.weaken_unused(&conn, 0.5, 1).expect("weaken");
510
511 // The old edge (0.10) decays to 0.05 which is below min_strength (0.08),
512 // so it gets deleted. The fresh edge is untouched.
513 // affected = 1 (updated) + 1 (deleted) = 2
514 assert!(affected >= 1, "at least one edge should be affected");
515
516 // The old edge should be gone.
517 let count: i64 = conn
518 .query_row(
519 "SELECT COUNT(*) FROM coactivation_edges WHERE from_id=100 AND to_id=200",
520 [],
521 |r| r.get(0),
522 )
523 .unwrap();
524 assert_eq!(count, 0, "sub-threshold edge should be deleted");
525
526 // The fresh edge should still be present.
527 let count: i64 = conn
528 .query_row(
529 "SELECT COUNT(*) FROM coactivation_edges WHERE from_id=100 AND to_id=300",
530 [],
531 |r| r.get(0),
532 )
533 .unwrap();
534 assert_eq!(count, 1, "fresh edge should survive");
535 }
536
537 // -------------------------------------------------------------------------
538 // Test 4: get_coactivation_graph returns neighbors sorted by strength desc
539 // -------------------------------------------------------------------------
540 #[test]
541 fn test_get_coactivation_graph_returns_sorted_neighbors() {
542 let conn = setup_db();
543 let t = tracker();
544
545 // Build a small star: node 1 connected to 2, 3, 4 with different counts.
546 for _ in 0..3 {
547 t.record_coactivation(&conn, &[1, 2], "s").unwrap();
548 }
549 for _ in 0..5 {
550 t.record_coactivation(&conn, &[1, 3], "s").unwrap();
551 }
552 for _ in 0..1 {
553 t.record_coactivation(&conn, &[1, 4], "s").unwrap();
554 }
555
556 let graph = t.get_coactivation_graph(&conn, 1).expect("graph");
557
558 assert_eq!(graph.len(), 3, "node 1 has 3 neighbors");
559
560 // Verify descending strength ordering.
561 for w in graph.windows(2) {
562 assert!(
563 w[0].strength >= w[1].strength,
564 "edges must be sorted by strength desc: {} >= {}",
565 w[0].strength,
566 w[1].strength
567 );
568 }
569 }
570
571 // -------------------------------------------------------------------------
572 // Test 5: suggest_related returns top-k strongest neighbors
573 // -------------------------------------------------------------------------
574 #[test]
575 fn test_suggest_related_returns_top_k() {
576 let conn = setup_db();
577 let t = tracker();
578
579 // Connect memory 1 to five others with varying counts.
580 for (neighbor, times) in [(10i64, 1), (20, 3), (30, 5), (40, 2), (50, 4)] {
581 for _ in 0..times {
582 t.record_coactivation(&conn, &[1, neighbor], "s").unwrap();
583 }
584 }
585
586 let top3 = t.suggest_related(&conn, 1, 3).expect("suggest");
587
588 assert_eq!(top3.len(), 3, "must return exactly top_k results");
589
590 // Results must be sorted by strength descending.
591 for w in top3.windows(2) {
592 assert!(w[0].1 >= w[1].1, "results must be sorted by strength desc");
593 }
594
595 // The strongest neighbor is memory 30 (5 activations).
596 assert_eq!(top3[0].0, 30, "strongest neighbor should be memory 30");
597 }
598
599 // -------------------------------------------------------------------------
600 // Test 6: report returns correct stats
601 // -------------------------------------------------------------------------
602 #[test]
603 fn test_report_stats() {
604 let conn = setup_db();
605 let t = tracker();
606
607 t.record_coactivation(&conn, &[1, 2, 3], "s1").unwrap();
608
609 let report = t.report(&conn).expect("report");
610
611 assert_eq!(report.total_edges, 3);
612 assert!(report.avg_strength > 0.0, "avg_strength must be positive");
613 assert!(
614 report.avg_strength <= 1.0,
615 "avg_strength must be at most 1.0"
616 );
617 assert!(
618 !report.strongest_pairs.is_empty(),
619 "strongest_pairs must not be empty"
620 );
621 assert!(
622 report.strongest_pairs.len() <= 10,
623 "strongest_pairs must have at most 10 entries"
624 );
625 }
626
627 // -------------------------------------------------------------------------
628 // Test 7: empty graph operations
629 // -------------------------------------------------------------------------
630 #[test]
631 fn test_empty_graph() {
632 let conn = setup_db();
633 let t = tracker();
634
635 let graph = t.get_coactivation_graph(&conn, 999).expect("graph");
636 assert!(graph.is_empty(), "no neighbors for unknown memory");
637
638 let related = t.suggest_related(&conn, 999, 5).expect("suggest");
639 assert!(related.is_empty(), "no suggestions for unknown memory");
640
641 let report = t.report(&conn).expect("report");
642 assert_eq!(report.total_edges, 0);
643 assert_eq!(report.avg_strength, 0.0);
644 assert!(report.strongest_pairs.is_empty());
645 }
646
647 // -------------------------------------------------------------------------
648 // Test 8: strengthen — single-edge Hebbian update
649 // -------------------------------------------------------------------------
650 #[test]
651 fn test_strengthen_single_edge() {
652 let conn = setup_db();
653 let t = tracker();
654
655 let s1 = t.strengthen(&conn, 5, 6).expect("strengthen 1");
656 let s2 = t.strengthen(&conn, 5, 6).expect("strengthen 2");
657
658 assert!(s1 > 0.0);
659 assert!(s2 > s1, "repeated calls must increase strength");
660 }
661
662 // -------------------------------------------------------------------------
663 // Test 9: record_coactivation is idempotent for a single memory
664 // -------------------------------------------------------------------------
665 #[test]
666 fn test_single_memory_no_self_loops() {
667 let conn = setup_db();
668 let t = tracker();
669
670 let n = t
671 .record_coactivation(&conn, &[42], "session-x")
672 .expect("record single");
673
674 // A single memory has no pairs, so zero edges should be created.
675 assert_eq!(n, 0, "no pairs from a single memory");
676
677 let report = t.report(&conn).expect("report");
678 assert_eq!(report.total_edges, 0);
679 }
680
681 // -------------------------------------------------------------------------
682 // Test 10: coactivation_count increments correctly
683 // -------------------------------------------------------------------------
684 #[test]
685 fn test_coactivation_count_increments() {
686 let conn = setup_db();
687 let t = tracker();
688
689 for _ in 0..4 {
690 t.record_coactivation(&conn, &[7, 8], "s").unwrap();
691 }
692
693 let graph = t.get_coactivation_graph(&conn, 7).expect("graph");
694 assert_eq!(graph.len(), 1);
695 assert_eq!(graph[0].count, 4, "count should reflect 4 co-activations");
696 }
697
698 // -------------------------------------------------------------------------
699 // Helpers
700 // -------------------------------------------------------------------------
701
702 fn get_strength(conn: &Connection, from_id: i64, to_id: i64) -> f64 {
703 conn.query_row(
704 "SELECT strength FROM coactivation_edges WHERE from_id=?1 AND to_id=?2",
705 params![from_id, to_id],
706 |r| r.get(0),
707 )
708 .unwrap_or(0.0)
709 }
710}