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 --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 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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