graph-theory-optimizing #15
15 changed files with 3620 additions and 77 deletions
15
README.md
15
README.md
|
|
@ -65,3 +65,18 @@ cargo test --features stress # stress tests
|
||||||
cargo run --bin bench --release # benchmarks
|
cargo run --bin bench --release # benchmarks
|
||||||
cargo run --example hello
|
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
76
docs/connectome.md
Normal 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.
|
||||||
738
docs/connectome/connectome_dashboard.html
Normal file
738
docs/connectome/connectome_dashboard.html
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/connectome/connectome_dashboard.png
Normal file
BIN
docs/connectome/connectome_dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
280
docs/connectome/connectome_metrics.json
Normal file
280
docs/connectome/connectome_metrics.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
125
docs/connectome/connectome_report.txt
Normal file
125
docs/connectome/connectome_report.txt
Normal 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
125
spectral_report.txt
Normal 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
|
||||||
|
|
||||||
|
========================================================================
|
||||||
47
src/actor.rs
47
src/actor.rs
|
|
@ -1,6 +1,6 @@
|
||||||
use std::any::Any;
|
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
|
/// The primary trait defining data that can be passed to and from actor processes
|
||||||
pub trait Message: 'static + Sized + Clone + Send + Sync {}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ use std::cell::RefCell;
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
use pyo3::types::PyModule;
|
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::config::{BackoffPolicy, RuntimeConfig};
|
||||||
use crate::runtime::{Ctx, Inbox, Runtime, RuntimeHandle};
|
use crate::runtime::{Inbox, Runtime, RuntimeHandle};
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
// ─── PyMsg newtype ───────────────────────────────────────────────────────────
|
// ─── PyMsg newtype ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -65,43 +65,8 @@ impl RuntimeHandle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actor syscall interface — passed to `ActorInterface::handle()`.
|
// Re-export Ctx and ContextInner for backwards compatibility
|
||||||
///
|
pub use crate::actor::{ContextInner, Ctx};
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Type-erased sender for external inboxes.
|
/// Type-erased sender for external inboxes.
|
||||||
pub(crate) trait SenderT: Send + Sync {
|
pub(crate) trait SenderT: Send + Sync {
|
||||||
|
|
@ -287,7 +252,7 @@ impl Runtime {
|
||||||
inbox_registry: &rt_clone.inbox_registry,
|
inbox_registry: &rt_clone.inbox_registry,
|
||||||
config: &rt_clone.config,
|
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);
|
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 {
|
impl ContextInner for Runtime {
|
||||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
||||||
match self.address_map.lookup(&addr) {
|
match self.address_map.lookup(&addr) {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
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::address_map::{AddressMap, Placement, WorkerId};
|
||||||
use crate::channel::{Receiver, Sender};
|
use crate::channel::{Receiver, Sender};
|
||||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
use crate::config::RuntimeConfig;
|
||||||
use crate::runtime::{ContextInner, Ctx, Envelope, InboxRegistry};
|
use crate::runtime::{Envelope, InboxRegistry};
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
/// Per-worker stats published via atomics. Readable from any thread.
|
/// Per-worker stats published via atomics. Readable from any thread.
|
||||||
|
|
@ -90,12 +90,7 @@ impl Worker {
|
||||||
{
|
{
|
||||||
let worker_ctx = WorkerContext {
|
let worker_ctx = WorkerContext {
|
||||||
worker_id: self.id,
|
worker_id: self.id,
|
||||||
address_map: tc.address_map,
|
tc,
|
||||||
transfer_txs: tc.transfer_txs,
|
|
||||||
spawn_txs: tc.spawn_txs,
|
|
||||||
placement: tc.placement,
|
|
||||||
inbox_registry: tc.inbox_registry,
|
|
||||||
config: tc.config,
|
|
||||||
pending_local: &pending_local,
|
pending_local: &pending_local,
|
||||||
};
|
};
|
||||||
processed = self.pool.tick_all(&worker_ctx);
|
processed = self.pool.tick_all(&worker_ctx);
|
||||||
|
|
@ -121,7 +116,8 @@ impl Worker {
|
||||||
did_work
|
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;
|
let mut idle_count: u32 = 0;
|
||||||
while is_running.load(Ordering::Acquire) {
|
while is_running.load(Ordering::Acquire) {
|
||||||
let did_work = self.tick_once(tc);
|
let did_work = self.tick_once(tc);
|
||||||
|
|
@ -151,18 +147,13 @@ impl Worker {
|
||||||
/// Cross-worker sends go through the transfer queue.
|
/// Cross-worker sends go through the transfer queue.
|
||||||
struct WorkerContext<'a> {
|
struct WorkerContext<'a> {
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
address_map: &'a AddressMap,
|
tc: &'a TickContext<'a>,
|
||||||
transfer_txs: &'a [Sender<Envelope>],
|
|
||||||
spawn_txs: &'a [Sender<(ActorAddress, Box<dyn AnyActor>)>],
|
|
||||||
placement: &'a Placement,
|
|
||||||
inbox_registry: &'a InboxRegistry,
|
|
||||||
config: &'a RuntimeConfig,
|
|
||||||
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextInner for WorkerContext<'_> {
|
impl ContextInner for WorkerContext<'_> {
|
||||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
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 => {
|
Some(wid) if wid == self.worker_id => {
|
||||||
// Same worker: buffer for local delivery (after current tick round)
|
// Same worker: buffer for local delivery (after current tick round)
|
||||||
self.pending_local.borrow_mut().push((addr, msg));
|
self.pending_local.borrow_mut().push((addr, msg));
|
||||||
|
|
@ -171,26 +162,26 @@ impl ContextInner for WorkerContext<'_> {
|
||||||
Some(wid) => {
|
Some(wid) => {
|
||||||
// Cross worker: envelope through transfer queue
|
// Cross worker: envelope through transfer queue
|
||||||
let envelope = Envelope::new(addr, msg);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Try inbox registry (external inboxes)
|
// 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> {
|
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error> {
|
||||||
let worker_id = self.placement.next_worker();
|
let worker_id = self.tc.placement.next_worker();
|
||||||
self.address_map.insert(addr, worker_id);
|
self.tc.address_map.insert(addr, worker_id);
|
||||||
self.spawn_txs[worker_id.as_usize()]
|
self.tc.spawn_txs[worker_id.as_usize()]
|
||||||
.try_send((addr, actor))
|
.try_send((addr, actor))
|
||||||
.map_err(|_| Error::from("Spawn queue full"))
|
.map_err(|_| Error::from("Spawn queue full"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mailbox_waterlevel(&self) -> usize {
|
fn mailbox_waterlevel(&self) -> usize {
|
||||||
self.config.mailbox_waterlevel
|
self.tc.config.mailbox_waterlevel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use crate::actor::{ActorAddress, AnyActor};
|
use crate::actor::{ActorAddress, AnyActor, Ctx};
|
||||||
use crate::address_map::{AddressMap, Placement, WorkerId};
|
use crate::address_map::{AddressMap, Placement, WorkerId};
|
||||||
use crate::channel::Receiver;
|
use crate::channel::Receiver;
|
||||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
use crate::config::RuntimeConfig;
|
||||||
use crate::runtime::{Ctx, Envelope, InboxRegistry};
|
use crate::runtime::{Envelope, InboxRegistry};
|
||||||
|
|
||||||
use super::{TickContext, Worker, WorkerStats};
|
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 mut worker = Worker::new(WorkerId(0), transfer_rx, spawn_rx, stats);
|
||||||
|
|
||||||
let is_running = AtomicBool::new(false);
|
let is_running = AtomicBool::new(false);
|
||||||
let backoff = BackoffPolicy::default();
|
|
||||||
let address_map = AddressMap::new();
|
let address_map = AddressMap::new();
|
||||||
let placement = Placement::new(1);
|
let placement = Placement::new(1);
|
||||||
let inbox_registry = InboxRegistry::new();
|
let inbox_registry = InboxRegistry::new();
|
||||||
|
|
@ -415,6 +414,6 @@ fn run_loop_stops_on_shutdown() {
|
||||||
};
|
};
|
||||||
|
|
||||||
thread::scope(|s| {
|
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
2
tools/spectral/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__
|
||||||
|
output/*
|
||||||
1463
tools/spectral/spectral_analysis.py
Normal file
1463
tools/spectral/spectral_analysis.py
Normal file
File diff suppressed because it is too large
Load diff
727
tools/spectral/test_spectral.py
Normal file
727
tools/spectral/test_spectral.py
Normal 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()
|
||||||
Loading…
Reference in a new issue