Shared memory and context tools for agentic work.
Code Rooms
// === m1nd-mcp/src/perspective_handlers.rs ===
// Handlers for the 12 perspective MCP tools.
// Split from server.rs dispatch (Theme 8).
use crate::perspective::confidence::{
build_affinity_candidate, normalize_ghost_edge, normalize_provenance_overlap,
normalize_resonant_amplitude, normalize_route_path_neighborhood, normalize_semantic_overlap,
normalize_structural_hole,
};
use crate::perspective::keys::route_content_id;
use crate::perspective::state::*;
use crate::perspective::validation::*;
use crate::protocol::perspective::*;
use crate::session::SessionState;
use crate::util::now_ms;
use m1nd_core::error::{M1ndError, M1ndResult};
use m1nd_core::types::EdgeIdx;
use std::collections::HashSet;
// ---------------------------------------------------------------------------
// Helpers
fn perspective_not_found_error(tool: &str, agent_id: &str, perspective_id: &str) -> M1ndError {
M1ndError::InvalidParams {
tool: tool.into(),
detail: format!(
"perspective `{}` was not found for agent `{}`. It may have been closed, expired, or belongs to a different agent. Call `perspective_list` to inspect active perspectives, or `perspective_start` to create a new one.",
perspective_id, agent_id
),
}
fn route_set_stale_error(tool: &str, requested_version: u64, current_version: u64) -> M1ndError {
"stale `route_set_version` {}. Current version is {}. Call `perspective_routes` to refresh the route set, then retry this operation with the new `route_set_version`.",
requested_version, current_version
fn route_not_found_error(tool: &str, perspective_id: &str, route_ref: &str) -> M1ndError {
"route reference `{}` was not found in perspective `{}`. Call `perspective_routes` to list the current page of routes, then retry with a fresh `route_id` or 1-based `route_index`.",
route_ref, perspective_id
fn perspective_route_contract(
routes: &[Route],
focus_node: Option<&str>,
perspective_id: &str,
) -> (String, Option<String>, Option<String>, Option<String>) {
if let Some(route) = routes.first() {
return (
"triaging".into(),
Some("perspective_inspect".into()),
Some(route.route_id.clone()),
Some(format!(
"Inspect route {} to validate the next hop from `{}`.",
route.route_id, route.target_label
)),
);
if focus_node.is_some() {
"blocked".into(),
Some("perspective_suggest".into()),
Some(perspective_id.into()),
Some(
"This perspective has no live routes. Ask `perspective_suggest` whether to backtrack or close it."
.into(),
(
Some("seek".into()),
None,
"No focus node was resolved for this perspective. Use `seek` or restart with a stronger anchor."
)
fn perspective_inspect_contract(
route: &Route,
if route.peek_available {
"proving".into(),
Some("perspective_peek".into()),
"Peek route {} to inspect source evidence before moving the focus.",
route.route_id
} else {
Some("perspective_follow".into()),
"Route {} has no peekable source. Follow it to keep the investigation moving.",
fn perspective_suggestion_contract(
suggestion: &SuggestResult,
if let Some(route_id) = suggestion.recommended_action.strip_prefix("follow ") {
Some(route_id.to_string()),
Some(suggestion.why.clone()),
match suggestion.recommended_action.as_str() {
"perspective.back" => (
Some("perspective_back".into()),
"perspective.close" => (
Some("perspective_close".into()),
_ => ("triaging".into(), None, None, Some(suggestion.why.clone())),
fn perspective_affinity_contract(
candidates: &[AffinityCandidate],
if candidates.is_empty() {
"Affinity is under-indexed here. Inspect route {} directly or peek its source before branching wider.",
"Affinity found probable continuations from `{}`. Follow the route or inspect it more deeply first.",
perspective_id
fn route_provenance_overlap(route: &Route, candidate: &Route) -> Option<f32> {
let route_provenance = route.provenance.as_ref()?;
let candidate_provenance = candidate.provenance.as_ref()?;
let route_path = route_provenance.source_path.as_ref()?;
let candidate_path = candidate_provenance.source_path.as_ref()?;
let line_distance = route_provenance
.line_start
.zip(candidate_provenance.line_start)
.map(|(a, b)| a.abs_diff(b));
let overlap = normalize_provenance_overlap(route_path == candidate_path, line_distance);
(overlap > 0.0).then_some(overlap)
fn route_affinity_kind(route: &Route, candidate: &Route) -> AffinityCandidateKind {
if candidate.family == RouteFamily::Ghost {
AffinityCandidateKind::HypothesizedLatentEdge
} else if candidate.family == route.family || candidate.family == RouteFamily::Resonant {
AffinityCandidateKind::ResonantNeighbor
AffinityCandidateKind::MissingBridge
fn synthesize_route_affinity_candidates(
limit: usize,
) -> Vec<AffinityCandidate> {
let sibling_routes: Vec<&Route> = routes
.iter()
.filter(|candidate| candidate.route_id != route.route_id)
.filter(|candidate| candidate.target_node != route.target_node)
.collect();
if sibling_routes.is_empty() {
return Vec::new();
let min_score = sibling_routes
.map(|candidate| candidate.score)
.fold(1.0_f32, f32::min);
let max_score = routes
.fold(0.0_f32, f32::max);
let mut candidates: Vec<AffinityCandidate> = sibling_routes
.into_iter()
.filter_map(|candidate| {
let structural = normalize_structural_hole(candidate.score, min_score);
let resonance = normalize_resonant_amplitude(candidate.score, max_score);
let semantic = if candidate.family == route.family {
0.70
0.45
let breakdown = ConfidenceBreakdown {
ghost_edge_strength: (candidate.family == RouteFamily::Ghost)
.then_some(normalize_ghost_edge(candidate.score)),
structural_hole_pressure: (structural > 0.0).then_some(structural),
resonant_amplitude: (resonance > 0.0).then_some(resonance),
semantic_overlap: Some(normalize_semantic_overlap(semantic)),
provenance_overlap: route_provenance_overlap(route, candidate),
route_path_neighborhood: Some(normalize_route_path_neighborhood(2)),
build_affinity_candidate(
candidate.target_node.clone(),
candidate.target_label.clone(),
route_affinity_kind(route, candidate),
breakdown,
})
candidates.sort_by(|a, b| {
b.confidence
.total_cmp(&a.confidence)
.then_with(|| a.candidate_label.cmp(&b.candidate_label))
.then_with(|| a.candidate_node.cmp(&b.candidate_node))
});
candidates.truncate(limit);
candidates
fn perspective_list_contract(
perspectives: &[PerspectiveSummary],
if let Some(first) = perspectives.first() {
Some("perspective_routes".into()),
Some(first.perspective_id.clone()),
"Resume navigation in `{}` before opening another perspective.",
first.perspective_id
Some("perspective_start".into()),
"No active perspectives are open. Start one with a seed query or anchor node."
/// Check perspective ownership and return reference, or error.
fn require_perspective<'a>(
state: &'a SessionState,
agent_id: &str,
tool: &str,
) -> M1ndResult<&'a PerspectiveState> {
state
.get_perspective(agent_id, perspective_id)
.ok_or_else(|| perspective_not_found_error(tool, agent_id, perspective_id))
/// Check perspective ownership and return mutable reference, or error.
fn require_perspective_mut<'a>(
state: &'a mut SessionState,
) -> M1ndResult<&'a mut PerspectiveState> {
.get_perspective_mut(agent_id, perspective_id)
/// Classify a route family from a CSR edge relation string.
///
/// Honest by construction: an edge relation can only distinguish Semantic /
/// Temporal / Causal from the default Structural bucket. Ghost / Hole / Resonant
/// families are produced by other (latent-edge / structural-hole / resonance)
/// synthesis paths, never by a plain stored relation, so they are intentionally
/// unreachable here.
fn route_family_for_relation(relation: &str) -> RouteFamily {
match relation {
"semantic" | "shared_keyword" => RouteFamily::Semantic,
"temporal" | "next_binding" => RouteFamily::Temporal,
"causal" | "citation_chain" | "cross_cites" => RouteFamily::Causal,
_ => RouteFamily::Structural,
/// Synthesize routes from graph for a focus node.
/// Uses graph's existing activation data to build route candidates.
/// This is a simplified V1 implementation that builds routes from direct graph neighbors.
fn synthesize_routes(
state: &SessionState,
focus_node: &str,
lens: &PerspectiveLens,
visited: &HashSet<String>,
mode_ctx: &ModeContext,
) -> (Vec<Route>, u64) {
let graph = state.graph.read();
let version = now_ms();
// Find the focus node in graph: try external_id match first, then label match
let focus_nid = graph
.id_to_node
.find_map(|(interned, &nid)| {
let ext_id = graph.strings.resolve(*interned);
if ext_id == focus_node {
Some(nid)
None
.or_else(|| {
// Fallback: match by node label (handles anchor_node = short label)
for idx in 0..graph.num_nodes() as usize {
if idx < graph.nodes.label.len() {
let lbl = graph.strings.resolve(graph.nodes.label[idx]);
if lbl == focus_node {
return Some(m1nd_core::types::NodeId::new(idx as u32));
// Final fallback: substring match on external_id (contains)
graph.id_to_node.iter().find_map(|(interned, &nid)| {
if ext_id.contains(focus_node) {
let focus_nid = match focus_nid {
Some(nid) => nid,
None => return (vec![], version),
// Collect neighbor nodes as route candidates
let mut routes = Vec::new();
let mut route_index: u32 = 0;
// Get edges from CSR if finalized
if graph.finalized {
let idx = focus_nid.as_usize();
if idx < graph.num_nodes() as usize {
let start = if idx == 0 {
0
graph.csr.offsets[idx] as usize
let end = graph.csr.offsets[idx + 1] as usize;
for edge_pos in start..end.min(start + lens.top_k as usize) {
if edge_pos >= graph.csr.targets.len() {
break;
let target_nid = graph.csr.targets[edge_pos];
let target_idx = target_nid.as_usize();
if target_idx >= graph.num_nodes() as usize {
continue;
let target_label = graph
.strings
.resolve(graph.nodes.label[target_idx])
.to_string();
let _target_type = format!("{:?}", graph.nodes.node_type[target_idx]);
// Determine route family from the stored edge relation.
// Guard the CSR index so a truncated relations vec can never panic.
let family = graph
.csr
.relations
.get(edge_pos)
.map(|interned| route_family_for_relation(graph.strings.resolve(*interned)))
.unwrap_or(RouteFamily::Structural);
// Honor the lens family filter (empty = all families).
if !lens.route_families.is_empty() && !lens.route_families.contains(&family) {
let route_id = route_content_id(&target_label, &family);
// Compute basic score
let weight: f32 = graph.csr.read_weight(EdgeIdx::new(edge_pos as u32)).get();
let novelty = if visited.contains(&target_label) {
0.0
1.0
let score = (weight * 0.6 + novelty * 0.4).min(1.0);
// Check provenance availability
let provenance_info = graph.resolve_node_provenance(target_nid);
let peek_available =
!provenance_info.is_empty() && provenance_info.source_path.is_some();
let provenance = if provenance_info.is_empty() {
Some(RouteProvenance {
source_path: provenance_info.source_path,
line_start: provenance_info.line_start,
line_end: provenance_info.line_end,
route_index += 1;
routes.push(Route {
route_id,
route_index,
family,
target_node: target_label.clone(),
target_label,
reason: format!("connected to {}", focus_node),
score,
peek_available,
provenance,
// V1.1: Also collect reverse edges (nodes that point TO focus_node)
// This prevents dead ends at hub/sink nodes like Files
if graph.finalized && routes.len() < lens.top_k as usize {
let remaining = lens.top_k as usize - routes.len();
let mut seen_targets: HashSet<String> =
routes.iter().map(|r| r.target_label.clone()).collect();
for src_idx in 0..graph.num_nodes() as usize {
if seen_targets.len() >= remaining + routes.len() {
let src_start = if src_idx == 0 {
graph.csr.offsets[src_idx] as usize
let src_end = graph.csr.offsets[src_idx + 1] as usize;
for edge_pos in src_start..src_end {
let tgt = graph.csr.targets[edge_pos];
if tgt == focus_nid && src_idx != focus_nid.as_usize() {
let src_label = graph
.resolve(graph.nodes.label[src_idx])
if seen_targets.contains(&src_label) {
seen_targets.insert(src_label.clone());
// Derive the route family from the reverse edge's relation
// (guard the CSR index against a truncated relations vec).
let route_id = route_content_id(&src_label, &family);
let novelty = if visited.contains(&src_label) {
let score = (weight * 0.5 + novelty * 0.3).min(1.0); // slightly lower than forward
let src_nid = m1nd_core::types::NodeId::new(src_idx as u32);
let prov_info = graph.resolve_node_provenance(src_nid);
let peek_available = !prov_info.is_empty() && prov_info.source_path.is_some();
let provenance = if prov_info.is_empty() {
source_path: prov_info.source_path,
line_start: prov_info.line_start,
line_end: prov_info.line_end,
target_node: src_label.clone(),
target_label: src_label,
reason: format!("references {}", focus_node),
// Sort by score descending, then deterministic tie-breaking (Theme 4)
routes.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.route_id.cmp(&b.route_id))
.then_with(|| a.target_node.cmp(&b.target_node))
.then_with(|| a.family.ordinal().cmp(&b.family.ordinal()))
// Re-number after sort
for (i, route) in routes.iter_mut().enumerate() {
route.route_index = (i + 1) as u32;
(routes, version)
/// Build a diagnostic for empty results.
fn empty_diagnostic(state: &SessionState, reason: &str, suggestion: &str) -> Diagnostic {
Diagnostic {
sources_checked: vec!["graph_neighbors".into()],
sources_with_results: vec![],
sources_failed: vec![],
reason: reason.into(),
suggestion: suggestion.into(),
graph_stats: DiagnosticGraphStats {
node_count: graph.num_nodes(),
edge_count: graph.num_edges() as u64,
},
// perspective.start
pub fn handle_perspective_start(
state: &mut SessionState,
input: PerspectiveStartInput,
) -> M1ndResult<serde_json::Value> {
// Check limits
let count = state.agent_perspective_count(&input.agent_id);
if count >= state.perspective_limits.max_perspectives_per_agent {
return Err(M1ndError::PerspectiveLimitExceeded {
agent_id: input.agent_id.clone(),
current: count,
limit: state.perspective_limits.max_perspectives_per_agent,
// Memory budget check
let mem = state.perspective_and_lock_memory_bytes();
if mem >= state.perspective_limits.max_total_memory_bytes {
let perspective_id = state.next_perspective_id(&input.agent_id);
let lens = input.lens.unwrap_or_default();
let ts = now_ms();
// Determine mode
let mode = if input.anchor_node.is_some() {
PerspectiveMode::Anchored
PerspectiveMode::Local
// Find focus node: anchor_node if provided, otherwise first activated node from query
let focus_node = input.anchor_node.clone().or_else(|| {
// Try to find a node matching the query
graph.id_to_node.iter().find_map(|(interned, _)| {
let label = graph.strings.resolve(*interned);
if label.contains(&input.query) {
Some(label.to_string())
let mode_ctx = ModeContext {
mode: mode.clone(),
anchor_node: input.anchor_node.clone(),
anchor_query: Some(input.query.clone()),
// Synthesize initial routes
let mut visited = HashSet::new();
if let Some(ref f) = focus_node {
visited.insert(f.clone());
let (routes, version) = if let Some(ref f) = focus_node {
synthesize_routes(state, f, &lens, &visited, &mode_ctx)
(vec![], now_ms())
let total_routes = routes.len();
let page_size = 6u32;
let total_pages = if total_routes == 0 {
1
(total_routes as u32).div_ceil(page_size)
let page_routes: Vec<Route> = routes.iter().take(page_size as usize).cloned().collect();
let suggested = page_routes
.first()
.map(|r| format!("inspect {}", r.route_id));
let (proof_state, next_suggested_tool, next_suggested_target, next_step_hint) =
perspective_route_contract(&page_routes, focus_node.as_deref(), &perspective_id);
// Create perspective state
let persp_state = PerspectiveState {
perspective_id: perspective_id.clone(),
focus_node: focus_node.clone(),
lens: lens.clone(),
entry_path: focus_node.iter().cloned().collect(),
navigation_history: vec![NavigationEvent {
action: "start".into(),
target: focus_node.clone(),
timestamp_ms: ts,
route_set_version: version,
}],
checkpoints: vec![],
visited_nodes: visited,
route_cache: Some(CachedRouteSet {
routes,
total_routes,
page_size,
version,
synthesis_elapsed_ms: 0.0,
captured_cache_generation: state.cache_generation,
}),
stale: false,
created_at_ms: ts,
last_accessed_ms: ts,
branches: vec![],
state.perspectives.insert(
(input.agent_id.clone(), perspective_id.clone()),
persp_state,
let output = PerspectiveStartOutput {
perspective_id,
mode,
anchor_node: input.anchor_node,
focus_node,
routes: page_routes,
page: 1,
total_pages,
cache_generation: state.cache_generation,
suggested,
proof_state,
next_suggested_tool,
next_suggested_target,
next_step_hint,
serde_json::to_value(output).map_err(M1ndError::Serde)
// perspective.routes
pub fn handle_perspective_routes(
input: PerspectiveRoutesInput,
// v0.4.0 FIX (ADVERSARY B1+B3): Re-synthesize routes when cache is None
// instead of returning empty. This fixes the bug where perspective.start
// creates with focus_node=None and routes never computed, or cache was
// invalidated between start and routes calls.
let persp = require_perspective(
state,
&input.agent_id,
&input.perspective_id,
"perspective.routes",
)?;
// Staleness check: instead of erroring, mark as stale and continue
let mut _stale = false;
if let Some(client_version) = input.route_set_version {
if client_version != persp.route_set_version {
_stale = true;
// Continue with current version instead of erroring (ADVERSARY B3 fix)
// FIX: If route_cache is None (invalidated or never computed), re-synthesize
let needs_resynth = persp.route_cache.is_none();
let focus_node = persp.focus_node.clone();
let lens = persp.lens.clone();
let visited = persp.visited_nodes.clone();
mode: persp.mode.clone(),
anchor_node: persp.anchor_node.clone(),
anchor_query: persp.anchor_query.clone(),
if needs_resynth {
if let Some(ref focus) = focus_node {
// Re-synthesize routes from current graph state
let (routes, version) = synthesize_routes(state, focus, &lens, &visited, &mode_ctx);
let cache_gen = state.cache_generation;
if let Some(p) = state.get_perspective_mut(&input.agent_id, &input.perspective_id) {
p.route_cache = Some(CachedRouteSet {
captured_cache_generation: cache_gen,
p.route_set_version = version;
p.stale = false;
// Re-read perspective after potential re-synthesis
// Validate pagination
let cached = persp.route_cache.as_ref();
let total_routes = cached.map_or(0, |c| c.total_routes);
let pagination = validate_pagination(input.page, input.page_size, total_routes)?;
// Get routes page
let routes: Vec<Route> = cached
.map(|c| {
c.routes
.skip(pagination.offset)
.take(pagination.page_size as usize)
.cloned()
.collect()
.unwrap_or_default();
let mode_effective = if persp.mode == PerspectiveMode::Anchored {
let hops = persp.entry_path.len();
if hops > 8 {
"local".into()
"anchored".into()
let diagnostic = if routes.is_empty() && focus_node.is_none() {
Some(empty_diagnostic(
"no_focus_node",
"Perspective has no focus node. Use anchor_node or a more specific query.",
))
} else if routes.is_empty() {
"graph_empty",
"Try a different query or ingest more data",
let suggested = routes.first().map(|r| format!("inspect {}", r.route_id));
let lens_summary = format!(
"dims={} top_k={} xlr={}",
persp.lens.dimensions.len(),
persp.lens.top_k,
persp.lens.xlr,
perspective_route_contract(&routes, persp.focus_node.as_deref(), &input.perspective_id);
let output = PerspectiveRoutesOutput {
perspective_id: input.perspective_id.clone(),
mode_effective,
anchor: persp.anchor_node.clone(),
focus: persp.focus_node.clone(),
lens_summary,
page: pagination.page,
total_pages: pagination.total_pages,
route_set_version: persp.route_set_version,
cache_generation: persp.captured_cache_generation,
diagnostic,
family_diversity_warning: None,
dominant_family: None,
page_size_clamped: pagination.page_size_clamped,
// Update last_accessed
p.last_accessed_ms = now_ms();
// perspective.inspect
pub fn handle_perspective_inspect(
input: PerspectiveInspectInput,
let route_ref = validate_route_ref(&input.route_id, &input.route_index, "perspective.inspect")?;
"perspective.inspect",
// Staleness check
if input.route_set_version != persp.route_set_version {
return Err(route_set_stale_error(
input.route_set_version,
persp.route_set_version,
));
// Find the route
let cached = persp.route_cache.as_ref().ok_or_else(|| {
route_not_found_error(
"no cached routes",
})?;
let route = match route_ref {
ValidatedRouteRef::ById(ref id) => cached.routes.iter().find(|r| &r.route_id == id),
ValidatedRouteRef::ByIndex(idx) => cached.routes.iter().find(|r| r.route_index == idx),
.ok_or_else(|| {
&match &route_ref {
ValidatedRouteRef::ById(id) => id.clone(),
ValidatedRouteRef::ByIndex(idx) => format!("route_index={}", idx),
let provenance = route.provenance.as_ref().map(|p| InspectProvenance {
source_path: p.source_path.clone(),
line_start: p.line_start,
line_end: p.line_end,
namespace: None,
provenance_stale: false,
perspective_inspect_contract(route);
let output = PerspectiveInspectOutput {
route_id: route.route_id.clone(),
route_index: route.route_index,
family: route.family.clone(),
target_node: route.target_node.clone(),
target_label: route.target_label.clone(),
target_type: "module".into(),
path_preview: persp
.entry_path
.chain(std::iter::once(&route.target_node))
.collect(),
family_explanation: format!("{:?} connection", route.family),
score: route.score,
score_breakdown: InspectScoreBreakdown {
local_activation: route.score * 0.6,
path_coherence: route.score * 0.25,
novelty: if persp.visited_nodes.contains(&route.target_node) {
anchor_relevance: if persp.mode == PerspectiveMode::Anchored {
Some(0.15)
continuity: if persp.mode == PerspectiveMode::Anchored {
Some(0.10)
peek_available: route.peek_available,
affinity_candidates: vec![],
response_chars: 0, // filled after serialization
// perspective.peek
pub fn handle_perspective_peek(
input: PerspectivePeekInput,
let route_ref = validate_route_ref(&input.route_id, &input.route_index, "perspective.peek")?;
"perspective.peek",
if !route.peek_available {
return Err(M1ndError::InvalidParams {
tool: "perspective.peek".into(),
detail: format!("peek not available for route {}", route.route_id),
// Get source path from provenance
let source_path = route
.provenance
.as_ref()
.and_then(|p| p.source_path.as_ref())
.ok_or_else(|| M1ndError::InvalidParams {
detail: "no source path in provenance".into(),
let line_hint = route.provenance.as_ref().and_then(|p| p.line_start);
// Run security pipeline (Theme 6)
let content = crate::perspective::peek_security::secure_peek(
source_path,
&state.peek_security,
line_hint,
let next_suggested_tool = Some("perspective_follow".into());
let next_suggested_target = Some(route.route_id.clone());
let next_step_hint = Some(format!(
"If this snippet confirms the route, follow {} to move focus to `{}`.",
let output = PerspectivePeekOutput {
content,
proof_state: "proving".into(),
// perspective.follow
pub fn handle_perspective_follow(
input: PerspectiveFollowInput,
let route_ref = validate_route_ref(&input.route_id, &input.route_index, "perspective.follow")?;
// First borrow: read-only to validate and extract needed data
let (target_node, previous_focus, mode, lens, visited, mode_ctx, version_check) = {
"perspective.follow",
let route = match &route_ref {
ValidatedRouteRef::ById(id) => cached.routes.iter().find(|r| &r.route_id == id),
ValidatedRouteRef::ByIndex(idx) => cached.routes.iter().find(|r| r.route_index == *idx),
route.target_node.clone(),
persp.focus_node.clone(),
persp.mode.clone(),
persp.lens.clone(),
persp.visited_nodes.clone(),
ModeContext {
// Synthesize new routes at the target
let mut new_visited = visited;
new_visited.insert(target_node.clone());
let (routes, new_version) =
synthesize_routes(state, &target_node, &lens, &new_visited, &mode_ctx);
let diagnostic = if routes.is_empty() {
"dead_end",
"Try perspective.back or start a new perspective",
if let Some(route) = page_routes.first() {
"Inspect route {} to decide the next move from `{}`.",
route.route_id, target_node
Some(input.perspective_id.clone()),
Some("This follow reached a dead end. Go back or start a sibling branch.".into()),
let mode_effective = if mode == PerspectiveMode::Anchored {
// Now mutate the perspective state
let max_checkpoints = state.perspective_limits.max_checkpoints_per_perspective;
let persp = require_perspective_mut(
// Save checkpoint before moving
persp.checkpoints.push(PerspectiveCheckpoint {
focus_node: persp.focus_node.clone(),
lens: persp.lens.clone(),
route_set_version: version_check,
// Enforce checkpoint limit (Theme 5)
while persp.checkpoints.len() > max_checkpoints {
persp.checkpoints.remove(0);
persp.focus_node = Some(target_node.clone());
persp.entry_path.push(target_node.clone());
persp.visited_nodes = new_visited;
persp.navigation_history.push(NavigationEvent {
action: "follow".into(),
target: Some(target_node.clone()),
route_set_version: new_version,
persp.route_cache = Some(CachedRouteSet {
version: new_version,
persp.route_set_version = new_version;
persp.captured_cache_generation = cache_gen;
persp.last_accessed_ms = ts;
let output = PerspectiveFollowOutput {
perspective_id: input.perspective_id,
previous_focus,
new_focus: target_node,
// perspective.suggest
pub fn handle_perspective_suggest(
input: PerspectiveSuggestInput,
"perspective.suggest",
let top_route = cached.and_then(|c| c.routes.first());
let suggestion = if let Some(route) = top_route {
// Has routes: suggest following the highest-scored unvisited route
let unvisited = cached.and_then(|c| {
.find(|r| !persp.visited_nodes.contains(&r.target_node))
let best = unvisited.unwrap_or(route);
SuggestResult {
recommended_action: format!("follow {}", best.route_id),
confidence: best.score.min(0.85),
why: format!(
"Highest-scored {} route to {}",
format!("{:?}", best.family).to_lowercase(),
best.target_label
based_on: if persp.navigation_history.len() > 1 {
"navigation_history".into()
"initial_ranking".into()
alternatives: cached
.filter(|r| r.route_id != best.route_id)
.take(3)
.map(|r| SuggestAlternative {
action: format!("follow {}", r.route_id),
confidence: r.score.min(0.85),
why: format!("{:?} route to {}", r.family, r.target_label),
.unwrap_or_default(),
// Cold start or dead end
recommended_action: "perspective.back".into(),
confidence: 0.50,
why: "No routes available at current focus".into(),
based_on: "exhaustion_recovery".into(),
alternatives: vec![SuggestAlternative {
action: "perspective.close".into(),
confidence: 0.30,
why: "Start fresh with a new perspective".into(),
let diagnostic = if top_route.is_none() {
"Navigate back or start a new perspective",
perspective_suggestion_contract(&input.perspective_id, &suggestion);
let output = PerspectiveSuggestOutput {
suggestion,
// perspective.affinity
pub fn handle_perspective_affinity(
input: PerspectiveAffinityInput,
let route_ref =
validate_route_ref(&input.route_id, &input.route_index, "perspective.affinity")?;
"perspective.affinity",
let candidates = synthesize_route_affinity_candidates(
route,
&cached.routes,
state.perspective_limits.max_affinity_candidates,
perspective_affinity_contract(&input.perspective_id, route, &candidates);
let diagnostic = if candidates.is_empty() {
"under_indexed",
"Affinity requires more graph data for meaningful results",
let output = PerspectiveAffinityOutput {
notice: "Probable connections, not verified edges.".into(),
candidates,
// perspective.branch
pub fn handle_perspective_branch(
input: PerspectiveBranchInput,
// Check branch limit
"perspective.branch",
if persp.branches.len() >= state.perspective_limits.max_branches_per_agent {
return Err(M1ndError::BranchDepthExceeded {
depth: persp.branches.len(),
limit: state.perspective_limits.max_branches_per_agent,
// Must have at least 1 navigation event
if persp.navigation_history.is_empty() {
tool: "perspective.branch".into(),
detail: "cannot branch at root — navigate first".into(),
let focus = persp.focus_node.clone();
let branch_count = persp.branches.len();
let branch_name = input
.branch_name
.unwrap_or_else(|| format!("branch_{}", branch_count + 1));
let cloned_persp = persp.clone();
// Clone current perspective into a new one
let new_id = state.next_perspective_id(&input.agent_id);
let mut new_persp = cloned_persp;
new_persp.perspective_id = new_id.clone();
new_persp.created_at_ms = now_ms();
new_persp.last_accessed_ms = now_ms();
// Record branch in parent
let parent = require_perspective_mut(
parent.branches.push(new_id.clone());
// Insert new perspective
.perspectives
.insert((input.agent_id.clone(), new_id.clone()), new_persp);
let output = PerspectiveBranchOutput {
branch_perspective_id: new_id.clone(),
branch_name,
branched_from_focus: focus,
proof_state: "triaging".into(),
next_suggested_tool: Some("perspective_routes".into()),
next_suggested_target: Some(new_id.clone()),
next_step_hint: Some(
"Open the new branch's routes to continue from the forked state.".into(),
// perspective.back
pub fn handle_perspective_back(
input: PerspectiveBackInput,
// Validate and extract data in a scoped borrow
let (checkpoint, had_checkpoints) = {
"perspective.back",
if persp.checkpoints.is_empty() {
return Err(M1ndError::NavigationAtRoot {
(persp.checkpoints.last().cloned(), true)
let checkpoint = checkpoint.unwrap();
// Now mutate
persp.checkpoints.pop();
persp.focus_node = checkpoint.focus_node.clone();
persp.lens = checkpoint.lens.clone();
persp.mode = checkpoint.mode.clone();
if let Some(ref f) = persp.focus_node {
if let Some(pos) = persp.entry_path.iter().rposition(|n| n == f) {
persp.entry_path.truncate(pos + 1);
action: "back".into(),
target: checkpoint.focus_node.clone(),
// Re-synthesize routes at restored focus
let restored_focus = persp.focus_node.clone();
let restored_mode = persp.mode.clone();
let (routes, version) = if let Some(ref f) = restored_focus {
// Update cache
let perspective_id = input.perspective_id;
let back_next_tool = if let Some(route) = page_routes.first() {
Some("perspective_inspect".into())
Some("perspective_suggest".into())
let back_next_target = if let Some(route) = page_routes.first() {
Some(route.route_id.clone())
Some(perspective_id.clone())
let back_next_hint = if let Some(route) = page_routes.first() {
"Inspect route {} after backtracking to re-enter the route set cleanly.",
Some("This checkpoint also has no routes. Ask `perspective_suggest` how to recover.".into())
let output = PerspectiveBackOutput {
restored_focus,
restored_mode,
proof_state: if total_routes == 0 {
"blocked".into()
"triaging".into()
next_suggested_tool: back_next_tool,
next_suggested_target: back_next_target,
next_step_hint: back_next_hint,
// perspective.compare
pub fn handle_perspective_compare(
input: PerspectiveCompareInput,
let persp_a = require_perspective(
&input.perspective_id_a,
"perspective.compare",
let persp_b = require_perspective(
&input.perspective_id_b,
let visited_a: HashSet<&String> = persp_a.visited_nodes.iter().collect();
let visited_b: HashSet<&String> = persp_b.visited_nodes.iter().collect();
let shared: Vec<String> = visited_a
.intersection(&visited_b)
.map(|s| (*s).clone())
let unique_a: Vec<String> = visited_a
.difference(&visited_b)
let unique_b: Vec<String> = visited_b
.difference(&visited_a)
let gen_warning = if persp_a.captured_cache_generation != persp_b.captured_cache_generation {
"Generation mismatch: {} vs {}. Results may not be directly comparable.",
persp_a.captured_cache_generation, persp_b.captured_cache_generation
let output = PerspectiveCompareOutput {
perspective_id_a: input.perspective_id_a,
perspective_id_b: input.perspective_id_b,
shared_nodes: shared,
unique_to_a: unique_a,
unique_to_b: unique_b,
dimension_deltas: vec![], // V1: requires engine_ops for dimension scoring
response_chars: 0,
generation_mismatch_warning: gen_warning,
next_suggested_target: Some(persp_a.perspective_id.clone()),
"Re-open one of the compared perspectives and inspect the route set where the delta looks most promising."
// perspective.list
pub fn handle_perspective_list(
input: PerspectiveListInput,
let perspectives: Vec<PerspectiveSummary> = state
.filter(|((a, _), _)| a == &input.agent_id)
.map(|((_, _), p)| PerspectiveSummary {
perspective_id: p.perspective_id.clone(),
mode: p.mode.clone(),
focus_node: p.focus_node.clone(),
route_count: p.route_cache.as_ref().map_or(0, |c| c.total_routes),
nav_event_count: p.navigation_history.len(),
stale: p.stale,
created_at_ms: p.created_at_ms,
last_accessed_ms: p.last_accessed_ms,
let total_memory = state.perspective_and_lock_memory_bytes();
perspective_list_contract(&perspectives);
let output = PerspectiveListOutput {
agent_id: input.agent_id,
perspectives,
total_memory_bytes: total_memory,
// perspective.close
pub fn handle_perspective_close(
input: PerspectiveCloseInput,
// Check it exists
let _ = require_perspective(
"perspective.close",
// Find and release associated locks
let agent_locks: Vec<String> = state
.locks
.values()
.filter(|l| l.agent_id == input.agent_id)
.map(|l| l.lock_id.clone())
// Remove the perspective
.remove(&(input.agent_id.clone(), input.perspective_id.clone()));
// Remove associated locks (cascade cleanup, Theme 5)
let mut released = Vec::new();
for lock_id in &agent_locks {
// Only release if no other perspectives from this agent reference it
// V1: release all agent locks on close (simplified)
if state.agent_perspective_count(&input.agent_id) == 0 {
state.locks.remove(lock_id);
released.push(lock_id.clone());
let output = PerspectiveCloseOutput {
closed: true,
locks_released: released,
proof_state: "ready_to_edit".into(),
next_suggested_tool: Some("perspective_list".into()),
next_suggested_target: None,
"List active perspectives to continue an existing trail, or start a new one if this investigation is finished."
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn perspective_not_found_error_teaches_recovery() {
let err = perspective_not_found_error("perspective.peek", "agent-1", "persp-9").to_string();
assert!(err.contains("perspective `persp-9` was not found"));
assert!(err.contains("perspective_list"));
assert!(err.contains("perspective_start"));
fn route_set_stale_error_teaches_refresh_flow() {
let err = route_set_stale_error("perspective.follow", 7, 11).to_string();
assert!(err.contains("stale `route_set_version` 7"));
assert!(err.contains("Current version is 11"));
assert!(err.contains("perspective_routes"));
assert!(err.contains("retry this operation"));
fn route_not_found_error_teaches_route_discovery() {
let err =
route_not_found_error("perspective.inspect", "persp-2", "route_index=4").to_string();
assert!(err.contains("route reference `route_index=4` was not found"));
assert!(err.contains("fresh `route_id` or 1-based `route_index`"));
fn perspective_route_contract_prefers_inspect_when_routes_exist() {
let route = Route {
route_id: "R01".into(),
route_index: 1,
family: RouteFamily::Structural,
target_node: "file::src/lib.rs".into(),
target_label: "src/lib.rs".into(),
reason: "hot edge".into(),
score: 0.82,
peek_available: true,
provenance: None,
let (proof_state, tool, target, hint) =
perspective_route_contract(&[route], Some("file::src/main.rs"), "persp-1");
assert_eq!(proof_state, "triaging");
assert_eq!(tool.as_deref(), Some("perspective_inspect"));
assert_eq!(target.as_deref(), Some("R01"));
assert!(hint.unwrap().contains("Inspect route R01"));
fn perspective_suggestion_contract_maps_back_recovery() {
let suggestion = SuggestResult {
confidence: 0.5,
why: "No routes available".into(),
alternatives: vec![],
perspective_suggestion_contract("persp-7", &suggestion);
assert_eq!(proof_state, "blocked");
assert_eq!(tool.as_deref(), Some("perspective_back"));
assert_eq!(target.as_deref(), Some("persp-7"));
assert_eq!(hint.as_deref(), Some("No routes available"));
// --- Route-family derivation over a real mixed-relation graph -----------
use m1nd_core::domain::DomainConfig;
use m1nd_core::graph::Graph;
use m1nd_core::types::{EdgeDirection, FiniteF32, NodeType};
/// Build a finalized graph whose focus node has four outgoing edges, one per
/// relation class: `semantic`, `calls` (structural), `temporal`, `causal`.
/// The focus external_id is `file::focus` so route synthesis resolves it by
/// exact external_id match.
fn build_mixed_relation_graph() -> Graph {
let mut g = Graph::new();
let focus = g
.add_node("file::focus", "focus", NodeType::Function, &[], 0.0, 0.0)
.unwrap();
let sem = g
.add_node("file::sem", "sem_target", NodeType::Function, &[], 0.0, 0.0)
let call = g
.add_node(
"file::call",
"call_target",
NodeType::Function,
&[],
0.0,
let temp = g
"file::temp",
"temp_target",
let caus = g
"file::caus",
"caus_target",
g.add_edge(
focus,
sem,
"semantic",
FiniteF32::new(0.9),
EdgeDirection::Forward,
false,
FiniteF32::ZERO,
call,
"calls",
temp,
"temporal",
caus,
"causal",
g.finalize().unwrap();
g
/// Wrap a finalized graph in a `SessionState` backed by a tempdir so the
/// runtime never touches real m1nd artifacts.
fn session_for(graph: Graph) -> (SessionState, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
let config = crate::server::McpConfig {
graph_source: temp_dir.path().join("graph_snapshot.json"),
plasticity_state: temp_dir.path().join("plasticity_state.json"),
runtime_dir: Some(temp_dir.path().to_path_buf()),
..crate::server::McpConfig::default()
let state = SessionState::initialize(graph, &config, DomainConfig::code()).unwrap();
(state, temp_dir)
fn find_route<'a>(routes: &'a [Route], label: &str) -> &'a Route {
routes
.find(|r| r.target_label == label)
.unwrap_or_else(|| panic!("route for target `{label}` not found in {routes:?}"))
fn synthesize_routes_derives_route_family_from_relation() {
let (state, _tmp) = session_for(build_mixed_relation_graph());
let lens = PerspectiveLens::default();
let visited: HashSet<String> = HashSet::new();
mode: PerspectiveMode::Local,
anchor_node: None,
anchor_query: None,
let (routes, _version) =
synthesize_routes(&state, "file::focus", &lens, &visited, &mode_ctx);
assert_eq!(
find_route(&routes, "sem_target").family,
RouteFamily::Semantic,
"the `semantic` edge must yield a Semantic-family route"
find_route(&routes, "call_target").family,
RouteFamily::Structural,
"the `calls` edge must yield a Structural-family route"
find_route(&routes, "temp_target").family,
RouteFamily::Temporal,
"the `temporal` edge must yield a Temporal-family route"
find_route(&routes, "caus_target").family,
RouteFamily::Causal,
"the `causal` edge must yield a Causal-family route"
fn synthesize_routes_respects_lens_route_families() {
let lens = PerspectiveLens {
route_families: vec![RouteFamily::Semantic],
..PerspectiveLens::default()
assert!(
!routes.is_empty(),
"the Semantic route must survive the lens filter"
routes.iter().all(|r| r.family == RouteFamily::Semantic),
"a lens.route_families = [Semantic] filter must drop non-Semantic routes, got {routes:?}"
routes.iter().any(|r| r.target_label == "sem_target"),
"the `semantic`-edge route must be the one that survives"
fn route_family_for_relation_maps_known_relations() {
// Semantic bucket.
assert_eq!(route_family_for_relation("semantic"), RouteFamily::Semantic);
route_family_for_relation("shared_keyword"),
RouteFamily::Semantic
// Temporal bucket.
assert_eq!(route_family_for_relation("temporal"), RouteFamily::Temporal);
route_family_for_relation("next_binding"),
RouteFamily::Temporal
// Causal bucket.
assert_eq!(route_family_for_relation("causal"), RouteFamily::Causal);
route_family_for_relation("citation_chain"),
RouteFamily::Causal
route_family_for_relation("cross_cites"),
// Everything code/structural or unknown falls back to Structural.
for rel in [
"imports",
"references",
"contains",
"cites",
"implements",
"same_as",
"depends_on",
"uses",
"extends",
"authored_by",
"totally_unknown_relation",
"",
] {
route_family_for_relation(rel),
"relation `{rel}` must map to Structural"