Skip to main content

rustbrain_core/
autolink.rs

1//! Orphan detection and soft auto-links (algorithmic, low weight).
2//!
3//! **Explicit** edges (`relates_to`, `anchors`, …) are user/agent-authored.
4//! **Auto** edges use `relation_type` values prefixed with `auto_` and lower
5//! weights so context hops prefer human links.
6
7use crate::error::Result;
8use crate::storage::Database;
9use crate::types::{Edge, Node, NodeType};
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14
15/// Soft edge: same filename stem under different paths (e.g. goals/x.md ↔ concepts/x.md).
16pub const REL_AUTO_FILENAME: &str = "auto_filename";
17/// Soft edge: shared tag (excluding trivial type-only tags).
18pub const REL_AUTO_TAG: &str = "auto_tag";
19
20/// Default weight for filename-stem matches.
21pub const WEIGHT_AUTO_FILENAME: f32 = 0.40;
22/// Default weight for shared-tag matches.
23pub const WEIGHT_AUTO_TAG: f32 = 0.25;
24
25/// True when `relation_type` is a soft auto-link.
26pub fn is_auto_relation(relation_type: &str) -> bool {
27    relation_type.starts_with("auto_")
28}
29
30/// True when the relation is user/agent-authored (not auto).
31pub fn is_explicit_relation(relation_type: &str) -> bool {
32    !is_auto_relation(relation_type)
33}
34
35/// A note with no explicit (non-auto) graph edges.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OrphanNote {
38    /// Node id.
39    pub id: String,
40    /// Domain type.
41    pub node_type: String,
42    /// Title.
43    pub title: String,
44    /// Repo-relative path if known.
45    pub file_path: Option<String>,
46    /// Suggested soft links (not yet written unless auto-link is run).
47    pub suggestions: Vec<AutoLinkSuggestion>,
48}
49
50/// One proposed or applied soft edge endpoint.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AutoLinkSuggestion {
53    /// Other node id.
54    pub target_id: String,
55    /// Other node title (best-effort).
56    pub target_title: String,
57    /// Other path if known.
58    pub target_path: Option<String>,
59    /// `auto_filename` / `auto_tag` / …
60    pub relation_type: String,
61    /// Soft weight.
62    pub weight: f32,
63    /// Human-readable reason.
64    pub reason: String,
65}
66
67/// Report from [`run_auto_link`].
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AutoLinkReport {
70    /// Edges inserted or updated this run.
71    pub edges_upserted: usize,
72    /// Unique undirected pairs considered.
73    pub pairs_considered: usize,
74    /// Target scope description.
75    pub scope: String,
76    /// Sample of applied links (up to 50).
77    pub applied: Vec<AutoLinkSuggestion>,
78}
79
80/// Filename stem for auto-filename matching (`docs/goals/rust-fluency.md` → `rust-fluency`).
81pub fn path_stem(file_path: &str) -> Option<String> {
82    let p = Path::new(file_path);
83    let stem = p.file_stem()?.to_str()?.to_ascii_lowercase();
84    if stem.is_empty() || stem == "readme" || stem == "template" {
85        return None;
86    }
87    // Ignore common generated / index stems
88    if stem.contains("generated") || stem == "from-readme" || stem == "bootstrap-checklist" {
89        return None;
90    }
91    Some(stem)
92}
93
94fn trivial_tag(tag: &str) -> bool {
95    matches!(
96        tag.to_ascii_lowercase().as_str(),
97        "goal"
98            | "adr"
99            | "concept"
100            | "symbol"
101            | "reference"
102            | "edge_case"
103            | "alternative"
104            | "analysis"
105            | "readme"
106            | "generated"
107            | "index"
108    )
109}
110
111/// List non-symbol notes that have zero **explicit** edges (auto edges ignored).
112pub fn list_orphan_notes(db: &Database) -> Result<Vec<OrphanNote>> {
113    let notes = list_note_nodes(db)?;
114    let explicit = explicit_degree(db)?;
115    let mut out = Vec::new();
116    for n in &notes {
117        let deg = explicit.get(&n.id).copied().unwrap_or(0);
118        if deg > 0 {
119            continue;
120        }
121        // Skip pure scaffold ids for the orphan list noise (still linkable via --auto path)
122        if is_noise_orphan_id(&n.id) {
123            continue;
124        }
125        let suggestions = suggest_for_node(db, n, &notes)?;
126        out.push(OrphanNote {
127            id: n.id.clone(),
128            node_type: n.node_type.as_str().to_string(),
129            title: n.title.clone(),
130            file_path: n.file_path.clone(),
131            suggestions,
132        });
133    }
134    out.sort_by(|a, b| a.id.cmp(&b.id));
135    Ok(out)
136}
137
138fn is_noise_orphan_id(id: &str) -> bool {
139    let id = id.to_lowercase();
140    id.contains("template")
141        || id.contains("bootstrap")
142        || id.contains("module-map.generated")
143        || id == "agents"
144        || id.ends_with("/agents")
145        || id == "docs/goals/readme"
146}
147
148fn list_note_nodes(db: &Database) -> Result<Vec<Node>> {
149    let mut nodes = Vec::new();
150    for ty in [
151        NodeType::Goal,
152        NodeType::Adr,
153        NodeType::Concept,
154        NodeType::Analysis,
155        NodeType::EdgeCase,
156        NodeType::Reference,
157        NodeType::Alternative,
158        NodeType::Changelog,
159        NodeType::Plan,
160    ] {
161        for id in db.list_node_ids_by_type(ty.as_str())? {
162            if let Some(n) = db.get_node(&id)? {
163                nodes.push(n);
164            }
165        }
166    }
167    // Root project hubs (README / CHANGELOG / optional planning docs)
168    for id in [
169        crate::hubs::HUB_README,
170        crate::hubs::HUB_CHANGELOG,
171        crate::hubs::HUB_ROADMAP,
172        crate::hubs::HUB_BACKLOG,
173    ] {
174        if let Some(n) = db.get_node(id)? {
175            if !nodes.iter().any(|x| x.id == id) {
176                nodes.push(n);
177            }
178        }
179    }
180    Ok(nodes)
181}
182
183fn explicit_degree(db: &Database) -> Result<HashMap<String, usize>> {
184    let edges = db.get_all_edges()?;
185    let mut deg: HashMap<String, usize> = HashMap::new();
186    for e in edges {
187        if !is_explicit_relation(&e.relation_type) {
188            continue;
189        }
190        *deg.entry(e.source_id).or_default() += 1;
191        *deg.entry(e.target_id).or_default() += 1;
192    }
193    Ok(deg)
194}
195
196fn suggest_for_node(db: &Database, node: &Node, all: &[Node]) -> Result<Vec<AutoLinkSuggestion>> {
197    let mut suggestions = Vec::new();
198    let mut seen = HashSet::new();
199
200    // Filename stem matches
201    if let Some(path) = &node.file_path {
202        if let Some(stem) = path_stem(path) {
203            for other in all {
204                if other.id == node.id {
205                    continue;
206                }
207                let Some(op) = other.file_path.as_ref() else {
208                    continue;
209                };
210                let Some(ostem) = path_stem(op) else {
211                    continue;
212                };
213                if ostem != stem {
214                    continue;
215                }
216                let key = format!("fn:{}:{}", node.id, other.id);
217                if !seen.insert(key) {
218                    continue;
219                }
220                suggestions.push(AutoLinkSuggestion {
221                    target_id: other.id.clone(),
222                    target_title: other.title.clone(),
223                    target_path: other.file_path.clone(),
224                    relation_type: REL_AUTO_FILENAME.into(),
225                    weight: WEIGHT_AUTO_FILENAME,
226                    reason: format!("same filename stem `{stem}`"),
227                });
228            }
229        }
230    }
231
232    // Shared tags
233    let tags: HashSet<String> = db
234        .get_tags_for(&node.id)?
235        .into_iter()
236        .filter(|t| !trivial_tag(t))
237        .map(|t| t.to_ascii_lowercase())
238        .collect();
239    if !tags.is_empty() {
240        for other in all {
241            if other.id == node.id {
242                continue;
243            }
244            let otags = db.get_tags_for(&other.id)?;
245            let shared: Vec<String> = otags
246                .into_iter()
247                .filter(|t| !trivial_tag(t) && tags.contains(&t.to_ascii_lowercase()))
248                .collect();
249            if shared.is_empty() {
250                continue;
251            }
252            let key = format!("tag:{}:{}", node.id, other.id);
253            if !seen.insert(key) {
254                continue;
255            }
256            suggestions.push(AutoLinkSuggestion {
257                target_id: other.id.clone(),
258                target_title: other.title.clone(),
259                target_path: other.file_path.clone(),
260                relation_type: REL_AUTO_TAG.into(),
261                weight: WEIGHT_AUTO_TAG,
262                reason: format!("shared tag(s): {}", shared.join(", ")),
263            });
264        }
265    }
266
267    suggestions.sort_by(|a, b| {
268        b.weight
269            .partial_cmp(&a.weight)
270            .unwrap_or(std::cmp::Ordering::Equal)
271            .then_with(|| a.target_id.cmp(&b.target_id))
272    });
273    suggestions.truncate(24);
274    Ok(suggestions)
275}
276
277/// Create soft auto-links for all orphans, or for one file/path/node id.
278///
279/// When `target` is `None`, processes every orphan (plus always applies
280/// filename-stem matches among all notes so `goals/x.md` ↔ `concepts/x.md`
281/// even if neither was classified orphan).
282pub fn run_auto_link(db: &Database, target: Option<&Path>) -> Result<AutoLinkReport> {
283    let all = list_note_nodes(db)?;
284    let now = Utc::now().timestamp();
285    let mut applied = Vec::new();
286    let mut edges_upserted = 0usize;
287    let mut pairs = HashSet::new();
288
289    let scope = if let Some(t) = target {
290        let focus = resolve_target_node(db, t, &all)?;
291        // Clear prior auto edges involving this node so re-run is idempotent.
292        db.clear_auto_edges_involving(&focus.id)?;
293        let suggestions = suggest_for_node(db, &focus, &all)?;
294        for s in &suggestions {
295            edges_upserted += upsert_auto_pair(
296                db,
297                &focus.id,
298                &s.target_id,
299                &s.relation_type,
300                s.weight,
301                now,
302                &mut pairs,
303            )?;
304            applied.push(s.clone());
305        }
306        format!("node {}", focus.id)
307    } else {
308        // Full rebuild of auto edges from filename + tags (orphans prioritized in report).
309        db.clear_all_auto_edges()?;
310        // Filename: group by stem
311        let mut by_stem: HashMap<String, Vec<&Node>> = HashMap::new();
312        for n in &all {
313            if let Some(p) = &n.file_path {
314                if let Some(stem) = path_stem(p) {
315                    by_stem.entry(stem).or_default().push(n);
316                }
317            }
318        }
319        for (stem, group) in &by_stem {
320            if group.len() < 2 {
321                continue;
322            }
323            for i in 0..group.len() {
324                for j in (i + 1)..group.len() {
325                    let a = group[i];
326                    let b = group[j];
327                    edges_upserted += upsert_auto_pair(
328                        db,
329                        &a.id,
330                        &b.id,
331                        REL_AUTO_FILENAME,
332                        WEIGHT_AUTO_FILENAME,
333                        now,
334                        &mut pairs,
335                    )?;
336                    if applied.len() < 50 {
337                        applied.push(AutoLinkSuggestion {
338                            target_id: b.id.clone(),
339                            target_title: b.title.clone(),
340                            target_path: b.file_path.clone(),
341                            relation_type: REL_AUTO_FILENAME.into(),
342                            weight: WEIGHT_AUTO_FILENAME,
343                            reason: format!("same filename stem `{stem}` ({} ↔ {})", a.id, b.id),
344                        });
345                    }
346                }
347            }
348        }
349        // Tags among all notes
350        let mut tag_index: HashMap<String, Vec<String>> = HashMap::new();
351        for n in &all {
352            for t in db.get_tags_for(&n.id)? {
353                if trivial_tag(&t) {
354                    continue;
355                }
356                tag_index
357                    .entry(t.to_ascii_lowercase())
358                    .or_default()
359                    .push(n.id.clone());
360            }
361        }
362        for (tag, ids) in &tag_index {
363            let mut uniq = ids.clone();
364            uniq.sort();
365            uniq.dedup();
366            if uniq.len() < 2 {
367                continue;
368            }
369            for i in 0..uniq.len() {
370                for j in (i + 1)..uniq.len() {
371                    let a = &uniq[i];
372                    let b = &uniq[j];
373                    edges_upserted += upsert_auto_pair(
374                        db,
375                        a,
376                        b,
377                        REL_AUTO_TAG,
378                        WEIGHT_AUTO_TAG,
379                        now,
380                        &mut pairs,
381                    )?;
382                    if applied.len() < 50 {
383                        let bt = all
384                            .iter()
385                            .find(|n| n.id == *b)
386                            .map(|n| n.title.clone())
387                            .unwrap_or_else(|| b.clone());
388                        let bp = all
389                            .iter()
390                            .find(|n| n.id == *b)
391                            .and_then(|n| n.file_path.clone());
392                        applied.push(AutoLinkSuggestion {
393                            target_id: b.clone(),
394                            target_title: bt,
395                            target_path: bp,
396                            relation_type: REL_AUTO_TAG.into(),
397                            weight: WEIGHT_AUTO_TAG,
398                            reason: format!("shared tag `{tag}` ({a} ↔ {b})"),
399                        });
400                    }
401                }
402            }
403        }
404        "all notes (filename stems + shared tags)".into()
405    };
406
407    Ok(AutoLinkReport {
408        edges_upserted,
409        pairs_considered: pairs.len(),
410        scope,
411        applied,
412    })
413}
414
415fn upsert_auto_pair(
416    db: &Database,
417    a: &str,
418    b: &str,
419    rel: &str,
420    weight: f32,
421    now: i64,
422    pairs: &mut HashSet<String>,
423) -> Result<usize> {
424    if a == b {
425        return Ok(0);
426    }
427    // Canonical undirected pair key
428    let (lo, hi) = if a < b { (a, b) } else { (b, a) };
429    let key = format!("{rel}:{lo}:{hi}");
430    if !pairs.insert(key) {
431        return Ok(0);
432    }
433    // Store both directions so CSR hops work either way.
434    let mut n = 0;
435    for (src, tgt) in [(lo, hi), (hi, lo)] {
436        db.insert_edge(&Edge {
437            source_id: src.to_string(),
438            target_id: tgt.to_string(),
439            relation_type: rel.to_string(),
440            weight,
441            decay_rate: 0.0,
442            created_at: now,
443        })?;
444        n += 1;
445    }
446    Ok(n)
447}
448
449fn resolve_target_node(db: &Database, target: &Path, all: &[Node]) -> Result<Node> {
450    let raw = target.to_string_lossy().replace('\\', "/");
451    let raw = raw.trim_start_matches("./");
452
453    // Exact node id
454    if let Some(n) = db.get_node(raw)? {
455        return Ok(n);
456    }
457
458    // Match file_path suffix / equality
459    let candidates: Vec<&Node> = all
460        .iter()
461        .filter(|n| {
462            n.file_path
463                .as_ref()
464                .map(|p| {
465                    let p = p.replace('\\', "/");
466                    p == raw || p.ends_with(raw) || p.ends_with(&format!("/{raw}"))
467                })
468                .unwrap_or(false)
469        })
470        .collect();
471    if candidates.len() == 1 {
472        return Ok(candidates[0].clone());
473    }
474    if candidates.len() > 1 {
475        return Err(crate::error::BrainError::other(format!(
476            "ambiguous path `{raw}` matches {} nodes",
477            candidates.len()
478        )));
479    }
480
481    // Try with docs/ prefix
482    let with_docs = if raw.starts_with("docs/") {
483        raw.to_string()
484    } else {
485        format!("docs/{raw}")
486    };
487    if let Some(n) = all.iter().find(|n| {
488        n.file_path
489            .as_ref()
490            .map(|p| p.replace('\\', "/") == with_docs)
491            .unwrap_or(false)
492    }) {
493        return Ok(n.clone());
494    }
495
496    Err(crate::error::BrainError::other(format!(
497        "no note node found for `{raw}` — pass a path like docs/goals/foo.md or a node id"
498    )))
499}
500
501/// Normalize a user path argument to something resolve_target can use.
502pub fn normalize_target_arg(s: &str) -> PathBuf {
503    PathBuf::from(s.trim().trim_start_matches("./"))
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::types::NodeType;
510
511    fn note(id: &str, ty: NodeType, path: &str, title: &str) -> Node {
512        Node {
513            id: id.into(),
514            node_type: ty,
515            title: title.into(),
516            file_path: Some(path.into()),
517            symbol_hash: None,
518            summary: Some(title.into()),
519            content_hash: None,
520            created_at: 1,
521            updated_at: 1,
522        }
523    }
524
525    #[test]
526    fn path_stem_extracts_filename() {
527        assert_eq!(
528            path_stem("docs/goals/rust-fluency.md").as_deref(),
529            Some("rust-fluency")
530        );
531        assert_eq!(
532            path_stem("docs/concepts/rust-fluency.md").as_deref(),
533            Some("rust-fluency")
534        );
535        assert!(path_stem("docs/adr/TEMPLATE.md").is_none());
536    }
537
538    #[test]
539    fn filename_stem_auto_links_and_orphans() {
540        let db = Database::open_in_memory().unwrap();
541        let g = note(
542            "docs/goals/rust-fluency",
543            NodeType::Goal,
544            "docs/goals/rust-fluency.md",
545            "Rust fluency goal",
546        );
547        let c = note(
548            "docs/concepts/rust-fluency",
549            NodeType::Concept,
550            "docs/concepts/rust-fluency.md",
551            "Rust fluency deep dive",
552        );
553        db.insert_node(&g).unwrap();
554        db.insert_node(&c).unwrap();
555        db.index_fts(&g.id, &g.title, "goal body", "goal").unwrap();
556        db.index_fts(&c.id, &c.title, "concept body", "concept")
557            .unwrap();
558
559        let orphans = list_orphan_notes(&db).unwrap();
560        assert_eq!(orphans.len(), 2);
561        assert!(orphans
562            .iter()
563            .any(|o| o.suggestions.iter().any(|s| s.relation_type == REL_AUTO_FILENAME)));
564
565        let report = run_auto_link(&db, None).unwrap();
566        assert!(report.edges_upserted >= 2);
567        let edges = db.get_all_edges().unwrap();
568        assert!(edges.iter().any(|e| e.relation_type == REL_AUTO_FILENAME));
569
570        // After auto-link only, still orphan re: *explicit* edges
571        let orphans2 = list_orphan_notes(&db).unwrap();
572        assert_eq!(
573            orphans2.len(),
574            2,
575            "auto edges must not count as explicit"
576        );
577    }
578
579    #[test]
580    fn targeted_auto_link_by_path() {
581        let db = Database::open_in_memory().unwrap();
582        let g = note(
583            "docs/goals/foo",
584            NodeType::Goal,
585            "docs/goals/foo.md",
586            "Foo goal",
587        );
588        let c = note(
589            "docs/concepts/foo",
590            NodeType::Concept,
591            "docs/concepts/foo.md",
592            "Foo concept",
593        );
594        db.insert_node(&g).unwrap();
595        db.insert_node(&c).unwrap();
596        let report = run_auto_link(&db, Some(Path::new("docs/goals/foo.md"))).unwrap();
597        assert!(report.edges_upserted >= 2);
598        assert!(report.scope.contains("docs/goals/foo"));
599    }
600}