use rusqlite::{Connection, params};
const DEFAULT_MAX_DEPTH: u32 = 16;
const CYCLE_DEPTH_SAFETY_FACTOR: u32 = 2;
pub struct CycleCheckResult {
pub would_cycle: bool,
pub cycle_path: Vec<String>,
}
pub fn would_create_reflection_cycle(
conn: &Connection,
source_id: &str,
target_id: &str,
max_depth: u32,
) -> rusqlite::Result<CycleCheckResult> {
would_create_reflection_cycle_with(source_id, target_id, max_depth, &mut |node| {
forward_neighbors(conn, node)
})
}
#[must_use]
pub fn walk_bound(max_depth: u32) -> u32 {
let base = if max_depth == 0 {
DEFAULT_MAX_DEPTH
} else {
max_depth
};
base.saturating_mul(CYCLE_DEPTH_SAFETY_FACTOR)
}
pub fn would_create_reflection_cycle_with<E>(
source_id: &str,
target_id: &str,
max_depth: u32,
neighbors: &mut dyn FnMut(&str) -> Result<Vec<String>, E>,
) -> Result<CycleCheckResult, E> {
if source_id == target_id {
return Ok(CycleCheckResult {
would_cycle: true,
cycle_path: vec![source_id.to_string(), target_id.to_string()],
});
}
let bound = walk_bound(max_depth);
let mut frontier: Vec<String> = vec![target_id.to_string()];
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut predecessor: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
visited.insert(target_id.to_string());
let mut depth = 0u32;
while !frontier.is_empty() {
if depth >= bound {
return Ok(CycleCheckResult {
would_cycle: true,
cycle_path: vec![source_id.to_string(), target_id.to_string()],
});
}
depth += 1;
let mut next_frontier: Vec<String> = Vec::new();
for current in &frontier {
let step = neighbors(current)?;
for neighbor in step {
if neighbor == source_id {
let path = reconstruct_path(source_id, target_id, current, &predecessor);
return Ok(CycleCheckResult {
would_cycle: true,
cycle_path: path,
});
}
if visited.insert(neighbor.clone()) {
predecessor.insert(neighbor.clone(), current.clone());
next_frontier.push(neighbor);
}
}
}
frontier = next_frontier;
}
Ok(CycleCheckResult {
would_cycle: false,
cycle_path: vec![],
})
}
fn forward_neighbors(conn: &Connection, node: &str) -> rusqlite::Result<Vec<String>> {
let mut stmt = conn.prepare_cached(
"SELECT target_id FROM memory_links \
WHERE source_id = ?1 AND relation = 'reflects_on'",
)?;
let rows = stmt.query_map(params![node], |row| row.get(0))?;
rows.collect()
}
fn reconstruct_path(
source_id: &str,
target_id: &str,
found_at: &str,
predecessor: &std::collections::HashMap<String, String>,
) -> Vec<String> {
let mut segment: Vec<String> = vec![found_at.to_string()];
let mut cur = found_at;
while let Some(pred) = predecessor.get(cur) {
segment.push(pred.clone());
cur = pred;
if cur == target_id {
break;
}
}
segment.reverse();
let mut path = Vec::with_capacity(segment.len() + 2);
path.push(source_id.to_string());
path.extend(segment);
path.push(source_id.to_string());
path
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn open_db() -> Connection {
crate::db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
}
fn insert_memory(conn: &Connection, id: &str) {
use crate::models::{Memory, Tier};
use chrono::Utc;
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: id.to_string(),
tier: Tier::Mid,
namespace: "test".to_string(),
title: format!("memory-{id}"),
content: "content".to_string(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": "test-agent"}),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
crate::db::insert(conn, &mem).expect("insert memory");
}
fn add_reflects_on(conn: &Connection, source_id: &str, target_id: &str) {
crate::db::create_link(conn, source_id, target_id, "reflects_on")
.expect("create reflects_on link");
}
fn map_neighbors<'a>(
adjacency: &'a std::collections::HashMap<&'static str, Vec<&'static str>>,
) -> impl FnMut(&str) -> Result<Vec<String>, std::convert::Infallible> + 'a {
move |node: &str| {
Ok(adjacency
.get(node)
.map(|v| v.iter().map(ToString::to_string).collect())
.unwrap_or_default())
}
}
#[test]
fn generic_walker_detects_cycle_over_adjacency_map_1568() {
let adjacency = std::collections::HashMap::from([("a", vec!["b"]), ("b", vec!["c"])]);
let mut neighbors = map_neighbors(&adjacency);
let hit = would_create_reflection_cycle_with("c", "a", 8, &mut neighbors).expect("ok");
assert!(hit.would_cycle, "c→a must close the a→b→c chain");
assert_eq!(hit.cycle_path, vec!["c", "a", "b", "c"]);
let legal = would_create_reflection_cycle_with("x", "a", 8, &mut neighbors).expect("ok");
assert!(!legal.would_cycle);
assert!(legal.cycle_path.is_empty());
}
#[test]
fn generic_walker_fails_closed_on_truncation_1568() {
let adjacency = std::collections::HashMap::from([
("a", vec!["b"]),
("b", vec!["c"]),
("c", vec!["d"]),
("d", vec!["e"]),
("e", vec!["f"]),
("f", vec!["g"]),
]);
let mut neighbors = map_neighbors(&adjacency);
let truncated =
would_create_reflection_cycle_with("g", "a", 2, &mut neighbors).expect("ok");
assert!(
truncated.would_cycle,
"ceiling reached with non-empty frontier must fail CLOSED"
);
let resolved = would_create_reflection_cycle_with("g", "a", 8, &mut neighbors).expect("ok");
assert!(resolved.would_cycle);
let legal = would_create_reflection_cycle_with("zz", "a", 8, &mut neighbors).expect("ok");
assert!(!legal.would_cycle);
}
#[test]
fn no_edges_is_no_cycle() {
let conn = open_db();
insert_memory(&conn, "a");
insert_memory(&conn, "b");
let result = would_create_reflection_cycle(&conn, "a", "b", 8).expect("ok");
assert!(!result.would_cycle);
assert!(result.cycle_path.is_empty());
}
#[test]
fn direct_cycle_detected() {
let conn = open_db();
insert_memory(&conn, "a");
insert_memory(&conn, "b");
add_reflects_on(&conn, "b", "a");
let result = would_create_reflection_cycle(&conn, "a", "b", 8).expect("ok");
assert!(
result.would_cycle,
"direct cycle A→B with B→A must be detected"
);
assert!(!result.cycle_path.is_empty());
assert_eq!(result.cycle_path.first().map(String::as_str), Some("a"));
assert_eq!(result.cycle_path.last().map(String::as_str), Some("a"));
}
#[test]
fn indirect_cycle_detected() {
let conn = open_db();
insert_memory(&conn, "a");
insert_memory(&conn, "b");
insert_memory(&conn, "c");
add_reflects_on(&conn, "a", "b"); add_reflects_on(&conn, "b", "c");
let result = would_create_reflection_cycle(&conn, "c", "a", 8).expect("ok");
assert!(
result.would_cycle,
"indirect cycle C→A with A→B→C must be detected"
);
assert!(!result.cycle_path.is_empty());
assert_eq!(result.cycle_path.first().map(String::as_str), Some("c"));
assert_eq!(result.cycle_path.last().map(String::as_str), Some("c"));
}
#[test]
fn non_cycle_succeeds() {
let conn = open_db();
insert_memory(&conn, "a");
insert_memory(&conn, "b");
insert_memory(&conn, "c");
add_reflects_on(&conn, "a", "b");
let result = would_create_reflection_cycle(&conn, "c", "b", 8).expect("ok");
assert!(
!result.would_cycle,
"C→B with only A→B existing is not a cycle"
);
assert!(result.cycle_path.is_empty());
}
#[test]
fn legal_deep_chain_within_safety_headroom_resolves() {
let conn = open_db();
for id in ["a", "b", "c", "d", "e"] {
insert_memory(&conn, id);
}
add_reflects_on(&conn, "e", "d");
add_reflects_on(&conn, "d", "c");
add_reflects_on(&conn, "c", "b");
add_reflects_on(&conn, "b", "a");
insert_memory(&conn, "x");
let legal = would_create_reflection_cycle(&conn, "x", "a", 1).expect("ok");
assert!(
!legal.would_cycle,
"a chain tail with no outgoing edges must resolve as no-cycle"
);
assert!(legal.cycle_path.is_empty());
}
#[test]
fn depth_bound_fails_closed_on_truncation() {
let conn = open_db();
for id in ["a", "b", "c", "d", "e", "f", "g"] {
insert_memory(&conn, id);
}
add_reflects_on(&conn, "g", "f");
add_reflects_on(&conn, "f", "e");
add_reflects_on(&conn, "e", "d");
add_reflects_on(&conn, "d", "c");
add_reflects_on(&conn, "c", "b");
add_reflects_on(&conn, "b", "a");
let truncated = would_create_reflection_cycle(&conn, "a", "g", 2).expect("ok");
assert!(
truncated.would_cycle,
"ceiling reached with unexplored frontier must fail CLOSED (would_cycle=true)"
);
assert!(
!truncated.cycle_path.is_empty(),
"fail-CLOSED result must carry a non-empty audit path"
);
let resolved = would_create_reflection_cycle(&conn, "a", "g", 6).expect("ok");
assert!(
resolved.would_cycle,
"with adequate depth the real cycle must be detected"
);
assert_eq!(resolved.cycle_path.first().map(String::as_str), Some("a"));
assert_eq!(resolved.cycle_path.last().map(String::as_str), Some("a"));
}
#[test]
fn direct_self_link_returns_cycle_with_two_node_path() {
let conn = open_db();
insert_memory(&conn, "self");
let result = would_create_reflection_cycle(&conn, "self", "self", 8).expect("ok");
assert!(
result.would_cycle,
"direct self-link must be flagged as a cycle"
);
assert_eq!(
result.cycle_path,
vec!["self".to_string(), "self".to_string()]
);
}
#[test]
fn max_depth_zero_falls_back_to_default_bound() {
let conn = open_db();
insert_memory(&conn, "a");
insert_memory(&conn, "b");
add_reflects_on(&conn, "b", "a");
let result = would_create_reflection_cycle(&conn, "a", "b", 0).expect("ok");
assert!(
result.would_cycle,
"max_depth=0 should fall back to DEFAULT_MAX_DEPTH and still detect the cycle"
);
assert!(!result.cycle_path.is_empty());
}
#[test]
fn sql_error_fails_closed_1090() {
let conn = open_db();
insert_memory(&conn, "a");
insert_memory(&conn, "b");
conn.execute("DROP TABLE memory_links", []).expect("drop");
let result = would_create_reflection_cycle(&conn, "a", "b", 8);
assert!(
result.is_err(),
"SQL error during cycle walk must propagate as Err (#1090 fail-CLOSED)"
);
}
}