Research-Stack/4-Infrastructure/infra/ene-session-sync/src/math.rs
Brandon Schneider c9ccc497f8 ene-session-sync: complete Python→Rust port; fix all 6 pre-existing test failures
**New Rust modules (batch 2 — 9 files)**
- src/deepseek_adapter.rs    — DeepSeek/Ollama chat + DeepSeekProver
- src/ene_cloud_credential_manager.rs — ENE cloud credential + node balancer (SQLite)
- src/enhanced_swarm.rs      — enhanced swarm stub
- src/gemma_integration.rs   — SQLite task queue for Gemma 4 model tasks
- src/hyperbolic_encoding.rs — Poincaré disk math, HyperbolicManifoldEncoder
- src/knowledge_ingestion.rs — WolframAlpha, OpenMath, nLab wiki adapters
- src/manifold_perception.rs — filesystem manifest scanner / topological report
- src/s3c_lean_review.rs     — CLI adapter submitting S3C.lean to Gemma4Integration
- src/search_adapter.rs      — Google (stub) + Brave search providers

All 9 wired into main.rs as mod declarations.

**Test fixes (6 pre-existing failures → 0)**
- s3c.rs: fix shell decomp width formula (a+b not a+b+1); correct test
  expectations for n=9 (b=7, not b=1); invariant a+b=2k+1 not 2k
- math.rs: fix test_avg_chain expected avg to 10/6 (all-pairs average,
  not just A→* paths)
- ene_core.rs: fix AES-GCM decrypt AAD mismatch in retrieve_sensitive_data —
  SELECT now fetches pkg column and passes it as AAD (matches store path)
- hyperbolic_encoding.rs: fix Möbius transform formula to standard gyrovector
  form: denom = 1+2⟨a,z⟩+‖a‖²‖z‖² (was missing ‖a‖²‖z‖² term, had +‖z‖²
  instead) — satisfies T_0(z)=z identity

**cargo test: 145 passed, 0 failed**

