Skip to main content

wm_tools/expansion/
association.rs

1//! Association tools — associate_mine.
2
3#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::sync::Arc;
9use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
10use wm_memory::{Association, AssociationStore, LinkType, MemoryStore};
11
12use super::common::parse_galaxy;
13
14pub struct MemoryAssociateMineTool {
15    store: Arc<MemoryStore>,
16    stats: ToolStats,
17    effects: EffectRow,
18}
19
20impl MemoryAssociateMineTool {
21    pub fn new(store: Arc<MemoryStore>) -> Self {
22        Self {
23            store,
24            stats: ToolStats::default(),
25            effects: EffectRow {
26                writes: vec![Resource::Galaxy("associations".into())],
27                ..Default::default()
28            },
29        }
30    }
31}
32
33#[async_trait]
34impl Tool for MemoryAssociateMineTool {
35    fn input_schema(&self) -> Value {
36        super::common::schema(
37            &json!({
38                "galaxy": super::common::str_prop("Galaxy to mine (optional; default codex)"),
39                "limit": super::common::int_prop("Maximum memories to mine (default 50)"),
40            }),
41            &[],
42        )
43    }
44    fn name(&self) -> &str {
45        "memory.associate_mine"
46    }
47    fn gana(&self) -> Gana {
48        Gana::Net
49    }
50    fn effects(&self) -> &EffectRow {
51        &self.effects
52    }
53    fn description(&self) -> &str {
54        "Mine associations across galaxies using keyword overlap"
55    }
56    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
57        let galaxy_name_str = args
58            .get("galaxy")
59            .and_then(|v| v.as_str())
60            .unwrap_or("codex");
61        let galaxy = parse_galaxy(galaxy_name_str)?;
62        let limit = args
63            .get("limit")
64            .and_then(serde_json::Value::as_u64)
65            .unwrap_or(50) as usize;
66        let memories = self.store.scan(galaxy, limit)?;
67        let env = self.store.env();
68        let assoc_store = AssociationStore::open(env)?;
69        let mut proposed = 0u32;
70        for i in 0..memories.len() {
71            for j in (i + 1)..memories.len() {
72                let a = &memories[i];
73                let b = &memories[j];
74                let a_words: std::collections::HashSet<&str> =
75                    a.content.split_whitespace().collect();
76                let b_words: std::collections::HashSet<&str> =
77                    b.content.split_whitespace().collect();
78                let intersection = a_words.intersection(&b_words).count();
79                let union = a_words.union(&b_words).count();
80                if union > 0 && intersection > 2 {
81                    let strength = intersection as f32 / union as f32;
82                    if strength > 0.3 {
83                        let assoc = Association::new(
84                            a.metadata.id,
85                            b.metadata.id,
86                            LinkType::Related,
87                            strength,
88                        );
89                        let _ = assoc_store.put(env, &assoc);
90                        proposed += 1;
91                    }
92                }
93            }
94        }
95        Ok(json!({
96            "status": "success",
97            "galaxy": galaxy_name_str,
98            "scanned": memories.len(),
99            "proposed_associations": proposed,
100        }))
101    }
102    fn stats(&self) -> &ToolStats {
103        &self.stats
104    }
105}
106
107/// `memory.corroborate` — record a session's corroboration of a memory
108/// (bridging consensus counter, 2nd PR after Slice B; maintainer ruling 2026-09-04).
109///
110/// A corroboration says: this session's agent independently stands behind
111/// the memory. Sessions nest agents and machines — sessions are the working
112/// unit for now; the record keeps only the session id (agent/machine can be
113/// joined from session records later without a schema change).
114///
115/// Semantics:
116/// - corroborator = explicit `session` UUID arg, else the dispatch
117///   `Context.session_id`, else `InvalidArgs` (anonymous corroboration is
118///   meaningless — independence requires an identity, and it is
119///   client-asserted like `user_id`: attribution, never authentication).
120/// - idempotent: a repeat corroboration by the same session reports
121///   `already_recorded` and changes nothing.
122/// - corroborating a non-current (validity-stamped) memory is allowed —
123///   history accumulates; the recall surface still filters by validity
124///   while enforced.
125/// - ranking effect: none unless `WM_CORROBORATION_WEIGHT > 0` (the
126///   saturating `corroboration_boost`, disclosed per-result).
127pub struct MemoryCorroborateTool {
128    store: Arc<MemoryStore>,
129    stats: ToolStats,
130    effects: EffectRow,
131}
132
133impl MemoryCorroborateTool {
134    pub fn new(store: Arc<MemoryStore>) -> Self {
135        Self {
136            store,
137            stats: ToolStats::default(),
138            effects: EffectRow {
139                writes: super::common::memory_galaxy_writes(),
140                reads: super::common::memory_galaxy_reads(),
141                ..Default::default()
142            },
143        }
144    }
145}
146
147#[async_trait]
148impl Tool for MemoryCorroborateTool {
149    fn name(&self) -> &str {
150        "memory.corroborate"
151    }
152    fn gana(&self) -> Gana {
153        Gana::Net
154    }
155    fn effects(&self) -> &EffectRow {
156        &self.effects
157    }
158    fn description(&self) -> &str {
159        "Record this session's corroboration of a memory (bridging consensus counter input; ranking effect only with WM_CORROBORATION_WEIGHT > 0)"
160    }
161    fn input_schema(&self) -> Value {
162        super::common::schema(
163            &json!({
164                "id": super::common::str_prop("Memory UUID to corroborate"),
165                "session": super::common::str_prop("Corroborating session UUID (defaults to the dispatch session; required when the dispatch carries none)"),
166            }),
167            &["id"],
168        )
169    }
170    async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
171        let id_str = args.get("id").and_then(Value::as_str).ok_or_else(|| {
172            wm_core::CoreError::InvalidArgs("id (memory UUID string) required".into())
173        })?;
174        let id = uuid::Uuid::parse_str(id_str)
175            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid memory UUID: {e}")))?;
176        let session = args
177            .get("session")
178            .and_then(Value::as_str)
179            .map(uuid::Uuid::parse_str)
180            .transpose()
181            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid session UUID: {e}")))?
182            .or(ctx.session_id)
183            .ok_or_else(|| {
184                wm_core::CoreError::InvalidArgs(
185                    "session (UUID string) required: the dispatch carries no session_id and none was passed"
186                        .into(),
187                )
188            })?;
189        // Galaxy-blind resolution (the graph.walk pattern): corroborate by
190        // id wherever the memory lives.
191        let mut found = None;
192        for galaxy in wm_core::Galaxy::memory_galaxies() {
193            if let Some(mem) = self.store.get(galaxy, id)? {
194                found = Some((galaxy, mem));
195                break;
196            }
197        }
198        let (galaxy, mut mem) = found.ok_or_else(|| {
199            wm_core::CoreError::NotFound(format!("memory {id} not found in any memory galaxy"))
200        })?;
201        if mem.metadata.corroborated_by.contains(&session) {
202            return Ok(json!({
203                "status": "already_recorded",
204                "id": id,
205                "galaxy": super::common::galaxy_name(galaxy),
206                "corroboration_count": mem.metadata.corroborated_by.len(),
207            }));
208        }
209        mem.metadata.corroborated_by.push(session);
210        // No content change: the Tantivy index needs no update.
211        self.store.put(galaxy, &mem)?;
212        Ok(json!({
213            "status": "recorded",
214            "id": id,
215            "galaxy": super::common::galaxy_name(galaxy),
216            "session": session,
217            "corroboration_count": mem.metadata.corroborated_by.len(),
218        }))
219    }
220    fn stats(&self) -> &ToolStats {
221        &self.stats
222    }
223}
224
225/// `memory.relate` — typed-edge tool over the existing [`AssociationStore`]
226/// (V8 S6: thin tool, not a new store).
227///
228/// Two actions:
229/// - **relate** (default): create or re-activate a typed edge between two
230///   memories. Existing edges are Hebbian-activated (`co_activation_count`
231///   bumps, weight strengthens) instead of duplicated. The `follows`
232///   link type maps onto `LinkType::Temporal` with the `follows`
233///   association-type marker — session --follows--> session semantics.
234/// - **derive_follows**: scan the Sessions galaxy (optionally filtered by
235///   a workspace tag), sort session starts by creation time, and link
236///   each consecutive pair with a `follows` edge — weight reflects the
237///   shared tags (provenance signal), capped at 0.9.
238///
239/// Edges decay/prune via the store's existing Hebbian dynamics; the read
240/// surface is `graph.walk` (and, with `WM_RECALL_GRAPH_WEIGHT > 0`, the
241/// recall fusion itself).
242pub struct MemoryRelateTool {
243    store: Arc<MemoryStore>,
244    stats: ToolStats,
245    effects: EffectRow,
246}
247
248impl MemoryRelateTool {
249    pub fn new(store: Arc<MemoryStore>) -> Self {
250        Self {
251            store,
252            stats: ToolStats::default(),
253            effects: EffectRow {
254                writes: vec![Resource::Galaxy("associations".into())],
255                ..Default::default()
256            },
257        }
258    }
259
260    /// Resolve a memory id across the memory galaxies.
261    fn memory_exists(&self, id: uuid::Uuid) -> bool {
262        wm_core::Galaxy::memory_galaxies()
263            .iter()
264            .any(|g| self.store.get(*g, id).ok().flatten().is_some())
265    }
266
267    fn relate_pair(
268        &self,
269        source: uuid::Uuid,
270        target: uuid::Uuid,
271        link_type: LinkType,
272        marker: &str,
273        weight: f32,
274    ) -> wm_core::Result<Value> {
275        if source == target {
276            return Err(wm_core::CoreError::InvalidArgs(
277                "source and target must differ".into(),
278            ));
279        }
280        if !self.memory_exists(source) {
281            return Err(wm_core::CoreError::NotFound(format!(
282                "source memory {source} not found in any memory galaxy"
283            )));
284        }
285        if !self.memory_exists(target) {
286            return Err(wm_core::CoreError::NotFound(format!(
287                "target memory {target} not found in any memory galaxy"
288            )));
289        }
290        let env = self.store.env();
291        let assoc_store = AssociationStore::open(env)?;
292        if let Some(mut existing) = assoc_store.get(env, source, target)? {
293            // Hebbian re-activation: the edge strengthens with co-use.
294            existing.activate();
295            if existing.link_type != link_type {
296                existing.link_type = link_type;
297                existing.association_type = marker.to_string();
298            }
299            assoc_store.put(env, &existing)?;
300            return Ok(json!({
301                "status": "activated",
302                "source": source.to_string(),
303                "target": target.to_string(),
304                "link_type": existing.link_type.as_str(),
305                "weight": existing.weight,
306                "co_activation_count": existing.co_activation_count,
307            }));
308        }
309        let mut assoc = Association::new(source, target, link_type, weight.clamp(0.0, 1.0));
310        // `follows` rides the temporal type with its own marker (the
311        // constructor derives the string from the type — override it).
312        if marker != assoc.association_type {
313            assoc.association_type = marker.to_string();
314        }
315        assoc_store.put(env, &assoc)?;
316        Ok(json!({
317            "status": "created",
318            "source": source.to_string(),
319            "target": target.to_string(),
320            "link_type": assoc.link_type.as_str(),
321            "association_type": assoc.association_type,
322            "weight": assoc.weight,
323        }))
324    }
325
326    fn derive_follows(&self, workspace_tag: Option<&str>) -> wm_core::Result<Value> {
327        // Session start markers carry the `start` tag; sort by creation.
328        let mut sessions: Vec<wm_memory::Memory> = self
329            .store
330            .scan_all(wm_core::Galaxy::Sessions)?
331            .into_iter()
332            .filter(|m| m.metadata.tags.contains(&"start".to_string()))
333            .filter(|m| workspace_tag.is_none_or(|w| m.metadata.tags.contains(&w.to_string())))
334            .collect();
335        sessions.sort_by_key(|m| m.metadata.created_at);
336
337        let env = self.store.env();
338        let assoc_store = AssociationStore::open(env)?;
339        let mut linked = 0usize;
340        let mut activated = 0usize;
341        for pair in sessions.windows(2) {
342            let (earlier, later) = (&pair[0], &pair[1]);
343            // Shared tags = the provenance signal for the follow.
344            let shared = earlier
345                .metadata
346                .tags
347                .iter()
348                .filter(|t| later.metadata.tags.contains(t))
349                .count();
350            // Documented allow: mul_add changes float rounding; the weight is a
351            // deterministic provenance signal, not a hot-path score.
352            #[allow(clippy::suboptimal_flops)]
353            let weight = (0.5 + 0.05 * shared as f32).min(0.9);
354            let source = earlier.metadata.id;
355            let target = later.metadata.id;
356            if let Some(mut existing) = assoc_store.get(env, source, target)? {
357                existing.activate();
358                assoc_store.put(env, &existing)?;
359                activated += 1;
360            } else {
361                let mut assoc = Association::new(source, target, LinkType::Temporal, weight);
362                assoc.association_type = "follows".to_string();
363                assoc_store.put(env, &assoc)?;
364                linked += 1;
365            }
366        }
367        Ok(json!({
368            "status": "success",
369            "sessions": sessions.len(),
370            "workspace_tag": workspace_tag,
371            "follows_created": linked,
372            "follows_activated": activated,
373        }))
374    }
375
376    /// Extract `@token` mentions from text (`@` + 2+ word chars/hyphens,
377    /// lowercased, deduped, in first-seen order). No regex dependency —
378    /// a manual scan keeps this crate's dependency surface unchanged.
379    fn extract_mentions(text: &str) -> Vec<String> {
380        let mut out = Vec::new();
381        let bytes = text.as_bytes();
382        let mut i = 0;
383        while i < bytes.len() {
384            if bytes[i] == b'@' {
385                let mut j = i + 1;
386                while j < bytes.len()
387                    && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'-')
388                {
389                    j += 1;
390                }
391                if j - (i + 1) >= 2 {
392                    let token = text[i + 1..j].to_lowercase();
393                    if !out.contains(&token) {
394                        out.push(token);
395                    }
396                }
397                i = j;
398            } else {
399                i += 1;
400            }
401        }
402        out
403    }
404
405    /// Extract ISO calendar dates (`YYYY-MM-DD`) validated by chrono —
406    /// bare digit runs that are not real dates (month 13, day 40) are
407    /// refused rather than linked.
408    fn extract_iso_dates(text: &str) -> Vec<String> {
409        let mut out = Vec::new();
410        let bytes = text.as_bytes();
411        let mut i = 0;
412        while i + 10 <= bytes.len() {
413            let s = &text[i..i + 10];
414            let is_shape = s.as_bytes()[4] == b'-'
415                && s.as_bytes()[7] == b'-'
416                && s[..4].bytes().all(|b| b.is_ascii_digit())
417                && s[5..7].bytes().all(|b| b.is_ascii_digit())
418                && s[8..10].bytes().all(|b| b.is_ascii_digit());
419            if is_shape && chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
420                let date = s.to_string();
421                if !out.contains(&date) {
422                    out.push(date);
423                }
424                i += 10;
425            } else {
426                i += 1;
427            }
428        }
429        out
430    }
431
432    /// `derive_mentions` — close the S6 `mentions` gap beside
433    /// `derive_follows`. For each `@token` in a memory's content/title,
434    /// link to memories whose title contains the token or whose tags
435    /// equal it (case-insensitive) with a `(Related, "mentions")` edge —
436    /// the same marker-reuse trick as `follows` (no DB migration; the
437    /// recall graph phase already walks any edge type).
438    ///
439    /// Conservative by construction: tokens resolve only onto memories in
440    /// the same scanned galaxy, at most 5 targets per token (oldest
441    /// first), and re-runs Hebbian-activate instead of duplicating.
442    fn derive_mentions(&self, galaxy_arg: Option<&str>) -> wm_core::Result<Value> {
443        let galaxy = super::common::parse_galaxy_or(galaxy_arg, wm_core::Galaxy::Codex)?;
444        let mut mems = self.store.scan(galaxy, 10_000)?;
445        mems.sort_by_key(|m| m.metadata.created_at);
446        let mut created = 0usize;
447        let mut activated = 0usize;
448        let mut scanned = 0usize;
449        for source in &mems {
450            scanned += 1;
451            let haystack = match &source.metadata.title {
452                Some(title) => format!("{}\n{title}", source.content),
453                None => source.content.clone(),
454            };
455            for token in Self::extract_mentions(&haystack).iter().take(20) {
456                let mut targets = 0;
457                for target in &mems {
458                    if target.metadata.id == source.metadata.id || targets >= 5 {
459                        continue;
460                    }
461                    let title_hit = target
462                        .metadata
463                        .title
464                        .as_deref()
465                        .unwrap_or_default()
466                        .to_lowercase()
467                        .contains(token);
468                    let tag_hit = target
469                        .metadata
470                        .tags
471                        .iter()
472                        .any(|t| t.to_lowercase() == *token);
473                    if !(title_hit || tag_hit) {
474                        continue;
475                    }
476                    targets += 1;
477                    let out = self.relate_pair(
478                        source.metadata.id,
479                        target.metadata.id,
480                        LinkType::Related,
481                        "mentions",
482                        0.6,
483                    )?;
484                    if out.get("status").and_then(Value::as_str) == Some("activated") {
485                        activated += 1;
486                    } else {
487                        created += 1;
488                    }
489                }
490            }
491        }
492        Ok(json!({
493            "status": "success",
494            "galaxy": super::common::galaxy_name(galaxy),
495            "scanned": scanned,
496            "mentions_created": created,
497            "mentions_activated": activated,
498        }))
499    }
500
501    /// `derive_at` — close the S6 `at` gap beside `derive_follows`.
502    /// Memories are not time nodes, so there is nothing to point at:
503    /// instead, memories sharing an exact ISO date string are linked
504    /// consecutive-by-time with a `(Temporal, "at")` edge (same
505    /// marker-reuse trick). Exact-date co-occurrence only — no fuzzy
506    /// temporal inference, no speculative links.
507    fn derive_at(&self, galaxy_arg: Option<&str>) -> wm_core::Result<Value> {
508        let galaxy = super::common::parse_galaxy_or(galaxy_arg, wm_core::Galaxy::Codex)?;
509        let mut mems = self.store.scan(galaxy, 10_000)?;
510        mems.sort_by_key(|m| m.metadata.created_at);
511        let mut by_date: std::collections::BTreeMap<String, Vec<uuid::Uuid>> =
512            std::collections::BTreeMap::new();
513        for mem in &mems {
514            for date in Self::extract_iso_dates(&mem.content) {
515                by_date.entry(date).or_default().push(mem.metadata.id);
516            }
517        }
518        let mut created = 0usize;
519        let mut activated = 0usize;
520        let mut dates_linked = 0usize;
521        for ids in by_date.values().filter(|ids| ids.len() >= 2) {
522            dates_linked += 1;
523            for pair in ids.windows(2) {
524                let out = self.relate_pair(pair[0], pair[1], LinkType::Temporal, "at", 0.5)?;
525                if out.get("status").and_then(Value::as_str) == Some("activated") {
526                    activated += 1;
527                } else {
528                    created += 1;
529                }
530            }
531        }
532        Ok(json!({
533            "status": "success",
534            "galaxy": super::common::galaxy_name(galaxy),
535            "scanned": mems.len(),
536            "dates_linked": dates_linked,
537            "at_created": created,
538            "at_activated": activated,
539        }))
540    }
541}
542
543#[async_trait]
544impl Tool for MemoryRelateTool {
545    fn name(&self) -> &str {
546        "memory.relate"
547    }
548    fn gana(&self) -> Gana {
549        Gana::Net
550    }
551    fn effects(&self) -> &EffectRow {
552        &self.effects
553    }
554    fn input_schema(&self) -> Value {
555        super::common::schema(
556            &json!({
557                "action": super::common::str_prop("relate (default) | derive_follows | derive_mentions | derive_at"),
558                "source": super::common::str_prop("Source memory UUID (relate action)"),
559                "target": super::common::str_prop("Target memory UUID (relate action)"),
560                "link_type": super::common::str_prop("related | extends | contradicts | supersedes | temporal | causal | cascade | follows (default related; follows = temporal + follows marker)"),
561                "weight": super::common::str_prop("Edge weight 0.0-1.0 (default 0.6; follows derivation derives it from shared tags)"),
562                "workspace_tag": super::common::str_prop("derive_follows: only link sessions carrying this tag"),
563                "galaxy": super::common::str_prop("derive_mentions / derive_at: galaxy to scan (default codex)"),
564            }),
565            &[],
566        )
567    }
568    fn description(&self) -> &str {
569        "Relate two memories with a typed, Hebbian edge (related/extends/contradicts/supersedes/temporal/causal/cascade/follows), or derive session --follows--> session threads (action: derive_follows), @mention links (action: derive_mentions), or shared-date links (action: derive_at). Edges feed graph.walk and the recall fusion graph phase."
570    }
571    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
572        match args
573            .get("action")
574            .and_then(Value::as_str)
575            .unwrap_or("relate")
576        {
577            "derive_follows" => {
578                let workspace_tag = args
579                    .get("workspace_tag")
580                    .and_then(Value::as_str)
581                    .filter(|s| !s.is_empty());
582                self.derive_follows(workspace_tag)
583            }
584            "derive_mentions" => self.derive_mentions(args.get("galaxy").and_then(Value::as_str)),
585            "derive_at" => self.derive_at(args.get("galaxy").and_then(Value::as_str)),
586            "relate" => {
587                let source_str = args.get("source").and_then(Value::as_str).ok_or_else(|| {
588                    wm_core::CoreError::InvalidArgs("source (UUID string) required".into())
589                })?;
590                let target_str = args.get("target").and_then(Value::as_str).ok_or_else(|| {
591                    wm_core::CoreError::InvalidArgs("target (UUID string) required".into())
592                })?;
593                let source = uuid::Uuid::parse_str(source_str).map_err(|e| {
594                    wm_core::CoreError::InvalidArgs(format!("invalid source UUID: {e}"))
595                })?;
596                let target = uuid::Uuid::parse_str(target_str).map_err(|e| {
597                    wm_core::CoreError::InvalidArgs(format!("invalid target UUID: {e}"))
598                })?;
599                let weight = args
600                    .get("weight")
601                    .and_then(Value::as_f64)
602                    .map_or(0.6, |w| w as f32);
603                let link_str = args
604                    .get("link_type")
605                    .and_then(Value::as_str)
606                    .unwrap_or("related");
607                // `follows` is a semantic marker on the temporal type.
608                let (link_type, marker) = if link_str == "follows" {
609                    (LinkType::Temporal, "follows")
610                } else {
611                    (LinkType::from_str_lossy(link_str), link_str)
612                };
613                self.relate_pair(source, target, link_type, marker, weight)
614            }
615            other => Err(wm_core::CoreError::InvalidArgs(format!(
616                "unknown action '{other}' (relate | derive_follows | derive_mentions | derive_at)"
617            ))),
618        }
619    }
620    fn stats(&self) -> &ToolStats {
621        &self.stats
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use wm_memory::Memory;
629
630    fn test_store() -> Arc<MemoryStore> {
631        let dir = tempfile::tempdir().unwrap();
632        let path = dir.path().join("lmdb");
633        std::fs::create_dir_all(&path).unwrap();
634        // Leak the tempdir for the test's lifetime (store outlives it).
635        std::mem::forget(dir);
636        Arc::new(MemoryStore::open_default(path).unwrap())
637    }
638
639    #[tokio::test]
640    async fn relate_creates_then_activates_the_same_edge() {
641        let store = test_store();
642        let a = Memory::new(wm_core::Galaxy::Codex, "decision alpha".into());
643        let b = Memory::new(wm_core::Galaxy::Codex, "decision beta extends alpha".into());
644        store.put(wm_core::Galaxy::Codex, &a).unwrap();
645        store.put(wm_core::Galaxy::Codex, &b).unwrap();
646
647        let tool = MemoryRelateTool::new(store.clone());
648        let mut ctx = Context::default();
649        let args = json!({
650            "source": a.metadata.id.to_string(),
651            "target": b.metadata.id.to_string(),
652            "link_type": "extends",
653            "weight": 0.7,
654        });
655        let first = tool.call(&mut ctx, args.clone()).await.unwrap();
656        assert_eq!(first["status"], "created");
657        assert_eq!(first["link_type"], "extends");
658        assert!((first["weight"].as_f64().unwrap() - 0.7).abs() < 1e-6);
659
660        let second = tool.call(&mut ctx, args).await.unwrap();
661        assert_eq!(second["status"], "activated");
662        assert_eq!(second["co_activation_count"], 1);
663        // Hebbian re-activation strengthened the edge.
664        assert!(second["weight"].as_f64().unwrap() > 0.7);
665
666        // Exactly one edge exists.
667        let env = store.env();
668        let assocs = AssociationStore::open(env).unwrap();
669        assert_eq!(assocs.count(env).unwrap(), 1);
670    }
671
672    #[tokio::test]
673    async fn relate_follows_maps_to_temporal_with_marker() {
674        let store = test_store();
675        let s1 = Memory::new(wm_core::Galaxy::Sessions, "session one start".into());
676        let s2 = Memory::new(wm_core::Galaxy::Sessions, "session two start".into());
677        store.put(wm_core::Galaxy::Sessions, &s1).unwrap();
678        store.put(wm_core::Galaxy::Sessions, &s2).unwrap();
679
680        let tool = MemoryRelateTool::new(store);
681        let mut ctx = Context::default();
682        let out = tool
683            .call(
684                &mut ctx,
685                json!({
686                    "source": s1.metadata.id.to_string(),
687                    "target": s2.metadata.id.to_string(),
688                    "link_type": "follows",
689                }),
690            )
691            .await
692            .unwrap();
693        assert_eq!(out["link_type"], "temporal");
694        assert_eq!(out["association_type"], "follows");
695    }
696
697    #[tokio::test]
698    async fn relate_validates_endpoints() {
699        let store = test_store();
700        let a = Memory::new(wm_core::Galaxy::Codex, "existing memory".into());
701        store.put(wm_core::Galaxy::Codex, &a).unwrap();
702        let tool = MemoryRelateTool::new(store);
703        let mut ctx = Context::default();
704        let err = tool
705            .call(
706                &mut ctx,
707                json!({
708                    "source": a.metadata.id.to_string(),
709                    "target": uuid::Uuid::new_v4().to_string(),
710                }),
711            )
712            .await
713            .unwrap_err();
714        assert!(err.to_string().contains("target memory"), "{err}");
715
716        // Self-edges are refused.
717        let err2 = tool
718            .call(
719                &mut ctx,
720                json!({
721                    "source": a.metadata.id.to_string(),
722                    "target": a.metadata.id.to_string(),
723                }),
724            )
725            .await
726            .unwrap_err();
727        assert!(err2.to_string().contains("must differ"), "{err2}");
728    }
729
730    #[tokio::test]
731    async fn derive_follows_links_consecutive_sessions_in_order() {
732        let store = test_store();
733        // Three sessions, deliberately created out of chronological order.
734        let mk = |content: &str, offset_days: i64, workspace: Option<&str>| {
735            let mut m = Memory::new(wm_core::Galaxy::Sessions, content.to_string());
736            m.metadata.tags = vec!["start".to_string(), "session".to_string()];
737            if let Some(w) = workspace {
738                m.metadata.tags.push(w.to_string());
739            }
740            m.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(offset_days);
741            m.metadata.accessed_at = m.metadata.created_at;
742            m
743        };
744        let s1 = mk("first session", 3, Some("wmv5"));
745        let s2 = mk("second session", 2, Some("wmv5"));
746        let s3 = mk("third session", 1, None);
747        // Insert out of order; the derivation sorts by created_at.
748        for m in [&s3, &s1, &s2] {
749            store.put(wm_core::Galaxy::Sessions, m).unwrap();
750        }
751
752        let tool = MemoryRelateTool::new(store.clone());
753        let mut ctx = Context::default();
754
755        // Workspace-filtered: only s1 --follows--> s2 (both carry "wmv5").
756        let out = tool
757            .call(
758                &mut ctx,
759                json!({"action": "derive_follows", "workspace_tag": "wmv5"}),
760            )
761            .await
762            .unwrap();
763        assert_eq!(out["sessions"], 2);
764        assert_eq!(out["follows_created"], 1);
765
766        let env = store.env();
767        let assocs = AssociationStore::open(env).unwrap();
768        let from_s1 = assocs.find_from(env, s1.metadata.id).unwrap();
769        assert_eq!(from_s1.len(), 1);
770        assert_eq!(from_s1[0].target, s2.metadata.id);
771        assert_eq!(from_s1[0].link_type, LinkType::Temporal);
772        assert_eq!(from_s1[0].association_type, "follows");
773
774        // Unfiltered re-run: s2 --follows--> s3 is new; s1→s2 re-activates.
775        let out2 = tool
776            .call(&mut ctx, json!({"action": "derive_follows"}))
777            .await
778            .unwrap();
779        assert_eq!(out2["sessions"], 3);
780        assert_eq!(out2["follows_created"], 1);
781        assert_eq!(out2["follows_activated"], 1);
782        assert_eq!(assocs.count(env).unwrap(), 2);
783
784        // The acceptance query: "what led to s3" walks back through the
785        // follows chain to s1.
786        let chain = assocs.find_to(env, s3.metadata.id).unwrap();
787        assert_eq!(chain[0].source, s2.metadata.id);
788        let earlier = assocs.find_to(env, chain[0].source).unwrap();
789        assert_eq!(earlier[0].source, s1.metadata.id);
790    }
791
792    /// S6: `@token` mentions resolve onto titled/tagged memories with a
793    /// `(Related, "mentions")` edge; unresolvable tokens link nothing.
794    #[tokio::test]
795    async fn derive_mentions_links_at_tokens_to_titled_memories() {
796        let store = test_store();
797        let mut aria = Memory::new(wm_core::Galaxy::Codex, "persona notes".into());
798        aria.metadata.title = Some("Aria Essays".into());
799        store.put(wm_core::Galaxy::Codex, &aria).unwrap();
800        let mentioner = Memory::new(
801            wm_core::Galaxy::Codex,
802            "as @aria wrote, memory persists".into(),
803        );
804        store.put(wm_core::Galaxy::Codex, &mentioner).unwrap();
805        let lonely = Memory::new(
806            wm_core::Galaxy::Codex,
807            "shouting into the void @nobodyhere".into(),
808        );
809        store.put(wm_core::Galaxy::Codex, &lonely).unwrap();
810
811        let tool = MemoryRelateTool::new(store.clone());
812        let mut ctx = Context::default();
813        let out = tool
814            .call(&mut ctx, json!({"action": "derive_mentions"}))
815            .await
816            .unwrap();
817        assert_eq!(out["status"], "success");
818        assert_eq!(out["scanned"], 3);
819        assert_eq!(out["mentions_created"], 1);
820
821        let env = store.env();
822        let assocs = AssociationStore::open(env).unwrap();
823        let edges = assocs.find_from(env, mentioner.metadata.id).unwrap();
824        assert_eq!(edges.len(), 1);
825        assert_eq!(edges[0].target, aria.metadata.id);
826        assert_eq!(edges[0].link_type, LinkType::Related);
827        assert_eq!(edges[0].association_type, "mentions");
828        // Unresolvable token: no edge.
829        assert!(
830            assocs
831                .find_from(env, lonely.metadata.id)
832                .unwrap()
833                .is_empty()
834        );
835
836        // Re-run Hebbian-activates instead of duplicating.
837        let out2 = tool
838            .call(&mut ctx, json!({"action": "derive_mentions"}))
839            .await
840            .unwrap();
841        assert_eq!(out2["mentions_created"], 0);
842        assert_eq!(out2["mentions_activated"], 1);
843        assert_eq!(assocs.count(env).unwrap(), 1);
844    }
845
846    /// S6: shared exact ISO dates link consecutive-by-time with a
847    /// `(Temporal, "at")` edge; non-dates (`2026-13-40`) never link.
848    #[tokio::test]
849    async fn derive_at_links_shared_dates_only() {
850        let store = test_store();
851        let a = Memory::new(
852            wm_core::Galaxy::Codex,
853            "shipped the slice on 2026-09-04".into(),
854        );
855        store.put(wm_core::Galaxy::Codex, &a).unwrap();
856        let b = Memory::new(
857            wm_core::Galaxy::Codex,
858            "retro on 2026-09-04 went well".into(),
859        );
860        store.put(wm_core::Galaxy::Codex, &b).unwrap();
861        let c = Memory::new(
862            wm_core::Galaxy::Codex,
863            "impossible date 2026-13-40 here".into(),
864        );
865        store.put(wm_core::Galaxy::Codex, &c).unwrap();
866
867        let tool = MemoryRelateTool::new(store.clone());
868        let mut ctx = Context::default();
869        let out = tool
870            .call(&mut ctx, json!({"action": "derive_at"}))
871            .await
872            .unwrap();
873        assert_eq!(out["status"], "success");
874        assert_eq!(out["dates_linked"], 1);
875        assert_eq!(out["at_created"], 1);
876
877        let env = store.env();
878        let assocs = AssociationStore::open(env).unwrap();
879        let edges = assocs.find_from(env, a.metadata.id).unwrap();
880        assert_eq!(edges.len(), 1);
881        assert_eq!(edges[0].target, b.metadata.id);
882        assert_eq!(edges[0].link_type, LinkType::Temporal);
883        assert_eq!(edges[0].association_type, "at");
884        assert!(assocs.find_from(env, c.metadata.id).unwrap().is_empty());
885    }
886
887    /// Bridging counter: recording is idempotent per session, distinct
888    /// sessions accumulate, and anonymous corroboration is refused.
889    #[tokio::test]
890    async fn corroborate_records_distinct_sessions_idempotently() {
891        let store = test_store();
892        let mem = Memory::new(wm_core::Galaxy::Codex, "a claim".into());
893        let id = mem.metadata.id;
894        store.put(wm_core::Galaxy::Codex, &mem).unwrap();
895
896        let tool = MemoryCorroborateTool::new(store.clone());
897        let mut ctx = Context::default();
898        let s1 = uuid::Uuid::new_v4().to_string();
899        let s2 = uuid::Uuid::new_v4().to_string();
900
901        let first = tool
902            .call(&mut ctx, json!({"id": id.to_string(), "session": s1}))
903            .await
904            .unwrap();
905        assert_eq!(first["status"], "recorded");
906        assert_eq!(first["corroboration_count"], 1);
907
908        // Same session again: no-op, count unchanged.
909        let repeat = tool
910            .call(&mut ctx, json!({"id": id.to_string(), "session": s1}))
911            .await
912            .unwrap();
913        assert_eq!(repeat["status"], "already_recorded");
914        assert_eq!(repeat["corroboration_count"], 1);
915
916        // Distinct session accumulates.
917        let second = tool
918            .call(&mut ctx, json!({"id": id.to_string(), "session": s2}))
919            .await
920            .unwrap();
921        assert_eq!(second["status"], "recorded");
922        assert_eq!(second["corroboration_count"], 2);
923
924        // Persisted on the record.
925        let stored = store.get(wm_core::Galaxy::Codex, id).unwrap().unwrap();
926        assert_eq!(stored.metadata.corroborated_by.len(), 2);
927    }
928
929    #[tokio::test]
930    async fn corroborate_uses_dispatch_session_then_refuses_anonymous() {
931        let store = test_store();
932        let mem = Memory::new(wm_core::Galaxy::Codex, "a claim".into());
933        let id = mem.metadata.id;
934        store.put(wm_core::Galaxy::Codex, &mem).unwrap();
935
936        let tool = MemoryCorroborateTool::new(store.clone());
937        // Dispatch session fills the corroborator.
938        let session = uuid::Uuid::new_v4();
939        let mut ctx = Context {
940            session_id: Some(session),
941            ..Default::default()
942        };
943        let out = tool
944            .call(&mut ctx, json!({"id": id.to_string()}))
945            .await
946            .unwrap();
947        assert_eq!(out["status"], "recorded");
948        assert_eq!(out["session"], session.to_string());
949
950        // No session anywhere: refused, not minted anonymously.
951        let mut bare = Context::default();
952        let err = tool
953            .call(&mut bare, json!({"id": id.to_string()}))
954            .await
955            .unwrap_err();
956        assert!(matches!(err, wm_core::CoreError::InvalidArgs(_)));
957
958        // Unknown memory: not found.
959        let err = tool
960            .call(
961                &mut bare,
962                json!({"id": uuid::Uuid::new_v4().to_string(), "session": session.to_string()}),
963            )
964            .await
965            .unwrap_err();
966        assert!(matches!(err, wm_core::CoreError::NotFound(_)));
967    }
968}