Shared memory and context tools for agentic work.
Code Rooms
// === crates/m1nd-core/src/counterfactual.rs ===
use crate::activation::{ActivationEngine, DimensionResult, HybridEngine};
use crate::error::M1ndResult;
use crate::graph::Graph;
use crate::types::PropagationConfig;
use crate::types::*;
// ---------------------------------------------------------------------------
// Constants
/// Default number of diverse seed-set trials per counterfactual.
pub const DEFAULT_SEED_TRIALS: u8 = 8;
/// Default top_n for keystone analysis.
pub const DEFAULT_KEYSTONE_TOP_N: usize = 20;
// RemovalMask — bitset for virtual node removal (FM-CF-004 fix)
// Replaces: counterfactual.py _clone_graph() (was O(V+E) per removal)
// Now O(1) allocation — just flip bits.
/// Bitset mask marking nodes/edges as removed.
/// Zero-allocation counterfactual: no graph clone needed.
/// FM-CF-004 fix: bitset instead of full graph clone.
pub struct RemovalMask {
/// Bit per node: true = removed.
pub removed_nodes: Vec<bool>,
/// Bit per edge: true = removed (edges incident on removed nodes).
pub removed_edges: Vec<bool>,
}
impl RemovalMask {
/// Create empty mask for a graph.
pub fn new(num_nodes: u32, num_edges: usize) -> Self {
Self {
removed_nodes: vec![false; num_nodes as usize],
removed_edges: vec![false; num_edges],
/// Mark a node and all its incident edges as removed.
pub fn remove_node(&mut self, graph: &Graph, node: NodeId) {
let idx = node.as_usize();
if idx >= self.removed_nodes.len() {
return;
self.removed_nodes[idx] = true;
// Mark outgoing edges
let out_range = graph.csr.out_range(node);
for j in out_range {
if j < self.removed_edges.len() {
self.removed_edges[j] = true;
// Mark incoming edges
let in_range = graph.csr.in_range(node);
for j in in_range {
let fwd_idx = graph.csr.rev_edge_idx[j].as_usize();
if fwd_idx < self.removed_edges.len() {
self.removed_edges[fwd_idx] = true;
/// Mark a specific edge as removed.
pub fn remove_edge(&mut self, edge: EdgeIdx) {
self.removed_edges[edge.as_usize()] = true;
/// Check if a node is removed.
#[inline]
pub fn is_node_removed(&self, node: NodeId) -> bool {
self.removed_nodes[node.as_usize()]
/// Check if an edge is removed.
pub fn is_edge_removed(&self, edge: EdgeIdx) -> bool {
self.removed_edges[edge.as_usize()]
/// Reset all removals.
pub fn reset(&mut self) {
self.removed_nodes.fill(false);
self.removed_edges.fill(false);
// CounterfactualResult — output of single node removal
// Replaces: counterfactual.py NodeRemovalSimulator.simulate() return
/// Impact of removing one or more nodes.
#[derive(Clone, Debug)]
pub struct CounterfactualResult {
pub removed_nodes: Vec<NodeId>,
/// Total impact score: fraction of activation lost.
pub total_impact: FiniteF32,
/// Percentage of total activation lost.
pub pct_activation_lost: FiniteF32,
/// Nodes that become completely unreachable after removal.
pub orphaned_nodes: Vec<NodeId>,
/// Nodes that lost >50% of their activation.
pub weakened_nodes: Vec<(NodeId, FiniteF32)>, // (node, pct_lost)
/// Number of communities split by the removal.
pub communities_split: u32,
/// Graph reachability before removal.
pub reachability_before: u32,
/// Graph reachability after removal.
pub reachability_after: u32,
// KeystoneResult — top nodes by counterfactual impact
// Replaces: counterfactual.py CounterfactualSimulator.find_keystones() return
/// Keystone node analysis result.
pub struct KeystoneEntry {
pub node: NodeId,
/// Average impact across seed trials.
/// FM-CF-010 fix: denominator is n_runs (not per-node count).
pub avg_impact: FiniteF32,
/// Standard deviation of impact across trials.
pub impact_std: FiniteF32,
/// Keystone analysis output.
pub struct KeystoneResult {
/// Top keystones sorted by avg_impact descending.
pub keystones: Vec<KeystoneEntry>,
/// Number of seed trials used.
pub num_trials: u8,
// CascadeResult — cascade analysis after removal
// Replaces: counterfactual.py CascadeAnalyzer.analyze()
/// Cascade analysis: what happens downstream after a node is removed.
pub struct CascadeResult {
pub removed_node: NodeId,
/// Cascade depth (how many hops the effect propagates).
pub cascade_depth: u8,
/// Nodes affected at each depth level.
pub affected_by_depth: Vec<Vec<NodeId>>,
/// Total nodes affected.
pub total_affected: u32,
// SynergyResult — multi-node removal synergy analysis
// Replaces: counterfactual.py WhatIfSimulator.simulate() synergy output
/// Synergy analysis for multi-node removal.
pub struct SynergyResult {
/// Individual impact of each removed node.
pub individual_impacts: Vec<(NodeId, FiniteF32)>,
/// Combined impact of removing all nodes together.
pub combined_impact: FiniteF32,
/// Synergy factor: combined / sum(individual). >1.0 = synergistic fragility.
pub synergy_factor: FiniteF32,
// RedundancyResult — how replaceable is a node?
// Replaces: counterfactual.py CounterfactualSimulator.check_redundancy()
// FM-CF-016 fix: confidence levels + architectural node protection.
/// Redundancy analysis for a single node.
pub struct RedundancyResult {
/// Redundancy score [0, 1]: 1.0 = fully redundant, 0.0 = irreplaceable.
pub redundancy_score: FiniteF32,
/// Confidence level of the redundancy assessment.
pub confidence: RedundancyConfidence,
/// Alternative paths that bypass this node.
pub alternative_paths: u32,
/// Whether this is an architectural node (FM-CF-016: protected from deletion advice).
pub is_architectural: bool,
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RedundancyConfidence {
High,
Medium,
Low,
// AntifragilityResult — combined keystone + redundancy
// Replaces: counterfactual.py CounterfactualSimulator.antifragility_score()
/// Antifragility score for the graph or a subgraph.
pub struct AntifragilityResult {
/// Overall antifragility score [0, 1].
pub score: FiniteF32,
/// Top keystones (most fragile points).
pub top_keystones: Vec<KeystoneEntry>,
/// Most redundant nodes.
pub most_redundant: Vec<RedundancyResult>,
/// Least redundant nodes (most irreplaceable).
pub least_redundant: Vec<RedundancyResult>,
// Helper: run activation with removal mask
fn run_baseline_activation(
graph: &Graph,
engine: &HybridEngine,
config: &PropagationConfig,
seeds: &[(NodeId, FiniteF32)],
) -> M1ndResult<Vec<(NodeId, FiniteF32)>> {
let result = engine.propagate(graph, seeds, config)?;
Ok(result.scores)
/// Propagate with removal mask. Skips removed nodes and edges during traversal.
/// This gives accurate counterfactual results — signal cannot flow through removed nodes.
fn propagate_with_mask(
mask: &RemovalMask,
let n = graph.num_nodes() as usize;
if n == 0 || seeds.is_empty() {
return Ok(Vec::new());
let threshold = config.threshold.get();
let decay = config.decay.get();
let max_depth = config.max_depth.min(20) as usize;
let mut activation = vec![0.0f32; n];
let mut visited = vec![false; n];
let mut frontier: Vec<NodeId> = Vec::new();
for &(node, score) in seeds {
if idx < n && !mask.is_node_removed(node) {
let s = score.get().min(config.saturation_cap.get());
if s > activation[idx] {
activation[idx] = s;
if !visited[idx] {
frontier.push(node);
visited[idx] = true;
for _depth in 0..max_depth {
if frontier.is_empty() {
break;
let mut next_frontier: Vec<NodeId> = Vec::new();
for &src in &frontier {
let src_act = activation[src.as_usize()];
if src_act < threshold {
continue;
let range = graph.csr.out_range(src);
for j in range {
// Skip removed edges
if mask.is_edge_removed(EdgeIdx::new(j as u32)) {
let tgt = graph.csr.targets[j];
let tgt_idx = tgt.as_usize();
// Skip removed nodes
if tgt_idx >= n || mask.is_node_removed(tgt) {
let w = graph.csr.read_weight(EdgeIdx::new(j as u32)).get();
let is_inhib = graph.csr.inhibitory[j];
let mut signal = src_act * w * decay;
if is_inhib {
signal = -signal * config.inhibitory_factor.get();
if !is_inhib && signal > threshold {
if signal > activation[tgt_idx] {
activation[tgt_idx] = signal;
if !visited[tgt_idx] {
visited[tgt_idx] = true;
next_frontier.push(tgt);
} else if is_inhib {
activation[tgt_idx] = (activation[tgt_idx] + signal).max(0.0);
frontier = next_frontier;
let mut scores: Vec<(NodeId, FiniteF32)> = activation
.iter()
.enumerate()
.filter(|(i, &v)| v > 0.0 && !mask.is_node_removed(NodeId::new(*i as u32)))
.map(|(i, &v)| (NodeId::new(i as u32), FiniteF32::new(v)))
.collect();
scores.sort_by_key(|entry| std::cmp::Reverse(entry.1));
Ok(scores)
/// Compute total activation from a score vector.
fn total_activation(scores: &[(NodeId, FiniteF32)]) -> f32 {
scores.iter().map(|(_, s)| s.get()).sum()
/// Generate diverse seed sets for trials using PageRank-stratified selection.
/// Avoids isolated/leaf nodes that produce degenerate baselines.
fn generate_diverse_seeds(graph: &Graph, num_trials: u8) -> Vec<Vec<(NodeId, FiniteF32)>> {
if n == 0 {
return Vec::new();
// Collect nodes with nonzero out-degree, sorted by PageRank descending
let mut candidates: Vec<(usize, f32)> = (0..n)
.filter(|&i| {
let r = graph.csr.out_range(NodeId::new(i as u32));
r.end > r.start // has outgoing edges
})
.map(|i| (i, graph.nodes.pagerank[i].get()))
candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
if candidates.is_empty() {
// Stride through candidates to get diverse, high-PageRank seeds
let mut trials = Vec::new();
let stride = candidates.len().max(1) / (num_trials as usize).max(1);
for t in 0..num_trials as usize {
let idx = (t * stride.max(1)) % candidates.len();
let (node_idx, _) = candidates[idx];
trials.push(vec![(NodeId::new(node_idx as u32), FiniteF32::ONE)]);
trials
// CounterfactualEngine — facade
// Replaces: counterfactual.py CounterfactualSimulator
/// Counterfactual analysis engine. Uses bitset-based removal (FM-CF-004 fix).
/// Replaces: counterfactual.py CounterfactualSimulator
pub struct CounterfactualEngine {
num_trials: u8,
keystone_top_n: usize,
impl CounterfactualEngine {
pub fn new(num_trials: u8, keystone_top_n: usize) -> Self {
num_trials,
keystone_top_n,
pub fn with_defaults() -> Self {
num_trials: DEFAULT_SEED_TRIALS,
keystone_top_n: DEFAULT_KEYSTONE_TOP_N,
/// Simulate removal of one or more nodes.
/// FM-CF-001 fix: if a seed node is in the removal set, replace it instead of dropping.
/// FM-CF-010 fix: aggregation divides by n_runs, not per-node count.
/// Uses RemovalMask for accurate propagation — signal cannot flow through removed nodes.
/// Replaces: counterfactual.py NodeRemovalSimulator.simulate()
pub fn simulate_removal(
&self,
remove_nodes: &[NodeId],
) -> M1ndResult<CounterfactualResult> {
// Generate seed trials
let seed_trials = generate_diverse_seeds(graph, self.num_trials);
let mut total_baseline = 0.0f32;
let mut total_removed = 0.0f32;
// Build removal mask (marks nodes AND their incident edges)
let mut mask = RemovalMask::new(graph.num_nodes(), graph.num_edges());
let mut removed_set = vec![false; n];
for &node in remove_nodes {
if node.as_usize() < n {
removed_set[node.as_usize()] = true;
mask.remove_node(graph, node);
let mut per_node_loss = vec![0.0f32; n];
for seeds in &seed_trials {
// FM-CF-001: replace seeds that are in removal set
let adjusted_seeds: Vec<(NodeId, FiniteF32)> = seeds
.map(|&(node, score)| {
if removed_set[node.as_usize()] {
// Find replacement: nearest non-removed neighbor (forward)
let range = graph.csr.out_range(node);
if !removed_set[tgt.as_usize()] {
return (tgt, score);
// Also check reverse neighbors (incoming edges)
let rev_range = graph.csr.in_range(node);
for j in rev_range {
let src = graph.csr.rev_sources[j];
if !removed_set[src.as_usize()] {
return (src, score);
// Last resort: pick any non-removed node
for (i, &removed) in removed_set.iter().enumerate().take(n) {
if !removed {
return (NodeId::new(i as u32), score);
(node, FiniteF32::ZERO) // All nodes removed (impossible in practice)
} else {
(node, score)
.filter(|(_, s)| s.get() > 0.0)
// Baseline activation (full graph)
let baseline = run_baseline_activation(graph, engine, config, seeds)?;
let baseline_total = total_activation(&baseline);
total_baseline += baseline_total;
// Masked propagation: signal cannot flow through removed nodes/edges
let removed_scores = propagate_with_mask(graph, &adjusted_seeds, config, &mask)?;
let removed_total = total_activation(&removed_scores);
total_removed += removed_total;
// Per-node loss tracking
let mut baseline_map = std::collections::HashMap::new();
for &(node, score) in &baseline {
baseline_map.insert(node.0, score.get());
let mut removed_map = std::collections::HashMap::new();
for &(node, score) in &removed_scores {
removed_map.insert(node.0, score.get());
for (i, loss) in per_node_loss.iter_mut().enumerate().take(n) {
let base = baseline_map.get(&(i as u32)).copied().unwrap_or(0.0);
let rem = removed_map.get(&(i as u32)).copied().unwrap_or(0.0);
if base > 0.0 {
*loss += (base - rem) / base;
let num_trials = seed_trials.len().max(1) as f32;
// FM-CF-010 fix: denominator is n_runs
let pct_lost = if total_baseline > 0.0 {
((total_baseline - total_removed) / total_baseline).clamp(0.0, 1.0)
0.0
};
// Orphaned: nodes with >99% activation loss
let orphaned: Vec<NodeId> = (0..n)
.filter(|&i| per_node_loss[i] / num_trials > 0.99 && !removed_set[i])
.map(|i| NodeId::new(i as u32))
// Weakened: nodes with >50% activation loss
let weakened: Vec<(NodeId, FiniteF32)> = (0..n)
let avg = per_node_loss[i] / num_trials;
avg > 0.5 && avg <= 0.99 && !removed_set[i]
.map(|i| {
(NodeId::new(i as u32), FiniteF32::new(avg))
// Compute reachability via BFS from arbitrary start node
let reachability_before = Self::compute_reachability(graph, n, &vec![false; n]);
let reachability_after = Self::compute_reachability(graph, n, &removed_set);
Ok(CounterfactualResult {
removed_nodes: remove_nodes.to_vec(),
total_impact: FiniteF32::new(pct_lost),
pct_activation_lost: FiniteF32::new(pct_lost),
orphaned_nodes: orphaned,
weakened_nodes: weakened,
communities_split: 0, // Would need Louvain recomputation
reachability_before,
reachability_after,
/// BFS reachability count. Starts from highest-degree non-removed node
/// to avoid starting from isolated nodes (e.g. config files with no edges).
fn compute_reachability(graph: &Graph, n: usize, removed: &[bool]) -> u32 {
return 0;
// Find highest-degree non-removed node (not just first) to avoid isolated starts
let start = (0..n).filter(|&i| !removed[i]).max_by_key(|&i| {
let nid = NodeId::new(i as u32);
let out = graph.csr.out_range(nid);
let inv = graph.csr.in_range(nid);
(out.end - out.start) + (inv.end - inv.start)
});
let start = match start {
Some(s) => s,
None => return 0,
let mut queue = std::collections::VecDeque::new();
queue.push_back(start);
visited[start] = true;
let mut count = 1u32;
while let Some(node) = queue.pop_front() {
let nid = NodeId::new(node as u32);
// Forward edges
let range = graph.csr.out_range(nid);
let tgt = graph.csr.targets[j].as_usize();
if tgt < n && !visited[tgt] && !removed[tgt] {
visited[tgt] = true;
queue.push_back(tgt);
count += 1;
// Reverse edges
let rev_range = graph.csr.in_range(nid);
let src = graph.csr.rev_sources[j].as_usize();
if src < n && !visited[src] && !removed[src] {
visited[src] = true;
queue.push_back(src);
count
/// Find keystone nodes (highest counterfactual impact). Parallelised via rayon.
/// FM-CF-010 fix: correct aggregation denominator.
/// Replaces: counterfactual.py CounterfactualSimulator.find_keystones()
pub fn find_keystones(
) -> M1ndResult<KeystoneResult> {
let mut impacts: Vec<(NodeId, f32)> = Vec::new();
// Test removal of each node (or top-N by degree for efficiency)
let mut candidates: Vec<(usize, usize)> = (0..n)
let range = graph.csr.out_range(NodeId::new(i as u32));
(i, range.end - range.start)
candidates.sort_by_key(|entry| std::cmp::Reverse(entry.1));
candidates.truncate(self.keystone_top_n * 2);
for (node_idx, _) in &candidates {
let result =
self.simulate_removal(graph, engine, config, &[NodeId::new(*node_idx as u32)])?;
impacts.push((NodeId::new(*node_idx as u32), result.total_impact.get()));
impacts.sort_by(|a, b| b.1.total_cmp(&a.1));
let keystones: Vec<KeystoneEntry> = impacts
.take(self.keystone_top_n)
.map(|&(node, impact)| KeystoneEntry {
node,
avg_impact: FiniteF32::new(impact),
impact_std: FiniteF32::ZERO, // Single-trial std
Ok(KeystoneResult {
keystones,
num_trials: self.num_trials,
/// Cascade analysis for a removed node.
/// Replaces: counterfactual.py CascadeAnalyzer.analyze()
pub fn cascade_analysis(
_engine: &HybridEngine,
_config: &PropagationConfig,
remove_node: NodeId,
) -> M1ndResult<CascadeResult> {
if remove_node.as_usize() >= n {
return Ok(CascadeResult {
removed_node: remove_node,
cascade_depth: 0,
affected_by_depth: Vec::new(),
total_affected: 0,
// BFS from removed node to find downstream affected nodes
let mut affected_by_depth: Vec<Vec<NodeId>> = Vec::new();
visited[remove_node.as_usize()] = true;
let mut frontier = vec![remove_node];
let max_depth = 5u8;
let mut next = Vec::new();
let mut depth_affected = Vec::new();
for &node in &frontier {
if tgt_idx < n && !visited[tgt_idx] {
next.push(tgt);
depth_affected.push(tgt);
if !depth_affected.is_empty() {
affected_by_depth.push(depth_affected);
frontier = next;
let total_affected: u32 = affected_by_depth.iter().map(|d| d.len() as u32).sum();
Ok(CascadeResult {
cascade_depth: affected_by_depth.len() as u8,
affected_by_depth,
total_affected,
/// Multi-node synergy analysis.
/// Replaces: counterfactual.py WhatIfSimulator.simulate()
pub fn synergy_analysis(
) -> M1ndResult<SynergyResult> {
// Individual impacts
let mut individual_impacts = Vec::new();
let result = self.simulate_removal(graph, engine, config, &[node])?;
individual_impacts.push((node, result.total_impact));
// Combined impact
let combined = self.simulate_removal(graph, engine, config, remove_nodes)?;
let sum_individual: f32 = individual_impacts.iter().map(|(_, s)| s.get()).sum();
let synergy_factor = if sum_individual > 0.0 {
combined.total_impact.get() / sum_individual
1.0
Ok(SynergyResult {
individual_impacts,
combined_impact: combined.total_impact,
synergy_factor: FiniteF32::new(synergy_factor.min(10.0)),
/// FM-CF-016 fix: architectural node protection + confidence levels.
/// Replaces: counterfactual.py CounterfactualSimulator.check_redundancy()
pub fn check_redundancy(
node: NodeId,
) -> M1ndResult<RedundancyResult> {
if idx >= n {
return Ok(RedundancyResult {
redundancy_score: FiniteF32::ZERO,
confidence: RedundancyConfidence::Low,
alternative_paths: 0,
is_architectural: false,
// Simulate removal
let impact = result.total_impact.get();
// Redundancy = 1 - impact (low impact = high redundancy)
let redundancy = (1.0 - impact).clamp(0.0, 1.0);
// Count alternative paths: BFS bypassing this node
let out_degree = out_range.end - out_range.start;
let in_degree = in_range.end - in_range.start;
let alternative_paths = (out_degree.min(in_degree)) as u32;
// Architectural node detection: high degree + bridge-like
let is_architectural = out_degree >= 5 && impact > 0.3;
// Confidence based on number of trials
let confidence = if self.num_trials >= 8 {
RedundancyConfidence::High
} else if self.num_trials >= 4 {
RedundancyConfidence::Medium
RedundancyConfidence::Low
Ok(RedundancyResult {
redundancy_score: FiniteF32::new(redundancy),
confidence,
alternative_paths,
is_architectural,
/// Antifragility score for the graph.
/// Replaces: counterfactual.py CounterfactualSimulator.antifragility_score()
pub fn antifragility_score(
) -> M1ndResult<AntifragilityResult> {
let keystones = self.find_keystones(graph, engine, config)?;
// Overall score: lower max keystone impact = more antifragile
let max_impact = keystones
.keystones
.first()
.map(|k| k.avg_impact.get())
.unwrap_or(0.0);
let score = (1.0 - max_impact).clamp(0.0, 1.0);
Ok(AntifragilityResult {
score: FiniteF32::new(score),
top_keystones: keystones.keystones,
most_redundant: Vec::new(), // Would require full redundancy scan
least_redundant: Vec::new(),
static_assertions::assert_impl_all!(CounterfactualEngine: Send, Sync);