graph-theory-optimizing #15

Merged
zacheryasc merged 2 commits from graph-theory-optimizing into master 2026-02-07 10:40:18 +00:00
15 changed files with 3620 additions and 77 deletions

View file

@ -65,3 +65,18 @@ cargo test --features stress # stress tests
cargo run --bin bench --release # benchmarks
cargo run --example hello
```
## Connectome analysis
Spectral analysis of the internal dependency graph, producing a Connectome Complexity Index (CCI) and visual dashboards.
```sh
# Generate the dependency DAG
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
# Run spectral analysis (outputs to docs/connectome/)
source .venv/bin/activate
python tools/spectral/spectral_analysis.py deps.dot
```
This produces a text report, an interactive HTML dashboard, and a static PNG dashboard in `docs/connectome/`. See [docs/connectome.md](docs/connectome.md) for details on the metrics and interpretation.

76
docs/connectome.md Normal file
View file

@ -0,0 +1,76 @@
# Connectome Analysis
The connectome analysis applies spectral graph theory to the codebase's internal dependency DAG, producing quantitative coupling metrics and visual dashboards.
## What it measures
The tool parses `deps.dot` (a GraphViz DOT file describing struct/trait dependencies between modules) and computes:
- **Laplacian eigenvalue spectrum** -- encodes the graph's overall connectivity structure
- **Fiedler vector** -- the optimal spectral bisection of the dependency graph, revealing natural module clusters
- **Module coupling matrix** -- directed edge counts between every pair of modules
- **Connectome Complexity Index (CCI)** -- a single 0-1 score combining five sub-metrics:
| Sub-metric | Weight | What it captures |
|---|---|---|
| Algebraic connectivity (lambda_2/n) | 25% | How tightly connected the graph is |
| Spectral entropy (H/log2(k)) | 25% | How uniformly distributed coupling is across eigenvalues |
| Edge density (\|E\|/n(n-1)) | 15% | Raw ratio of edges to possible edges |
| Cross-module coupling ratio | 20% | Fraction of edges that cross module boundaries |
| Spectral radius (rho/(n-1)) | 15% | Maximum hub concentration |
### Interpreting CCI
| CCI range | Label | Meaning |
|---|---|---|
| < 0.30 | LOW | Well-decomposed architecture |
| 0.30 - 0.60 | MODERATE | Typical well-structured codebase |
| > 0.60 | HIGH | Consider reviewing module boundaries |
## Running
From the project root:
```sh
# Default: outputs to docs/connectome/
python tools/spectral/spectral_analysis.py deps.dot
# Custom output directory
python tools/spectral/spectral_analysis.py deps.dot -o path/to/output
# Also emit JSON metrics
python tools/spectral/spectral_analysis.py deps.dot --json
# Text report only (skip matplotlib PNG)
python tools/spectral/spectral_analysis.py deps.dot --no-plots
```
### Prerequisites
The script requires numpy, scipy, and matplotlib (for the PNG dashboard). These are available in the project's `.venv`:
```sh
source .venv/bin/activate
python tools/spectral/spectral_analysis.py deps.dot
```
## Output files
All output goes to `docs/connectome/` by default:
| File | Description |
|---|---|
| `connectome_report.txt` | Full text report with eigenvalues, Fiedler bisection, coupling matrix, and CCI breakdown |
| `connectome_dashboard.html` | Interactive HTML dashboard with zoomable DAG, eigenvalue plot, Fiedler bar chart, and coupling heatmap |
| `connectome_dashboard.png` | Static PNG snapshot of the spectral dashboard (dark theme, 16x12 @ 150 DPI) |
| `connectome_metrics.json` | Machine-readable metrics (only with `--json` flag) |
## Regenerating deps.dot
The DOT file is the input to the spectral analysis. To regenerate it from source:
```sh
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
```
Then re-run the spectral analysis to update the connectome report.

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View file

@ -0,0 +1,280 @@
{
"graph": {
"n_nodes": 36,
"n_edges": 78,
"n_modules": 8,
"connected_components": 2,
"modules": [
"error",
"config",
"channel",
"actor",
"address_map",
"runtime",
"worker",
"python"
]
},
"spectral": {
"eigenvalues": [
0.0,
0.0,
0.18637427422819514,
0.4813940269111958,
0.6123548189907484,
0.7985629750697533,
0.8319091149970231,
1.004600219615323,
1.2394224070963267,
1.3689639255261323,
1.4526860286383532,
1.626080007307936,
2.321279039207482,
2.3935870074779477,
2.909249108581605,
3.1569529438124246,
3.219980753498557,
3.3901681819448264,
3.4799333923457128,
3.605153966968332,
3.8847634489335645,
4.186333826694949,
4.707024553452379,
5.173220891347629,
5.586454240023603,
5.795938099946378,
5.8549806331718415,
6.1828765255391644,
6.461944112192484,
6.898584006266002,
7.3807011063714905,
7.896480708195232,
9.160238969430825,
11.171010263156152,
14.04747425561517,
15.533322167445291
],
"fiedler_value": 0.0,
"fiedler_vector": [
0.0,
1.6667674979754847e-17,
-4.4166826078552935e-16,
-5.256955919501151e-16,
-1.6422080940489055e-18,
-7.037238109196825e-17,
8.390622125197347e-16,
2.3690827037115515e-17,
1.4176669953736474e-16,
1.4226827878099615e-16,
3.1675939003075104e-17,
2.7236604915425953e-18,
-1.744993274089968e-16,
2.918795638720409e-17,
-2.1047785816801073e-16,
1.6100142369066343e-16,
-1.1048855416219909e-16,
2.623380592723269e-16,
-6.257340472605819e-17,
-2.7901019807352287e-17,
7.954130131218555e-17,
-2.8145783605573126e-16,
5.097927800469914e-17,
1.0000000000000002,
-7.635525673846673e-17,
4.0203070989124624e-17,
5.607482503879278e-17,
1.4848475991077948e-17,
-8.451175680174382e-17,
-1.3333327282927672e-16,
2.6566833162138994e-16,
1.0987812721413363e-16,
4.959951093541129e-16,
-1.2067067461630528e-16,
-2.172546179303562e-16,
-2.3212297109883297e-16
],
"node_names": [
"Error",
"BackoffPolicy",
"RuntimeConfig",
"HybridChannel",
"Receiver",
"Sender",
"Actor",
"ActorAddress",
"ActorInterface",
"AnyActor",
"ContextInner",
"Ctx",
"Message",
"AddressMap",
"Placement",
"WorkerId",
"Envelope",
"Inbox",
"InboxRegistry",
"Runtime",
"RuntimeHandle",
"SenderT",
"ActorPool",
"Mailbox",
"TickContext",
"Worker",
"WorkerContext",
"Effect",
"PyActor",
"PyActorAddress",
"PyCtx",
"PyInbox",
"PyMsg",
"PyRuntime",
"PyRuntimeConfig",
"PyRuntimeHandle"
],
"node_modules": [
"error",
"config",
"config",
"channel",
"channel",
"channel",
"actor",
"actor",
"actor",
"actor",
"actor",
"actor",
"actor",
"address_map",
"address_map",
"address_map",
"runtime",
"runtime",
"runtime",
"runtime",
"runtime",
"runtime",
"worker",
"worker",
"worker",
"worker",
"worker",
"python",
"python",
"python",
"python",
"python",
"python",
"python",
"python",
"python"
]
},
"module_coupling": {
"module_names": [
"error",
"config",
"channel",
"actor",
"address_map",
"runtime",
"worker",
"python"
],
"coupling_matrix": [
[
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0
],
[
0.0,
0.0,
3.0,
0.0,
0.0,
1.0,
0.0,
0.0
],
[
2.0,
0.0,
0.0,
7.0,
0.0,
0.0,
0.0,
0.0
],
[
0.0,
0.0,
0.0,
1.0,
2.0,
0.0,
0.0,
0.0
],
[
2.0,
1.0,
2.0,
6.0,
2.0,
6.0,
1.0,
0.0
],
[
1.0,
1.0,
2.0,
9.0,
4.0,
3.0,
3.0,
0.0
],
[
0.0,
0.0,
0.0,
5.0,
0.0,
3.0,
0.0,
10.0
]
],
"cross_module_edges": 46,
"total_edges": 78
},
"metrics": {
"algebraic_connectivity": 0.0,
"normalized_algebraic_connectivity": 0.0,
"spectral_entropy": 4.641128070102523,
"normalized_spectral_entropy": 0.9122677088609219,
"edge_density": 0.06190476190476191,
"cross_module_ratio": 0.5897435897435898,
"spectral_radius": 6.676215667817795,
"normalized_spectral_radius": 0.19074901908050843,
"cci": 0.383913712311739
}
}

View file

@ -0,0 +1,125 @@
========================================================================
SPECTRAL ANALYSIS REPORT — Dependency DAG
========================================================================
GRAPH SUMMARY
----------------------------------------
Nodes: 36
Directed edges: 78
Modules: 8
Connected components: 2
Modules: error, config, channel, actor, address_map, runtime, worker, python
LAPLACIAN EIGENVALUE SPECTRUM
----------------------------------------
lambda_ 0 = 0.0000
lambda_ 1 = 0.0000 <-- Fiedler value (lambda_2)
lambda_ 2 = 0.1864
lambda_ 3 = 0.4814
lambda_ 4 = 0.6124
lambda_ 5 = 0.7986
lambda_ 6 = 0.8319
lambda_ 7 = 1.0046
lambda_ 8 = 1.2394
lambda_ 9 = 1.3690
lambda_10 = 1.4527
lambda_11 = 1.6261
lambda_12 = 2.3213
lambda_13 = 2.3936
lambda_14 = 2.9092
lambda_15 = 3.1570
lambda_16 = 3.2200
lambda_17 = 3.3902
lambda_18 = 3.4799
lambda_19 = 3.6052
lambda_20 = 3.8848
lambda_21 = 4.1863
lambda_22 = 4.7070
lambda_23 = 5.1732
lambda_24 = 5.5865
lambda_25 = 5.7959
lambda_26 = 5.8550
lambda_27 = 6.1829
lambda_28 = 6.4619
lambda_29 = 6.8986
lambda_30 = 7.3807
lambda_31 = 7.8965
lambda_32 = 9.1602
lambda_33 = 11.1710
lambda_34 = 14.0475
lambda_35 = 15.5333
Spectral gap (lambda_max - lambda_2): 15.5333
Fiedler value (algebraic connectivity): 0.0000
FIEDLER VECTOR — SPECTRAL BISECTION
----------------------------------------
Partition A (Fiedler < 0):
HybridChannel [channel ] f = -0.0000
RuntimeConfig [config ] f = -0.0000
SenderT [runtime ] f = -0.0000
PyRuntimeHandle [python ] f = -0.0000
PyRuntimeConfig [python ] f = -0.0000
Placement [address_map ] f = -0.0000
Message [actor ] f = -0.0000
PyActorAddress [python ] f = -0.0000
PyRuntime [python ] f = -0.0000
Envelope [runtime ] f = -0.0000
PyActor [python ] f = -0.0000
TickContext [worker ] f = -0.0000
Sender [channel ] f = -0.0000
InboxRegistry [runtime ] f = -0.0000
Runtime [runtime ] f = -0.0000
Receiver [channel ] f = -0.0000
────────────────────────────────────
Partition B (Fiedler >= 0):
Error [error ] f = +0.0000
Ctx [actor ] f = +0.0000
Effect [python ] f = +0.0000
BackoffPolicy [config ] f = +0.0000
ActorAddress [actor ] f = +0.0000
AddressMap [address_map ] f = +0.0000
ContextInner [actor ] f = +0.0000
Worker [worker ] f = +0.0000
ActorPool [worker ] f = +0.0000
WorkerContext [worker ] f = +0.0000
RuntimeHandle [runtime ] f = +0.0000
PyInbox [python ] f = +0.0000
ActorInterface [actor ] f = +0.0000
AnyActor [actor ] f = +0.0000
WorkerId [address_map ] f = +0.0000
Inbox [runtime ] f = +0.0000
PyCtx [python ] f = +0.0000
PyMsg [python ] f = +0.0000
Actor [actor ] f = +0.0000
Mailbox [worker ] f = +1.0000
MODULE COUPLING MATRIX (directed edge counts)
----------------------------------------
error config channel actoraddress_map runtime worker python
error 0 0 0 0 0 0 0 0
config 0 1 0 0 0 0 0 0
channel 0 0 3 0 0 1 0 0
actor 2 0 0 7 0 0 0 0
address_map 0 0 0 1 2 0 0 0
runtime 2 1 2 6 2 6 1 0
worker 1 1 2 9 4 3 3 0
python 0 0 0 5 0 3 0 10
Cross-module edges: 46 / 78 (59.0%)
CONNECTOME COMPLEXITY INDEX (CCI)
----------------------------------------
Sub-metric Raw Normalized Weight Contrib
──────────────────────────────────────── ────────── ────────── ──────── ────────
Algebraic connectivity (lambda_2/n) 0.0000 0.0000 0.25 0.0000
Spectral entropy (H/log2(k)) 4.6411 0.9123 0.25 0.2281
Edge density (|E|/n(n-1)) 0.0619 0.0619 0.15 0.0093
Cross-module coupling ratio 0.5897 0.5897 0.20 0.1179
Spectral radius (rho/(n-1)) 6.6762 0.1907 0.15 0.0286
──────────────────────────────────────── ────────── ────────── ──────── ────────
CCI (weighted sum) 1.00 0.3839
Interpretation: MODERATE complexity — typical well-structured codebase
========================================================================

125
spectral_report.txt Normal file
View file

@ -0,0 +1,125 @@
========================================================================
SPECTRAL ANALYSIS REPORT — Dependency DAG
========================================================================
GRAPH SUMMARY
----------------------------------------
Nodes: 36
Directed edges: 78
Modules: 8
Connected components: 2
Modules: error, config, channel, actor, address_map, runtime, worker, python
LAPLACIAN EIGENVALUE SPECTRUM
----------------------------------------
lambda_ 0 = 0.0000
lambda_ 1 = 0.0000 <-- Fiedler value (lambda_2)
lambda_ 2 = 0.1864
lambda_ 3 = 0.4814
lambda_ 4 = 0.6124
lambda_ 5 = 0.7986
lambda_ 6 = 0.8319
lambda_ 7 = 1.0046
lambda_ 8 = 1.2394
lambda_ 9 = 1.3690
lambda_10 = 1.4527
lambda_11 = 1.6261
lambda_12 = 2.3213
lambda_13 = 2.3936
lambda_14 = 2.9092
lambda_15 = 3.1570
lambda_16 = 3.2200
lambda_17 = 3.3902
lambda_18 = 3.4799
lambda_19 = 3.6052
lambda_20 = 3.8848
lambda_21 = 4.1863
lambda_22 = 4.7070
lambda_23 = 5.1732
lambda_24 = 5.5865
lambda_25 = 5.7959
lambda_26 = 5.8550
lambda_27 = 6.1829
lambda_28 = 6.4619
lambda_29 = 6.8986
lambda_30 = 7.3807
lambda_31 = 7.8965
lambda_32 = 9.1602
lambda_33 = 11.1710
lambda_34 = 14.0475
lambda_35 = 15.5333
Spectral gap (lambda_max - lambda_2): 15.5333
Fiedler value (algebraic connectivity): 0.0000
FIEDLER VECTOR — SPECTRAL BISECTION
----------------------------------------
Partition A (Fiedler < 0):
HybridChannel [channel ] f = -0.0000
RuntimeConfig [config ] f = -0.0000
SenderT [runtime ] f = -0.0000
PyRuntimeHandle [python ] f = -0.0000
PyRuntimeConfig [python ] f = -0.0000
Placement [address_map ] f = -0.0000
Message [actor ] f = -0.0000
PyActorAddress [python ] f = -0.0000
PyRuntime [python ] f = -0.0000
Envelope [runtime ] f = -0.0000
PyActor [python ] f = -0.0000
TickContext [worker ] f = -0.0000
Sender [channel ] f = -0.0000
InboxRegistry [runtime ] f = -0.0000
Runtime [runtime ] f = -0.0000
Receiver [channel ] f = -0.0000
────────────────────────────────────
Partition B (Fiedler >= 0):
Error [error ] f = +0.0000
Ctx [actor ] f = +0.0000
Effect [python ] f = +0.0000
BackoffPolicy [config ] f = +0.0000
ActorAddress [actor ] f = +0.0000
AddressMap [address_map ] f = +0.0000
ContextInner [actor ] f = +0.0000
Worker [worker ] f = +0.0000
ActorPool [worker ] f = +0.0000
WorkerContext [worker ] f = +0.0000
RuntimeHandle [runtime ] f = +0.0000
PyInbox [python ] f = +0.0000
ActorInterface [actor ] f = +0.0000
AnyActor [actor ] f = +0.0000
WorkerId [address_map ] f = +0.0000
Inbox [runtime ] f = +0.0000
PyCtx [python ] f = +0.0000
PyMsg [python ] f = +0.0000
Actor [actor ] f = +0.0000
Mailbox [worker ] f = +1.0000
MODULE COUPLING MATRIX (directed edge counts)
----------------------------------------
error config channel actoraddress_map runtime worker python
error 0 0 0 0 0 0 0 0
config 0 1 0 0 0 0 0 0
channel 0 0 3 0 0 1 0 0
actor 2 0 0 7 0 0 0 0
address_map 0 0 0 1 2 0 0 0
runtime 2 1 2 6 2 6 1 0
worker 1 1 2 9 4 3 3 0
python 0 0 0 5 0 3 0 10
Cross-module edges: 46 / 78 (59.0%)
CONNECTOME COMPLEXITY INDEX (CCI)
----------------------------------------
Sub-metric Raw Normalized Weight Contrib
──────────────────────────────────────── ────────── ────────── ──────── ────────
Algebraic connectivity (lambda_2/n) 0.0000 0.0000 0.25 0.0000
Spectral entropy (H/log2(k)) 4.6411 0.9123 0.25 0.2281
Edge density (|E|/n(n-1)) 0.0619 0.0619 0.15 0.0093
Cross-module coupling ratio 0.5897 0.5897 0.20 0.1179
Spectral radius (rho/(n-1)) 6.6762 0.1907 0.15 0.0286
──────────────────────────────────────── ────────── ────────── ──────── ────────
CCI (weighted sum) 1.00 0.3839
Interpretation: MODERATE complexity — typical well-structured codebase
========================================================================

View file

@ -1,6 +1,6 @@
use std::any::Any;
use crate::runtime::Ctx;
use crate::Error;
/// The primary trait defining data that can be passed to and from actor processes
pub trait Message: 'static + Sized + Clone + Send + Sync {}
@ -49,3 +49,48 @@ where
}
}
}
/// Object-safe inner trait for sending type-erased messages.
pub(crate) trait ContextInner {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error>;
fn mailbox_waterlevel(&self) -> usize;
}
/// Actor syscall interface — passed to `ActorInterface::handle()`.
///
/// Wraps a `&dyn ContextInner` to solve the object-safety problem while
/// providing a typed public API.
pub struct Ctx<'a> {
inner: &'a dyn ContextInner,
self_addr: ActorAddress,
}
impl<'a> Ctx<'a> {
pub(crate) fn new(inner: &'a dyn ContextInner, self_addr: ActorAddress) -> Self {
Self { inner, self_addr }
}
#[cfg(feature = "python")]
pub(crate) fn raw_inner(&self) -> &dyn ContextInner {
self.inner
}
/// Returns the address of the actor currently being ticked.
pub fn self_addr(&self) -> ActorAddress {
self.self_addr
}
/// Send a typed message to an actor address.
pub fn send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
self.inner.send_any(addr, Box::new(msg))
}
/// Spawn a new actor, returning its address.
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
let addr = ActorAddress::new_random();
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
self.inner.spawn_any(addr, boxed)?;
Ok(addr)
}
}

View file

@ -4,9 +4,9 @@ use std::cell::RefCell;
use pyo3::prelude::*;
use pyo3::types::PyModule;
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor};
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Ctx};
use crate::config::{BackoffPolicy, RuntimeConfig};
use crate::runtime::{Ctx, Inbox, Runtime, RuntimeHandle};
use crate::runtime::{Inbox, Runtime, RuntimeHandle};
use crate::Error;
// ─── PyMsg newtype ───────────────────────────────────────────────────────────

View file

@ -65,43 +65,8 @@ impl RuntimeHandle {
}
}
/// Actor syscall interface — passed to `ActorInterface::handle()`.
///
/// Wraps a `&dyn ContextInner` to solve the object-safety problem while
/// providing a typed public API.
pub struct Ctx<'a> {
inner: &'a dyn ContextInner,
self_addr: ActorAddress,
}
impl<'a> Ctx<'a> {
pub(crate) fn new(inner: &'a dyn ContextInner, self_addr: ActorAddress) -> Self {
Self { inner, self_addr }
}
#[cfg(feature = "python")]
pub(crate) fn raw_inner(&self) -> &dyn ContextInner {
self.inner
}
/// Returns the address of the actor currently being ticked.
pub fn self_addr(&self) -> ActorAddress {
self.self_addr
}
/// Send a typed message to an actor address.
pub fn send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
self.inner.send_any(addr, Box::new(msg))
}
/// Spawn a new actor, returning its address.
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
let addr = ActorAddress::new_random();
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
self.inner.spawn_any(addr, boxed)?;
Ok(addr)
}
}
// Re-export Ctx and ContextInner for backwards compatibility
pub use crate::actor::{ContextInner, Ctx};
/// Type-erased sender for external inboxes.
pub(crate) trait SenderT: Send + Sync {
@ -287,7 +252,7 @@ impl Runtime {
inbox_registry: &rt_clone.inbox_registry,
config: &rt_clone.config,
};
worker.run(&tc, &rt_clone.is_running, &rt_clone.config.backoff_policy);
worker.run(&tc, &rt_clone.is_running);
});
handles.push(handle);
}
@ -400,14 +365,6 @@ impl InboxRegistry {
/// Object-safe inner trait for sending type-erased messages.
pub(crate) trait ContextInner {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error>;
fn mailbox_waterlevel(&self) -> usize;
}
impl ContextInner for Runtime {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
match self.address_map.lookup(&addr) {

View file

@ -5,11 +5,11 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use crate::actor::{ActorAddress, AnyActor, Message};
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, Message};
use crate::address_map::{AddressMap, Placement, WorkerId};
use crate::channel::{Receiver, Sender};
use crate::config::{BackoffPolicy, RuntimeConfig};
use crate::runtime::{ContextInner, Ctx, Envelope, InboxRegistry};
use crate::config::RuntimeConfig;
use crate::runtime::{Envelope, InboxRegistry};
use crate::Error;
/// Per-worker stats published via atomics. Readable from any thread.
@ -90,12 +90,7 @@ impl Worker {
{
let worker_ctx = WorkerContext {
worker_id: self.id,
address_map: tc.address_map,
transfer_txs: tc.transfer_txs,
spawn_txs: tc.spawn_txs,
placement: tc.placement,
inbox_registry: tc.inbox_registry,
config: tc.config,
tc,
pending_local: &pending_local,
};
processed = self.pool.tick_all(&worker_ctx);
@ -121,7 +116,8 @@ impl Worker {
did_work
}
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool, backoff: &BackoffPolicy) {
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool) {
let backoff = &tc.config.backoff_policy;
let mut idle_count: u32 = 0;
while is_running.load(Ordering::Acquire) {
let did_work = self.tick_once(tc);
@ -151,18 +147,13 @@ impl Worker {
/// Cross-worker sends go through the transfer queue.
struct WorkerContext<'a> {
worker_id: WorkerId,
address_map: &'a AddressMap,
transfer_txs: &'a [Sender<Envelope>],
spawn_txs: &'a [Sender<(ActorAddress, Box<dyn AnyActor>)>],
placement: &'a Placement,
inbox_registry: &'a InboxRegistry,
config: &'a RuntimeConfig,
tc: &'a TickContext<'a>,
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
}
impl ContextInner for WorkerContext<'_> {
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
match self.address_map.lookup(&addr) {
match self.tc.address_map.lookup(&addr) {
Some(wid) if wid == self.worker_id => {
// Same worker: buffer for local delivery (after current tick round)
self.pending_local.borrow_mut().push((addr, msg));
@ -171,26 +162,26 @@ impl ContextInner for WorkerContext<'_> {
Some(wid) => {
// Cross worker: envelope through transfer queue
let envelope = Envelope::new(addr, msg);
let _ = self.transfer_txs[wid.as_usize()].try_send(envelope);
let _ = self.tc.transfer_txs[wid.as_usize()].try_send(envelope);
Ok(())
}
None => {
// Try inbox registry (external inboxes)
self.inbox_registry.try_deliver(addr, msg)
self.tc.inbox_registry.try_deliver(addr, msg)
}
}
}
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error> {
let worker_id = self.placement.next_worker();
self.address_map.insert(addr, worker_id);
self.spawn_txs[worker_id.as_usize()]
let worker_id = self.tc.placement.next_worker();
self.tc.address_map.insert(addr, worker_id);
self.tc.spawn_txs[worker_id.as_usize()]
.try_send((addr, actor))
.map_err(|_| Error::from("Spawn queue full"))
}
fn mailbox_waterlevel(&self) -> usize {
self.config.mailbox_waterlevel
self.tc.config.mailbox_waterlevel
}
}

View file

@ -4,11 +4,11 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use crate::actor::{ActorAddress, AnyActor};
use crate::actor::{ActorAddress, AnyActor, Ctx};
use crate::address_map::{AddressMap, Placement, WorkerId};
use crate::channel::Receiver;
use crate::config::{BackoffPolicy, RuntimeConfig};
use crate::runtime::{Ctx, Envelope, InboxRegistry};
use crate::config::RuntimeConfig;
use crate::runtime::{Envelope, InboxRegistry};
use super::{TickContext, Worker, WorkerStats};
@ -399,7 +399,6 @@ fn run_loop_stops_on_shutdown() {
let mut worker = Worker::new(WorkerId(0), transfer_rx, spawn_rx, stats);
let is_running = AtomicBool::new(false);
let backoff = BackoffPolicy::default();
let address_map = AddressMap::new();
let placement = Placement::new(1);
let inbox_registry = InboxRegistry::new();
@ -415,6 +414,6 @@ fn run_loop_stops_on_shutdown() {
};
thread::scope(|s| {
s.spawn(|| worker.run(&tc, &is_running, &backoff));
s.spawn(|| worker.run(&tc, &is_running));
});
}

2
tools/spectral/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__
output/*

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,727 @@
#!/usr/bin/env python3
"""Comprehensive tests for the spectral analysis tool."""
from __future__ import annotations
import copy
import json
import math
import os
import random
import tempfile
import unittest
import numpy as np
from spectral_analysis import (
AnalysisResult,
ComplexityMetrics,
DependencyGraph,
Edge,
ModuleCouplingResult,
Node,
SpectralResults,
build_adjacency,
build_laplacian,
compute_complexity_metrics,
compute_module_coupling,
compute_spectral,
compute_spectral_entropy,
count_connected_components,
generate_report,
get_node_ordering,
metrics_to_dict,
parse_dot,
run_analysis,
symmetrize,
)
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _make_graph(
names: list[str],
modules: list[str],
edge_pairs: list[tuple[str, str]],
module_order: list[str] | None = None,
) -> DependencyGraph:
"""Build a DependencyGraph from names, module assignments, and edges."""
assert len(names) == len(modules)
graph = DependencyGraph()
seen_modules: list[str] = []
for name, mod in zip(names, modules):
graph.nodes.append(Node(name=name, module=mod))
graph.node_to_module[name] = mod
if mod not in seen_modules:
seen_modules.append(mod)
if module_order is not None:
graph.modules = module_order
else:
graph.modules = seen_modules
for src, tgt in edge_pairs:
src_mod = graph.node_to_module.get(src, "")
tgt_mod = graph.node_to_module.get(tgt, "")
cross = src_mod != tgt_mod
graph.edges.append(Edge(
source=src, target=tgt, label="dep",
edge_type="field", cross_module=cross,
))
return graph
# ─── DOT Parser Tests ─────────────────────────────────────────────────────────
class TestDotParser(unittest.TestCase):
def test_minimal_dot(self):
dot = '''digraph test {
subgraph cluster_mod1 {
label="mod1";
A [label="A", fillcolor="#fff"];
}
A -> A [label="self", style=dashed, color="#666", penwidth=1];
}'''
g = parse_dot(dot)
self.assertEqual(len(g.nodes), 1)
self.assertEqual(g.nodes[0].name, "A")
self.assertEqual(g.nodes[0].module, "mod1")
self.assertEqual(len(g.edges), 1)
def test_two_module_dot(self):
dot = '''digraph test {
subgraph cluster_alpha {
label="alpha";
X [label="X"];
Y [label="Y"];
}
subgraph cluster_beta {
label="beta";
Z [label="Z"];
}
X -> Y [label="dep", style=dashed, color="#666", penwidth=1];
X -> Z [label="dep", style=solid, color="#00f", penwidth=1.5];
}'''
g = parse_dot(dot)
self.assertEqual(len(g.nodes), 3)
self.assertEqual(len(g.modules), 2)
self.assertEqual(g.modules, ["alpha", "beta"])
self.assertEqual(g.node_to_module["X"], "alpha")
self.assertEqual(g.node_to_module["Z"], "beta")
# Edge classification
intra = [e for e in g.edges if not e.cross_module]
cross = [e for e in g.edges if e.cross_module]
self.assertEqual(len(intra), 1)
self.assertEqual(len(cross), 1)
def test_trait_impl_classification(self):
dot = '''digraph test {
subgraph cluster_m {
label="m";
A [label="A"];
B [label="B"];
}
A -> B [label="impl", style=dotted, color="#666", penwidth=1];
}'''
g = parse_dot(dot)
self.assertEqual(g.edges[0].edge_type, "trait_impl")
def test_real_deps_dot(self):
"""Parse the real deps.dot and verify expected counts."""
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
if not os.path.exists(dot_path):
self.skipTest("deps.dot not found")
with open(dot_path) as f:
dot = f.read()
g = parse_dot(dot)
self.assertEqual(len(g.nodes), 36, f"Expected 36 nodes, got {len(g.nodes)}")
self.assertEqual(len(g.edges), 89, f"Expected 89 edges, got {len(g.edges)}")
self.assertEqual(len(g.modules), 8, f"Expected 8 modules, got {len(g.modules)}")
def test_empty_dot(self):
dot = "digraph empty {}"
g = parse_dot(dot)
self.assertEqual(len(g.nodes), 0)
self.assertEqual(len(g.edges), 0)
# ─── Matrix Construction Tests ────────────────────────────────────────────────
class TestMatrixConstruction(unittest.TestCase):
def test_two_node_adjacency(self):
g = _make_graph(["A", "B"], ["m", "m"], [("A", "B")])
order = get_node_ordering(g)
A = build_adjacency(g, order)
self.assertEqual(A.shape, (2, 2))
idx_a = order.index("A")
idx_b = order.index("B")
self.assertEqual(A[idx_a, idx_b], 1.0)
self.assertEqual(A[idx_b, idx_a], 0.0)
def test_symmetrize_directed(self):
A = np.array([[0, 1, 0],
[0, 0, 1],
[0, 0, 0]], dtype=float)
S = symmetrize(A)
expected = np.array([[0, 1, 0],
[1, 0, 1],
[0, 1, 0]], dtype=float)
np.testing.assert_array_equal(S, expected)
def test_symmetrize_idempotent(self):
"""Symmetrizing an already-symmetric matrix should not change it."""
A = np.array([[0, 1, 1],
[1, 0, 1],
[1, 1, 0]], dtype=float)
S = symmetrize(A)
np.testing.assert_array_equal(S, A)
def test_laplacian_p3(self):
"""Path graph P3: A-B-C."""
A_sym = np.array([[0, 1, 0],
[1, 0, 1],
[0, 1, 0]], dtype=float)
L = build_laplacian(A_sym)
expected = np.array([[1, -1, 0],
[-1, 2, -1],
[0, -1, 1]], dtype=float)
np.testing.assert_array_equal(L, expected)
def test_laplacian_k3(self):
"""Complete graph K3."""
A_sym = np.array([[0, 1, 1],
[1, 0, 1],
[1, 1, 0]], dtype=float)
L = build_laplacian(A_sym)
expected = np.array([[2, -1, -1],
[-1, 2, -1],
[-1, -1, 2]], dtype=float)
np.testing.assert_array_equal(L, expected)
# ─── Spectral Analysis Tests ─────────────────────────────────────────────────
class TestSpectralAnalysis(unittest.TestCase):
def test_p3_eigenvalues(self):
"""Path P3 should have eigenvalues {0, 1, 3}."""
g = _make_graph(["A", "B", "C"], ["m", "m", "m"],
[("A", "B"), ("B", "C")])
s = compute_spectral(g)
np.testing.assert_allclose(sorted(s.eigenvalues), [0, 1, 3], atol=1e-10)
def test_k4_eigenvalues(self):
"""Complete K4 should have eigenvalues {0, 4, 4, 4}."""
names = ["A", "B", "C", "D"]
edges = [(a, b) for a in names for b in names if a != b]
g = _make_graph(names, ["m"] * 4, edges)
s = compute_spectral(g)
np.testing.assert_allclose(sorted(s.eigenvalues), [0, 4, 4, 4], atol=1e-10)
def test_star_s4_fiedler(self):
"""Star graph S4 (center + 3 leaves): lambda_2 = 1."""
g = _make_graph(
["C", "L1", "L2", "L3"], ["m"] * 4,
[("C", "L1"), ("C", "L2"), ("C", "L3")],
)
s = compute_spectral(g)
self.assertAlmostEqual(s.fiedler_value, 1.0, places=10)
def test_disconnected_graph(self):
"""Disconnected graph should have lambda_2 = 0."""
g = _make_graph(
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
[("A", "B"), ("C", "D")],
module_order=["m1", "m2"],
)
s = compute_spectral(g)
self.assertAlmostEqual(s.fiedler_value, 0.0, places=10)
def test_barbell_fiedler_separation(self):
"""Barbell graph: two K3 cliques connected by a bridge.
Fiedler vector should separate the two cliques (different signs).
"""
# Clique 1: A, B, C fully connected
# Clique 2: D, E, F fully connected
# Bridge: C-D
names = ["A", "B", "C", "D", "E", "F"]
edges = [
("A", "B"), ("A", "C"), ("B", "C"),
("D", "E"), ("D", "F"), ("E", "F"),
("C", "D"),
]
g = _make_graph(names, ["m1", "m1", "m1", "m2", "m2", "m2"], edges,
module_order=["m1", "m2"])
s = compute_spectral(g)
# Clique 1 nodes should have same sign, clique 2 opposite
order = s.node_names
fv = s.fiedler_vector
idx = {name: i for i, name in enumerate(order)}
clique1_signs = [np.sign(fv[idx[n]]) for n in ["A", "B", "C"]]
clique2_signs = [np.sign(fv[idx[n]]) for n in ["D", "E", "F"]]
# All in clique 1 should have the same sign
self.assertTrue(all(s == clique1_signs[0] for s in clique1_signs),
f"Clique 1 signs should be uniform: {clique1_signs}")
# All in clique 2 should have the same sign
self.assertTrue(all(s == clique2_signs[0] for s in clique2_signs),
f"Clique 2 signs should be uniform: {clique2_signs}")
# The two cliques should have opposite signs
self.assertNotEqual(clique1_signs[0], clique2_signs[0],
"Cliques should have opposite Fiedler signs")
def test_single_node(self):
g = _make_graph(["A"], ["m"], [])
s = compute_spectral(g)
self.assertEqual(s.fiedler_value, 0.0)
self.assertEqual(len(s.eigenvalues), 1)
def test_empty_graph(self):
g = DependencyGraph()
s = compute_spectral(g)
self.assertEqual(s.fiedler_value, 0.0)
self.assertEqual(len(s.eigenvalues), 0)
# ─── Module Coupling Tests ────────────────────────────────────────────────────
class TestModuleCoupling(unittest.TestCase):
def test_directed_counts(self):
g = _make_graph(
["A", "B", "C"], ["m1", "m1", "m2"],
[("A", "C"), ("B", "C"), ("C", "A")],
module_order=["m1", "m2"],
)
c = compute_module_coupling(g)
# m1->m2: 2 edges (A->C, B->C)
# m2->m1: 1 edge (C->A)
idx_m1 = c.module_names.index("m1")
idx_m2 = c.module_names.index("m2")
self.assertEqual(c.coupling_matrix[idx_m1, idx_m2], 2.0)
self.assertEqual(c.coupling_matrix[idx_m2, idx_m1], 1.0)
def test_cross_module_ratio(self):
g = _make_graph(
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
[("A", "B"), ("A", "C"), ("C", "D")],
module_order=["m1", "m2"],
)
c = compute_module_coupling(g)
# 1 cross-module edge (A->C) out of 3 total
self.assertEqual(c.cross_module_edges, 1)
self.assertEqual(c.total_edges, 3)
def test_intra_only(self):
g = _make_graph(
["A", "B"], ["m1", "m1"],
[("A", "B")],
module_order=["m1"],
)
c = compute_module_coupling(g)
self.assertEqual(c.cross_module_edges, 0)
self.assertEqual(c.coupling_matrix[0, 0], 1.0)
# ─── Complexity Metrics Tests ─────────────────────────────────────────────────
class TestComplexityMetrics(unittest.TestCase):
def test_k4_spectral_entropy(self):
"""K4 has uniform positive eigenvalues {4,4,4} -> entropy = log2(3)."""
evals = np.array([0.0, 4.0, 4.0, 4.0])
H = compute_spectral_entropy(evals)
self.assertAlmostEqual(H, math.log2(3), places=10)
def test_star_entropy_less_than_complete(self):
"""Star graph has less uniform eigenvalues than complete graph."""
# Star S4: eigenvalues are 0, 1, 1, 4
star_evals = np.array([0.0, 1.0, 1.0, 4.0])
k4_evals = np.array([0.0, 4.0, 4.0, 4.0])
H_star = compute_spectral_entropy(star_evals)
H_k4 = compute_spectral_entropy(k4_evals)
self.assertLess(H_star, H_k4)
def test_cci_in_range(self):
"""CCI should always be in [0, 1]."""
for _ in range(20):
n = random.randint(2, 10)
names = [f"N{i}" for i in range(n)]
mods = [f"m{i % 3}" for i in range(n)]
edges = []
for _ in range(random.randint(1, n * 2)):
a, b = random.sample(names, 2)
edges.append((a, b))
g = _make_graph(names, mods, edges,
module_order=sorted(set(mods)))
result = run_analysis(g)
self.assertGreaterEqual(result.metrics.cci, 0.0,
"CCI should be >= 0")
self.assertLessEqual(result.metrics.cci, 1.0,
"CCI should be <= 1")
def test_cci_increases_with_coupling(self):
"""Adding cross-module edges should increase CCI."""
# Base graph: two modules, minimal coupling
g1 = _make_graph(
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
[("A", "B"), ("C", "D"), ("A", "C")],
module_order=["m1", "m2"],
)
# More coupling
g2 = _make_graph(
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
[("A", "B"), ("C", "D"), ("A", "C"), ("A", "D"),
("B", "C"), ("B", "D"), ("C", "A"), ("D", "B")],
module_order=["m1", "m2"],
)
r1 = run_analysis(g1)
r2 = run_analysis(g2)
self.assertLess(r1.metrics.cci, r2.metrics.cci)
def test_connected_components(self):
A_sym = np.array([
[0, 1, 0, 0],
[1, 0, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0],
], dtype=float)
self.assertEqual(count_connected_components(A_sym), 2)
def test_single_component(self):
A_sym = np.array([
[0, 1, 1],
[1, 0, 1],
[1, 1, 0],
], dtype=float)
self.assertEqual(count_connected_components(A_sym), 1)
# ─── Complexity Ladder ────────────────────────────────────────────────────────
class TestComplexityLadder(unittest.TestCase):
"""Verify CCI correctly orders synthetic codebases of increasing complexity."""
def _rung1_linear_chain(self) -> DependencyGraph:
"""5 nodes in a single module, linear chain A->B->C->D->E."""
return _make_graph(
["A", "B", "C", "D", "E"],
["m1"] * 5,
[("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")],
module_order=["m1"],
)
def _rung2_clean_tree(self) -> DependencyGraph:
"""6 nodes across 2 modules, tree with mostly intra-module edges."""
return _make_graph(
["R", "A", "B", "C", "D", "E"],
["core", "core", "core", "util", "util", "util"],
[
("R", "A"), ("A", "B"), ("R", "C"), # intra core
("D", "E"), # intra util
("R", "D"), ("C", "E"), # 2 cross edges
],
module_order=["core", "util"],
)
def _rung3_layered_dag(self) -> DependencyGraph:
"""8 nodes across 3 modules in a layered architecture."""
return _make_graph(
["C1", "C2", "S1", "S2", "S3", "D1", "D2", "D3"],
["ctrl", "ctrl", "svc", "svc", "svc", "data", "data", "data"],
[
("C1", "C2"), # intra ctrl
("S1", "S2"), ("S2", "S3"), # intra svc
("D1", "D2"), ("D2", "D3"), # intra data
("C1", "S1"), ("C1", "S2"), ("C2", "S3"), # ctrl->svc
("S1", "D1"), ("S2", "D2"), ("S3", "D3"), # svc->data
],
module_order=["ctrl", "svc", "data"],
)
def _rung4_diamond_cross(self) -> DependencyGraph:
"""10 nodes across 5 modules with diamond patterns and cross-coupling."""
return _make_graph(
["A1", "A2", "B1", "B2", "C1", "C2", "D1", "D2", "E1", "E2"],
["ma", "ma", "mb", "mb", "mc", "mc", "md", "md", "me", "me"],
[
("A1", "A2"), ("B1", "B2"), ("C1", "C2"), # intra
("D1", "D2"), ("E1", "E2"), # intra
# Diamonds across modules
("A1", "B1"), ("A1", "C1"), ("B1", "D1"), ("C1", "D1"),
("A2", "B2"), ("A2", "C2"), ("B2", "D2"), ("C2", "D2"),
# Extra cross-coupling
("D1", "E1"), ("D2", "E2"), ("B1", "E1"),
],
module_order=["ma", "mb", "mc", "md", "me"],
)
def _rung5_hub_backlinks(self) -> DependencyGraph:
"""10 nodes across 5 modules, hub-dominated with back-edges."""
return _make_graph(
["Hub", "A1", "A2", "B1", "B2", "C1", "C2", "D1", "D2", "D3"],
["core", "sa", "sa", "sb", "sb", "sc", "sc", "sd", "sd", "sd"],
[
("A1", "A2"), ("B1", "B2"), ("C1", "C2"), # intra
("D1", "D2"), ("D2", "D3"), # intra
# Hub connections (cross-module)
("Hub", "A1"), ("Hub", "B1"), ("Hub", "C1"), ("Hub", "D1"),
("A1", "Hub"), ("B1", "Hub"), ("C1", "Hub"),
# Additional cross-module
("A1", "B1"), ("B1", "C1"), ("C1", "D1"),
("A2", "B2"), ("B2", "C2"), ("C2", "D2"),
("A1", "D1"), ("B2", "D3"),
],
module_order=["core", "sa", "sb", "sc", "sd"],
)
def _rung6_dense_mesh(self) -> DependencyGraph:
"""10 nodes across 4 modules with heavy cross-module coupling."""
names = ["X1", "X2", "X3", "Y1", "Y2", "Y3", "Z1", "Z2", "W1", "W2"]
mods = ["mx", "mx", "mx", "my", "my", "my", "mz", "mz", "mw", "mw"]
# Dense cross-module edges
edges = [
# intra
("X1", "X2"), ("X2", "X3"), ("Y1", "Y2"), ("Y2", "Y3"),
("Z1", "Z2"), ("W1", "W2"),
# cross - nearly every module to every other
("X1", "Y1"), ("X1", "Z1"), ("X1", "W1"),
("X2", "Y2"), ("X2", "Z2"), ("X2", "W2"),
("X3", "Y3"), ("X3", "Z1"),
("Y1", "X1"), ("Y1", "Z1"), ("Y1", "W1"),
("Y2", "X2"), ("Y2", "Z2"),
("Y3", "X3"), ("Y3", "W2"),
("Z1", "X1"), ("Z1", "Y1"), ("Z1", "W1"),
("Z2", "X2"), ("Z2", "Y2"), ("Z2", "W2"),
("W1", "X1"), ("W1", "Y1"), ("W1", "Z1"),
("W2", "X2"), ("W2", "Y2"), ("W2", "Z2"),
]
return _make_graph(names, mods, edges,
module_order=["mx", "my", "mz", "mw"])
def test_complexity_ladder(self):
"""CCI must strictly increase across the ladder rungs."""
ladder = [
self._rung1_linear_chain(),
self._rung2_clean_tree(),
self._rung3_layered_dag(),
self._rung4_diamond_cross(),
self._rung5_hub_backlinks(),
self._rung6_dense_mesh(),
]
ccis = [run_analysis(g).metrics.cci for g in ladder]
for i in range(len(ccis) - 1):
self.assertLess(
ccis[i], ccis[i + 1],
f"Rung {i + 1} (CCI={ccis[i]:.4f}) should be less complex "
f"than rung {i + 2} (CCI={ccis[i + 1]:.4f})"
)
# ─── Perturbation Tests ──────────────────────────────────────────────────────
class TestPerturbation(unittest.TestCase):
"""Test that CCI responds correctly to architectural changes on the real graph."""
def _load_real_graph(self) -> DependencyGraph:
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
if not os.path.exists(dot_path):
self.skipTest("deps.dot not found")
with open(dot_path) as f:
return parse_dot(f.read())
def test_remove_most_coupled_module(self):
"""Removing the runtime module should decrease CCI."""
g = self._load_real_graph()
original_cci = run_analysis(g).metrics.cci
# Remove runtime nodes and their edges
g2 = DependencyGraph()
g2.modules = [m for m in g.modules if m != "runtime"]
for node in g.nodes:
if node.module != "runtime":
g2.nodes.append(node)
g2.node_to_module[node.name] = node.module
runtime_nodes = {n.name for n in g.nodes if n.module == "runtime"}
for edge in g.edges:
if edge.source not in runtime_nodes and edge.target not in runtime_nodes:
src_mod = g2.node_to_module.get(edge.source, "")
tgt_mod = g2.node_to_module.get(edge.target, "")
g2.edges.append(Edge(
source=edge.source, target=edge.target, label=edge.label,
edge_type=edge.edge_type,
cross_module=src_mod != tgt_mod,
))
reduced_cci = run_analysis(g2).metrics.cci
self.assertLess(reduced_cci, original_cci,
f"Removing runtime should decrease CCI: "
f"{reduced_cci:.4f} vs {original_cci:.4f}")
def test_add_random_cross_edges(self):
"""Adding 10 random cross-module edges should increase CCI."""
g = self._load_real_graph()
original_cci = run_analysis(g).metrics.cci
g2 = copy.deepcopy(g)
random.seed(42)
node_names = [n.name for n in g2.nodes]
added = 0
attempts = 0
while added < 10 and attempts < 100:
src, tgt = random.sample(node_names, 2)
src_mod = g2.node_to_module[src]
tgt_mod = g2.node_to_module[tgt]
if src_mod != tgt_mod:
g2.edges.append(Edge(
source=src, target=tgt, label="added",
edge_type="field", cross_module=True,
))
added += 1
attempts += 1
augmented_cci = run_analysis(g2).metrics.cci
self.assertGreater(augmented_cci, original_cci,
f"Adding cross-module edges should increase CCI: "
f"{augmented_cci:.4f} vs {original_cci:.4f}")
def test_merge_modules_decreases_cci(self):
"""Merging two small modules into one should decrease CCI.
Merging error + config into a single module reduces cross-module
edges (their mutual and outward coupling consolidates), lowering CCI.
"""
g = self._load_real_graph()
original_cci = run_analysis(g).metrics.cci
# Merge error and config into "error_config"
merge_set = {"error", "config"}
merged_name = "error_config"
g2 = DependencyGraph()
g2.modules = [merged_name if m in merge_set else m
for m in g.modules if m not in merge_set]
if merged_name not in g2.modules:
g2.modules.insert(0, merged_name)
# Deduplicate
seen = set()
g2.modules = [m for m in g2.modules if not (m in seen or seen.add(m))]
for node in g.nodes:
new_mod = merged_name if node.module in merge_set else node.module
g2.nodes.append(Node(name=node.name, module=new_mod))
g2.node_to_module[node.name] = new_mod
for edge in g.edges:
src_mod = g2.node_to_module.get(edge.source, "")
tgt_mod = g2.node_to_module.get(edge.target, "")
g2.edges.append(Edge(
source=edge.source, target=edge.target, label=edge.label,
edge_type=edge.edge_type,
cross_module=src_mod != tgt_mod,
))
merged_cci = run_analysis(g2).metrics.cci
self.assertLess(merged_cci, original_cci,
f"Merging error+config should decrease CCI: "
f"{merged_cci:.4f} vs {original_cci:.4f}")
# ─── Integration Tests ────────────────────────────────────────────────────────
class TestIntegration(unittest.TestCase):
def test_full_pipeline_real_graph(self):
"""Run full pipeline on real deps.dot and sanity-check outputs."""
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
if not os.path.exists(dot_path):
self.skipTest("deps.dot not found")
with open(dot_path) as f:
graph = parse_dot(f.read())
result = run_analysis(graph)
# Basic sanity checks
self.assertEqual(result.metrics.n_nodes, 36)
self.assertEqual(result.metrics.n_edges, 89)
self.assertEqual(result.metrics.n_modules, 8)
# Connected graph -> lambda_2 > 0
self.assertGreater(result.spectral.fiedler_value, 0,
"Connected graph should have lambda_2 > 0")
# CCI should be in a reasonable range for a well-structured codebase
self.assertGreater(result.metrics.cci, 0.05)
self.assertLess(result.metrics.cci, 0.9)
# Eigenvalues should be non-negative (Laplacian property)
self.assertTrue(np.all(result.spectral.eigenvalues >= -1e-10),
"Laplacian eigenvalues should be non-negative")
# First eigenvalue should be 0
self.assertAlmostEqual(result.spectral.eigenvalues[0], 0.0, places=8)
def test_report_generation(self):
"""Verify report contains expected sections."""
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
if not os.path.exists(dot_path):
self.skipTest("deps.dot not found")
with open(dot_path) as f:
graph = parse_dot(f.read())
result = run_analysis(graph)
report = generate_report(result)
self.assertIn("GRAPH SUMMARY", report)
self.assertIn("LAPLACIAN EIGENVALUE SPECTRUM", report)
self.assertIn("FIEDLER VECTOR", report)
self.assertIn("MODULE COUPLING MATRIX", report)
self.assertIn("CONNECTOME COMPLEXITY INDEX", report)
def test_json_output(self):
"""Verify JSON output is well-formed and contains expected keys."""
g = _make_graph(
["A", "B", "C"], ["m1", "m1", "m2"],
[("A", "B"), ("A", "C")],
module_order=["m1", "m2"],
)
result = run_analysis(g)
d = metrics_to_dict(result)
self.assertIn("graph", d)
self.assertIn("spectral", d)
self.assertIn("module_coupling", d)
self.assertIn("metrics", d)
self.assertEqual(d["graph"]["n_nodes"], 3)
self.assertIsInstance(d["spectral"]["eigenvalues"], list)
self.assertIsInstance(d["metrics"]["cci"], float)
# Should be JSON-serializable
json_str = json.dumps(d)
self.assertIsInstance(json_str, str)
def test_dashboard_generation(self):
"""Verify dashboard PNG can be generated without errors."""
try:
import matplotlib
except ImportError:
self.skipTest("matplotlib not available")
g = _make_graph(
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
[("A", "B"), ("A", "C"), ("C", "D")],
module_order=["m1", "m2"],
)
result = run_analysis(g)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
path = f.name
try:
from spectral_analysis import generate_dashboard
generate_dashboard(result, path)
self.assertTrue(os.path.exists(path))
self.assertGreater(os.path.getsize(path), 1000,
"Dashboard should be a non-trivial PNG")
finally:
os.unlink(path)
if __name__ == "__main__":
unittest.main()