**Delete 35 Python source files** now superseded by Rust crate:
All 4-Infrastructure/infra/*.py and embedded_surface/server.py removed.

**Deploy scripts updated** to use rs-surface binary instead of Python:
- gcl_edge_in_place_upgrade.sh: CURRENT_SERVER → rs-surface binary; validate
  with test -x; smoke-test exec binary directly; rollback saves rs-surface
- xen_alpine/install_rs_surface_openrc.sh: SERVER_SRC → musl release binary;
  drop python3 from apk; install as rs-surface (not server.py)
- xen_alpine/run_qemu_alpine_surface.sh: default SURFACE_IMPL=rust; RUST_BIN
  var for musl binary; else-branch copies rs-surface; boot script exec binary
- recover_credential_server.sh: upload rs-surface binary; ExecStart → binary
  with RS_SURFACE_PORT=8444 (credential endpoint built into rs-surface /credentials)
- nixos-setup-cred-server.sh: same — ExecStart uses /opt/rs-surface/rs-surface

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-19 14:44:19 +00:00

860 lines
34 KiB
Rust

#![allow(dead_code)]
//! math.rs — Manifold intrinsic geometry: BFS distances, betweenness centrality,
//! cycle detection, and connected components.
//!
//! Port of manifold_geometry.py.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
// ─────────────────────────────────────────────────────────────────────────────
// §1 Graph type
// ─────────────────────────────────────────────────────────────────────────────
/// Directed graph represented as adjacency list (node name → set of neighbor names).
#[derive(Debug, Clone, Default)]
pub struct Graph {
pub nodes: BTreeSet<String>,
pub edges: HashMap<String, BTreeSet<String>>,
pub reverse_edges: HashMap<String, BTreeSet<String>>,
}
impl Graph {
/// Create an empty graph.
pub fn new() -> Self {
Self::default()
}
/// Add a directed edge from → to, registering both nodes and the reverse edge.
pub fn add_edge(&mut self, from: &str, to: &str) {
self.nodes.insert(from.to_owned());
self.nodes.insert(to.to_owned());
self.edges
.entry(from.to_owned())
.or_default()
.insert(to.to_owned());
self.reverse_edges
.entry(to.to_owned())
.or_default()
.insert(from.to_owned());
}
/// Add a node with no edges (idempotent).
pub fn add_node(&mut self, name: &str) {
self.nodes.insert(name.to_owned());
}
/// Number of nodes.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Number of directed edges (sum of all adjacency-set sizes).
pub fn edge_count(&self) -> usize {
self.edges.values().map(|s| s.len()).sum()
}
/// Out-degree of `node`.
fn out_degree(&self, node: &str) -> usize {
self.edges.get(node).map_or(0, |s| s.len())
}
/// In-degree of `node`.
fn in_degree(&self, node: &str) -> usize {
self.reverse_edges.get(node).map_or(0, |s| s.len())
}
/// Neighbours reachable by following forward edges from `node`.
/// Callers that need owned iteration use `graph.edges.get(node)` directly;
/// this helper is kept for documentation completeness.
#[allow(dead_code)]
fn neighbor_iter<'a>(&'a self, node: &str) -> Box<dyn Iterator<Item = &'a String> + 'a> {
match self.edges.get(node) {
Some(set) => Box::new(set.iter()),
None => Box::new(std::iter::empty()),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// §2 BFS distances
// ─────────────────────────────────────────────────────────────────────────────
/// BFS shortest-path distances from `start` to all reachable nodes.
/// Unreachable nodes (including nodes not in the graph) get distance `u64::MAX`.
pub fn bfs_distances(graph: &Graph, start: &str) -> HashMap<String, u64> {
// Initialise every known node to infinity.
let mut dist: HashMap<String, u64> = graph
.nodes
.iter()
.map(|n| (n.clone(), u64::MAX))
.collect();
if !graph.nodes.contains(start) {
return dist;
}
*dist.get_mut(start).unwrap() = 0;
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(start.to_owned());
while let Some(u) = queue.pop_front() {
let u_dist = dist[&u];
if let Some(neighbors) = graph.edges.get(&u) {
for v in neighbors {
if dist[v] == u64::MAX {
*dist.get_mut(v).unwrap() = u_dist + 1;
queue.push_back(v.clone());
}
}
}
}
dist
}
// ─────────────────────────────────────────────────────────────────────────────
// §3 All-pairs BFS
// ─────────────────────────────────────────────────────────────────────────────
/// All-pairs shortest-path distances.
/// Returns `distances[source][target]` for every ordered pair.
pub fn all_pairs_distances(
graph: &Graph,
) -> HashMap<String, HashMap<String, u64>> {
graph
.nodes
.iter()
.map(|n| (n.clone(), bfs_distances(graph, n)))
.collect()
}
// ─────────────────────────────────────────────────────────────────────────────
// §4 Diameter and average distance
// ─────────────────────────────────────────────────────────────────────────────
/// Returns `(diameter, average_finite_nonzero_distance)`.
///
/// `diameter` is the maximum finite shortest-path length across all pairs.
/// The average excludes both unreachable pairs (`u64::MAX`) and self-pairs (distance 0).
pub fn diameter_and_avg(
distances: &HashMap<String, HashMap<String, u64>>,
) -> (u64, f64) {
let mut max_d: u64 = 0;
let mut sum: f64 = 0.0;
let mut count: u64 = 0;
for row in distances.values() {
for &d in row.values() {
if d != u64::MAX && d > 0 {
if d > max_d {
max_d = d;
}
sum += d as f64;
count += 1;
}
}
}
let avg = if count > 0 { sum / count as f64 } else { 0.0 };
(max_d, avg)
}
// ─────────────────────────────────────────────────────────────────────────────
// §5 Curvature (Ollivier-Ricci approximation)
// ─────────────────────────────────────────────────────────────────────────────
/// Per-node curvature approximation: `(in_degree - out_degree) / (in_degree + out_degree)`.
///
/// Positive → sink (information converges here).
/// Negative → source (information diverges from here).
/// Returns `0.0` when both degrees are zero (isolated node).
pub fn node_curvature(graph: &Graph, node: &str) -> f64 {
let out = graph.out_degree(node) as f64;
let in_ = graph.in_degree(node) as f64;
let total = in_ + out;
if total == 0.0 {
0.0
} else {
(in_ - out) / total
}
}
/// Curvature for every node in the graph.
pub fn all_curvatures(graph: &Graph) -> HashMap<String, f64> {
graph
.nodes
.iter()
.map(|n| (n.clone(), node_curvature(graph, n)))
.collect()
}
// ─────────────────────────────────────────────────────────────────────────────
// §6 Betweenness centrality (approximate BFS-based)
// ─────────────────────────────────────────────────────────────────────────────
/// Betweenness centrality — for each node `n`, counts how many shortest s→t
/// paths pass through `n` (excluding endpoints). Normalised to `[0, 1]`.
///
/// Algorithm mirrors `compute_betweenness_centrality` in `manifold_geometry.py`:
/// - For each source `s`: BFS to build `pred[]` (predecessors on shortest paths).
/// - For each target `t` reachable from `s` (t ≠ s): walk back from `t` via
/// `pred[]` and mark all intermediate nodes on a shortest path.
/// - Increment centrality for every marked intermediate node excluding `t` itself.
/// - After all sources are processed, normalise by the maximum value.
pub fn betweenness_centrality(graph: &Graph) -> HashMap<String, f64> {
let node_list: Vec<&String> = graph.nodes.iter().collect();
let mut centrality: HashMap<String, f64> = node_list
.iter()
.map(|&n| (n.clone(), 0.0_f64))
.collect();
for &s in &node_list {
// ── BFS from s ──────────────────────────────────────────────────────
let mut dist: HashMap<&str, u64> = node_list
.iter()
.map(|&n| (n.as_str(), u64::MAX))
.collect();
let mut pred: HashMap<&str, Vec<&str>> = node_list
.iter()
.map(|&n| (n.as_str(), Vec::new()))
.collect();
*dist.get_mut(s.as_str()).unwrap() = 0;
let mut queue: VecDeque<&str> = VecDeque::new();
queue.push_back(s.as_str());
while let Some(u) = queue.pop_front() {
let u_dist = dist[u];
if let Some(neighbors) = graph.edges.get(u) {
for v in neighbors {
let v_str = v.as_str();
let v_dist = dist[v_str];
if v_dist == u64::MAX {
*dist.get_mut(v_str).unwrap() = u_dist + 1;
queue.push_back(v_str);
pred.get_mut(v_str).unwrap().push(u);
} else if v_dist == u_dist + 1 {
pred.get_mut(v_str).unwrap().push(u);
}
}
}
}
// ── Back-trace for each target t ────────────────────────────────────
for &t in &node_list {
if t == s || dist[t.as_str()] == u64::MAX
{
continue;
}
// DFS / iterative back-walk from t to s via pred[].
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: Vec<&str> = vec![t.as_str()];
while let Some(u) = stack.pop() {
if visited.contains(u) || u == s.as_str() {
continue;
}
visited.insert(u);
for &p in pred.get(u).map(|v| v.as_slice()).unwrap_or(&[]) {
if !visited.contains(p) {
stack.push(p);
}
}
}
// Every visited node except t itself is an intermediate hub.
for u in &visited {
if *u != t.as_str() {
*centrality.get_mut(*u).unwrap() += 1.0;
}
}
}
}
// Normalise.
let max_c = centrality.values().cloned().fold(0.0_f64, f64::max);
if max_c > 0.0 {
for v in centrality.values_mut() {
*v /= max_c;
}
}
centrality
}
// ─────────────────────────────────────────────────────────────────────────────
// §7 Cycle detection (depth-limited DFS, default max_depth = 5)
// ─────────────────────────────────────────────────────────────────────────────
/// Find all simple cycles in the directed graph with path length ≤ `max_depth`.
///
/// A cycle is reported when a neighbour equals `path[0]` and `path.len() >= 2`.
/// Cycles are deduplicated by rotating each to start at the lexicographically
/// smallest node — matching the normalisation in `manifold_geometry.py`.
pub fn find_cycles(graph: &Graph, max_depth: usize) -> Vec<Vec<String>> {
let mut raw_cycles: Vec<Vec<String>> = Vec::new();
for start in &graph.nodes {
let mut visited: HashSet<String> = HashSet::new();
visited.insert(start.clone());
dfs_cycles(
graph,
start,
&[start.clone()],
&mut visited,
&mut raw_cycles,
max_depth,
);
}
// Deduplicate: normalise each cycle by rotating to the lex-min node.
let mut seen: HashSet<Vec<String>> = HashSet::new();
let mut unique: Vec<Vec<String>> = Vec::new();
for cycle in raw_cycles {
// cycle ends with a copy of cycle[0], so the "body" is cycle[..len-1].
let body = &cycle[..cycle.len() - 1];
// Find the index of the lex-min element in the body.
let min_idx = body
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.cmp(b))
.map(|(i, _)| i)
.unwrap_or(0);
// Rotate: body[min_idx..] ++ body[..min_idx], then append body[min_idx] to close.
let mut normalised: Vec<String> = body[min_idx..].to_vec();
normalised.extend_from_slice(&body[..min_idx]);
normalised.push(normalised[0].clone()); // close the cycle
if seen.insert(normalised.clone()) {
unique.push(cycle);
}
}
unique
}
/// Recursive DFS helper for `find_cycles`.
fn dfs_cycles(
graph: &Graph,
current: &str,
path: &[String],
visited: &mut HashSet<String>,
cycles: &mut Vec<Vec<String>>,
max_depth: usize,
) {
// Depth guard: `path.len()` is the number of nodes visited so far (1-based).
// The Python guard is `if depth > 5: return` where depth starts at 1.
if path.len() > max_depth {
return;
}
if let Some(neighbors) = graph.edges.get(current) {
for neighbor in neighbors {
if neighbor == &path[0] && path.len() >= 2 {
// Found a cycle — append the closing node and record.
let mut cycle = path.to_vec();
cycle.push(neighbor.clone());
cycles.push(cycle);
} else if !path.contains(neighbor) && !visited.contains(neighbor) {
visited.insert(neighbor.clone());
let mut new_path = path.to_vec();
new_path.push(neighbor.clone());
dfs_cycles(graph, neighbor, &new_path, visited, cycles, max_depth);
visited.remove(neighbor);
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// §8 Connected components (undirected BFS)
// ─────────────────────────────────────────────────────────────────────────────
/// Weakly connected components — treats all directed edges as undirected.
/// Each component is a sorted `Vec<String>`.
pub fn connected_components(graph: &Graph) -> Vec<Vec<String>> {
// Build undirected adjacency from both edge directions.
let mut undirected: HashMap<&str, Vec<&str>> = HashMap::new();
for node in &graph.nodes {
undirected.entry(node.as_str()).or_default();
}
for (u, vs) in &graph.edges {
for v in vs {
undirected.entry(u.as_str()).or_default().push(v.as_str());
undirected.entry(v.as_str()).or_default().push(u.as_str());
}
}
let mut visited: HashSet<&str> = HashSet::new();
let mut components: Vec<Vec<String>> = Vec::new();
for node in &graph.nodes {
let node_str = node.as_str();
if visited.contains(node_str) {
continue;
}
// BFS to collect the component.
let mut comp: Vec<String> = Vec::new();
let mut queue: VecDeque<&str> = VecDeque::new();
visited.insert(node_str);
queue.push_back(node_str);
while let Some(u) = queue.pop_front() {
comp.push(u.to_owned());
if let Some(neighbors) = undirected.get(u) {
for &v in neighbors {
if !visited.contains(v) {
visited.insert(v);
queue.push_back(v);
}
}
}
}
comp.sort();
components.push(comp);
}
components
}
// ─────────────────────────────────────────────────────────────────────────────
// §9 GeometryReport
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize)]
pub struct HubEntry {
pub module: String,
pub centrality: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CurvatureEntry {
pub module: String,
pub curvature: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BoundaryEntry {
pub module: String,
pub out_degree: usize,
pub in_degree: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GeometryReport {
pub node_count: usize,
pub edge_count: usize,
pub diameter: u64,
pub average_distance: f64,
pub cycle_count: usize,
pub component_count: usize,
pub hubs: Vec<HubEntry>,
pub positive_curvature: Vec<CurvatureEntry>,
pub negative_curvature: Vec<CurvatureEntry>,
pub sources: Vec<BoundaryEntry>,
pub sinks: Vec<BoundaryEntry>,
pub isolated: Vec<String>,
pub cycles: Vec<Vec<String>>,
}
/// Run the full geometry analysis on a graph.
///
/// Equivalent to `manifold_geometry.py main()`:
/// - Top 15 hubs by betweenness centrality.
/// - Top/bottom 10 nodes by curvature.
/// - All sources (no in-edges, at least one out-edge).
/// - All sinks (no out-edges, at least one in-edge).
/// - All isolated nodes (degree zero).
/// - All detected cycles (deduplicated, depth ≤ 5).
pub fn analyze(graph: &Graph) -> GeometryReport {
// ── Distances ────────────────────────────────────────────────────────────
let distances = all_pairs_distances(graph);
let (diameter, average_distance) = diameter_and_avg(&distances);
// ── Curvature ────────────────────────────────────────────────────────────
let curvature = all_curvatures(graph);
// ── Centrality ───────────────────────────────────────────────────────────
let centrality = betweenness_centrality(graph);
// ── Cycles ───────────────────────────────────────────────────────────────
let cycles = find_cycles(graph, 5);
// ── Connected components ─────────────────────────────────────────────────
let components = connected_components(graph);
// ── Hubs: top 15 by centrality ───────────────────────────────────────────
let mut centrality_vec: Vec<(&String, f64)> =
centrality.iter().map(|(k, &v)| (k, v)).collect();
centrality_vec.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let hubs: Vec<HubEntry> = centrality_vec
.iter()
.take(15)
.map(|(m, c)| HubEntry {
module: (*m).clone(),
centrality: round4(*c),
})
.collect();
// ── Curvature rankings ───────────────────────────────────────────────────
let mut curv_vec: Vec<(&String, f64)> =
curvature.iter().map(|(k, &v)| (k, v)).collect();
// Positive curvature: sort descending, top 10.
curv_vec.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let positive_curvature: Vec<CurvatureEntry> = curv_vec
.iter()
.take(10)
.map(|(m, c)| CurvatureEntry {
module: (*m).clone(),
curvature: round4(*c),
})
.collect();
// Negative curvature: sort ascending, top 10.
curv_vec.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let negative_curvature: Vec<CurvatureEntry> = curv_vec
.iter()
.take(10)
.map(|(m, c)| CurvatureEntry {
module: (*m).clone(),
curvature: round4(*c),
})
.collect();
// ── Boundary detection ───────────────────────────────────────────────────
let mut sources: Vec<BoundaryEntry> = graph
.nodes
.iter()
.filter(|n| graph.in_degree(n) == 0 && graph.out_degree(n) > 0)
.map(|n| BoundaryEntry {
module: n.clone(),
out_degree: graph.out_degree(n),
in_degree: 0,
})
.collect();
sources.sort_by_key(|e| e.module.clone());
let mut sinks: Vec<BoundaryEntry> = graph
.nodes
.iter()
.filter(|n| graph.out_degree(n) == 0 && graph.in_degree(n) > 0)
.map(|n| BoundaryEntry {
module: n.clone(),
out_degree: 0,
in_degree: graph.in_degree(n),
})
.collect();
sinks.sort_by_key(|e| e.module.clone());
let mut isolated: Vec<String> = graph
.nodes
.iter()
.filter(|n| graph.out_degree(n) == 0 && graph.in_degree(n) == 0)
.cloned()
.collect();
isolated.sort();
GeometryReport {
node_count: graph.node_count(),
edge_count: graph.edge_count(),
diameter,
average_distance,
cycle_count: cycles.len(),
component_count: components.len(),
hubs,
positive_curvature,
negative_curvature,
sources,
sinks,
isolated,
cycles,
}
}
/// Round a float to 4 decimal places (mirrors Python's `round(x, 4)`).
#[inline]
fn round4(x: f64) -> f64 {
(x * 10_000.0).round() / 10_000.0
}
// ─────────────────────────────────────────────────────────────────────────────
// §10 Lean import parser
// ─────────────────────────────────────────────────────────────────────────────
/// Parse Lean import lines from source text, extracting `Semantics.*` module names.
///
/// Matches lines of the form `^\s*import\s+Semantics\.(\S+)` by simple string
/// scanning — no regex dependency needed.
///
/// Returns the captured suffix after `Semantics.` for each matching line.
pub fn extract_lean_imports(source: &str) -> Vec<String> {
let mut imports = Vec::new();
for line in source.lines() {
let trimmed = line.trim_start();
// Must start with "import"
if !trimmed.starts_with("import") {
continue;
}
let after_import = &trimmed["import".len()..];
// Must have at least one ASCII whitespace after "import"
if !after_import.starts_with(|c: char| c == ' ' || c == '\t') {
continue;
}
let module = after_import.trim_start();
// Module must begin with "Semantics."
if let Some(suffix) = module.strip_prefix("Semantics.") {
// Take up to the first whitespace (the spec says `\S+`)
let name: String = suffix
.chars()
.take_while(|c| !c.is_whitespace())
.collect();
if !name.is_empty() {
imports.push(name);
}
}
}
imports
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn triangle() -> Graph {
let mut g = Graph::new();
g.add_edge("A", "B");
g.add_edge("B", "C");
g.add_edge("C", "A");
g
}
fn chain() -> Graph {
let mut g = Graph::new();
g.add_edge("A", "B");
g.add_edge("B", "C");
g.add_edge("C", "D");
g
}
// ── §1 Graph ──────────────────────────────────────────────────────────────
#[test]
fn test_node_edge_count() {
let g = triangle();
assert_eq!(g.node_count(), 3);
assert_eq!(g.edge_count(), 3);
}
#[test]
fn test_add_edge_registers_reverse() {
let mut g = Graph::new();
g.add_edge("X", "Y");
assert!(g.reverse_edges["Y"].contains("X"));
assert!(g.edges["X"].contains("Y"));
}
// ── §2 BFS ────────────────────────────────────────────────────────────────
#[test]
fn test_bfs_chain() {
let g = chain();
let d = bfs_distances(&g, "A");
assert_eq!(d["A"], 0);
assert_eq!(d["B"], 1);
assert_eq!(d["C"], 2);
assert_eq!(d["D"], 3);
}
#[test]
fn test_bfs_unreachable() {
let g = chain();
let d = bfs_distances(&g, "D"); // no outgoing edges from D
assert_eq!(d["A"], u64::MAX);
}
// ── §3/4 All-pairs & diameter ─────────────────────────────────────────────
#[test]
fn test_diameter_chain() {
let g = chain();
let dist = all_pairs_distances(&g);
let (diam, _) = diameter_and_avg(&dist);
assert_eq!(diam, 3);
}
#[test]
fn test_avg_chain() {
let g = chain();
let dist = all_pairs_distances(&g);
let (_, avg) = diameter_and_avg(&dist);
// Finite non-zero distances for chain A→B→C→D:
// A→B=1, A→C=2, A→D=3, B→C=1, B→D=2, C→D=1 → sum=10, count=6, avg=10/6
assert!((avg - 10.0 / 6.0).abs() < 1e-9);
}
// ── §5 Curvature ──────────────────────────────────────────────────────────
#[test]
fn test_curvature_balanced() {
// B has in=1 (from A), out=1 (to C) → (1-1)/(1+1) = 0
let g = chain();
assert_eq!(node_curvature(&g, "B"), 0.0);
}
#[test]
fn test_curvature_sink() {
// D has in=1, out=0 → (1-0)/(1+0) = 1.0
let g = chain();
assert_eq!(node_curvature(&g, "D"), 1.0);
}
#[test]
fn test_curvature_source() {
// A has in=0, out=1 → (0-1)/(0+1) = -1.0
let g = chain();
assert_eq!(node_curvature(&g, "A"), -1.0);
}
#[test]
fn test_curvature_isolated() {
let mut g = Graph::new();
g.add_node("Lone");
assert_eq!(node_curvature(&g, "Lone"), 0.0);
}
// ── §6 Centrality ─────────────────────────────────────────────────────────
#[test]
fn test_centrality_chain_middle_highest() {
// In A→B→C→D, B and C are the intermediaries.
let g = chain();
let c = betweenness_centrality(&g);
// Both B and C should have centrality 1.0 (normalised), A and D should be 0.
assert_eq!(c["A"], 0.0);
assert_eq!(c["D"], 0.0);
// B and C are both intermediaries; one of them reaches max.
let max = c.values().cloned().fold(0.0_f64, f64::max);
assert!((max - 1.0).abs() < 1e-9);
}
#[test]
fn test_centrality_no_edges() {
let mut g = Graph::new();
g.add_node("Solo");
let c = betweenness_centrality(&g);
assert_eq!(c["Solo"], 0.0);
}
// ── §7 Cycles ─────────────────────────────────────────────────────────────
#[test]
fn test_triangle_has_one_cycle() {
let g = triangle();
let cycles = find_cycles(&g, 5);
assert_eq!(cycles.len(), 1);
}
#[test]
fn test_chain_no_cycles() {
let g = chain();
let cycles = find_cycles(&g, 5);
assert!(cycles.is_empty());
}
#[test]
fn test_cycle_deduplication() {
// A two-cycle graph: A↔B (A→B and B→A)
let mut g = Graph::new();
g.add_edge("A", "B");
g.add_edge("B", "A");
let cycles = find_cycles(&g, 5);
assert_eq!(cycles.len(), 1);
}
// ── §8 Connected components ───────────────────────────────────────────────
#[test]
fn test_single_component() {
let g = chain();
let comps = connected_components(&g);
assert_eq!(comps.len(), 1);
}
#[test]
fn test_two_components() {
let mut g = Graph::new();
g.add_edge("A", "B");
g.add_node("C"); // isolated
let comps = connected_components(&g);
assert_eq!(comps.len(), 2);
}
// ── §9 analyze ────────────────────────────────────────────────────────────
#[test]
fn test_analyze_smoke() {
let g = chain();
let report = analyze(&g);
assert_eq!(report.node_count, 4);
assert_eq!(report.edge_count, 3);
assert_eq!(report.diameter, 3);
assert_eq!(report.cycle_count, 0);
assert_eq!(report.component_count, 1);
assert_eq!(report.sources.len(), 1);
assert_eq!(report.sources[0].module, "A");
assert_eq!(report.sinks.len(), 1);
assert_eq!(report.sinks[0].module, "D");
assert!(report.isolated.is_empty());
}
#[test]
fn test_analyze_triangle() {
let g = triangle();
let report = analyze(&g);
assert_eq!(report.cycle_count, 1);
assert!(report.sources.is_empty());
assert!(report.sinks.is_empty());
assert!(report.isolated.is_empty());
}
#[test]
fn test_analyze_serializes() {
let report = analyze(&chain());
let json = serde_json::to_string(&report).expect("serialization failed");
let _back: GeometryReport =
serde_json::from_str(&json).expect("deserialization failed");
}
// ── §10 Lean import parser ────────────────────────────────────────────────
#[test]
fn test_extract_lean_imports_basic() {
let src = "import Semantics.Core\nimport Semantics.BraidedFieldPaths\n";
let imports = extract_lean_imports(src);
assert_eq!(imports, vec!["Core", "BraidedFieldPaths"]);
}
#[test]
fn test_extract_lean_imports_ignores_other() {
let src = "import Mathlib.Data.List\nimport Semantics.Foo\n-- import Semantics.Bar\n";
let imports = extract_lean_imports(src);
assert_eq!(imports, vec!["Foo"]);
}
#[test]
fn test_extract_lean_imports_with_leading_whitespace() {
let src = " import Semantics.TSM\n";
let imports = extract_lean_imports(src);
assert_eq!(imports, vec!["TSM"]);
}
#[test]
fn test_extract_lean_imports_comment_line_not_matched() {
// A line starting with '--' is a Lean comment; it should NOT be matched
// because after trim_start it begins with '--', not 'import'.
let src = "-- import Semantics.Commented\n";
let imports = extract_lean_imports(src);
assert!(imports.is_empty());
}
#[test]
fn test_extract_lean_imports_empty() {
assert!(extract_lean_imports("").is_empty());
assert!(extract_lean_imports("-- nothing here\n").is_empty());
}
}