Skip to main content

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}