Compare commits

...

3 commits

Author SHA1 Message Date
Zachery Aaron Shores-Chmielewski
6897d71e4b feat: refactor based on spectral spectral_analysis
Asking the agent to refactor to reduce spectral complexity, it worked. Trivial change, but this did reduce code complexity.
2026-02-07 17:36:45 +07:00
Zachery Aaron Shores-Chmielewski
89be164c19 feat: spectral analysis report
Generates a report on the connectivity properties of the code interdependency DAG.
2026-02-07 17:36:07 +07:00
de0f3960f4 feat: docs and README (#13)
Update the docs and readme with all the changes we've made
2026-02-06 17:12:04 +00:00
19 changed files with 4314 additions and 120 deletions

298
README.md
View file

@ -1,67 +1,279 @@
# swactor # swactor
(S)mall (W)ASM-compatible (actor) library
## Useful Small, WASM-compatible actor runtime for Rust, with Python and WebAssembly
bindings.
View code dependency DAG ## Quick Start (Rust)
```bash
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
```
## Quick example
```rust ```rust
use swactor::{ use swactor::actor::{ActorAddress, ActorInterface};
Ctx, use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
actor::{ActorAddress, ActorInterface},
runtime::{Runtime, RuntimeConfig},
};
#[derive(Debug, Default)] #[derive(Clone)]
struct Greeter { num_greeted: usize } struct Greet { name: String, reply_to: ActorAddress }
#[derive(Debug, Default, Clone)] #[derive(Clone)]
struct GreetMessage { who: String, return_addr: ActorAddress } struct Greeting(String);
#[derive(Debug, Default, Clone)] struct Greeter;
struct GreetResponse(String);
impl ActorInterface for Greeter { impl ActorInterface for Greeter {
type Incoming = GreetMessage; type Incoming = Greet;
type Response = GreetResponse; type Response = Greeting;
fn handle(&mut self, ctx: &Ctx, msg: GreetMessage) { fn handle(&mut self, ctx: &Ctx, msg: Greet) {
let res = GreetResponse(format!("Hello, {}!", msg.who)); let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
self.num_greeted += 1;
if let Err(_) = ctx.send(msg.return_addr, res) {
self.num_greeted -= 1;
}
} }
} }
fn main() { fn main() {
let rt = Runtime::new(RuntimeConfig::default()); let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(Greeter::default()).expect("failed to spawn"); let addr = rt.spawn(Greeter).unwrap();
let inbox = rt.new_inbox::<Greeting>().unwrap();
let inbox = rt.new_inbox::<GreetResponse>().unwrap(); rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
rt.send_to(addr, GreetMessage { rt.tick();
who: "world".into(), rt.tick();
return_addr: *inbox.addr(),
}).unwrap();
for _ in 0..3 { rt.tick(); } println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!"
let resp = inbox.try_recv().expect("should have response");
println!("{}", resp.0); // "Hello, world!"
} }
``` ```
## Build & test ## Quick Start (Python)
```bash
uv pip install . # builds the Rust extension automatically
```
Single-threaded — caller drives each tick:
```python
from swactor import Runtime
def echo(ctx, msg):
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
rt = Runtime()
addr = rt.spawn(echo)
inbox = rt.inbox()
rt.send(addr, {"name": "world", "reply_to": inbox.addr})
rt.tick()
print(inbox.try_recv()) # "hello, world!"
```
Multi-threaded — workers run on background threads:
```python
import asyncio
from swactor import Runtime, RuntimeConfig
async def main():
rt = Runtime(RuntimeConfig(num_threads=2))
def echo(ctx, msg):
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
addr = rt.spawn(echo)
inbox = rt.inbox()
handle = rt.run() # spawns worker threads, consumes rt
for name in ["alice", "bob", "charlie"]:
handle.send(addr, {"name": name, "reply_to": inbox.addr})
while (reply := inbox.try_recv()) is None:
await asyncio.sleep(0.01)
print(reply)
handle.shutdown()
handle.join()
asyncio.run(main())
```
## Quick Start (WASM)
The `wasm/` crate wraps swactor for use from JavaScript via `wasm-bindgen`.
It runs single-threaded with the caller driving `tick()` — a natural fit
for game loops, simulations, or any frame-based update cycle.
```bash
cd wasm && wasm-pack build --target nodejs # or --target web
```
```javascript
import { SwactorRuntime } from "./wasm/pkg/swactor_wasm.js";
const rt = new SwactorRuntime();
// spawn a counter actor — accumulates values sent to it
const counter = rt.spawn_counter();
// spawn a relay that forwards messages to the counter
const relay = rt.spawn_relay(counter);
// send through the relay
rt.send(relay, 5);
rt.send(relay, 7);
rt.tick(); // relay receives and forwards
rt.tick(); // counter receives forwarded messages
// drain results from the inbox
let v;
while ((v = rt.try_recv()) !== undefined) {
console.log(v); // 5, then 12
}
rt.free();
```
The WASM crate uses the `no_random` feature (deterministic address
generation) so there's no dependency on system RNG.
## Running the Examples
```bash
cargo run --example hello # single actor, request/response
cargo run --example ring # 500 actors in a ring topology
```
## Multi-threaded Mode
Pass `num_threads` in the config. The runtime spawns OS threads and runs
workers autonomously — no `tick()` calls needed.
```rust
let mut config = RuntimeConfig::default();
config.num_threads = 4;
let rt = Runtime::new(config);
let addr = rt.spawn(MyActor::default()).unwrap();
let handle = rt.run().unwrap(); // consumes rt, spawns 4 threads
// use handle.runtime to spawn/send while workers run
handle.runtime.send_to(addr, MyMsg).unwrap();
handle.shutdown();
handle.join();
```
## Architecture
The runtime is layered: **Runtime** → **Workers** → **ActorPool** → **Actors**.
```
┌─ Runtime (Arc, shared) ──────────────────────────────────────┐
│ │
│ AddressMap Placement InboxRegistry is_running │
│ (addr→worker) (round-robin) (external inboxes) (AtomicBool) │
│ │
│ transfer_txs[] spawn_txs[] │
│ (one Sender per worker) (one Sender per worker) │
│ │
└───────┬───────────────┬───────────────┬───────────────────────┘
│ │ │
v v v
┌─ Worker 0 ──┐ ┌─ Worker 1 ──┐ ┌─ Worker 2 ──┐
│ ActorPool │ │ ActorPool │ │ ActorPool │
│ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │
│ │mailbox │ │ │ │mailbox │ │ │ │mailbox │ │
│ │ actor │ │ │ │ actor │ │ │ │ actor │ │
│ └────────┘ │ │ └────────┘ │ │ └────────┘ │
│ ┌────────┐ │ │ ┌────────┐ │ │ │
│ │mailbox │ │ │ │mailbox │ │ └──────────────┘
│ │ actor │ │ │ │ actor │ │
│ └────────┘ │ │ └────────┘ │
└──────────────┘ └──────────────┘
```
Each worker runs a **four-phase tick loop**:
1. **Drain spawn queue** — add newly spawned actors to the pool
2. **Drain transfer queue** — deliver cross-worker messages to mailboxes
3. **Tick all actors** — pop messages, call handlers, buffer outgoing sends
4. **Drain pending local** — deliver same-worker messages for the next tick
Messages are type-erased (`Box<dyn Any + Send>`) in transit and downcast
back to the concrete type at delivery. Mismatched types are silently dropped.
Detailed architecture docs live in `docs/`:
| Document | Covers |
|----------|--------|
| [Worker Thread](docs/worker-thread.md) | Tick phases, backoff, message routing, full system topology |
| [Runtime](docs/runtime.md) | Runtime, Ctx, Inbox, RuntimeHandle, stats |
| [Actor Model](docs/actor-model.md) | Traits, type erasure, addresses |
| [Channels & Shared State](docs/channels.md) | HybridChannel, AddressMap, Placement |
## Source Layout
```
src/
├── lib.rs module root, feature gates, get_random()
├── actor.rs Message, ActorInterface, ActorAddress, type erasure
├── runtime.rs Runtime, Ctx, Inbox, RuntimeHandle, InboxRegistry
├── worker/
│ ├── mod.rs Worker, WorkerContext, ActorPool, tick loop
│ └── tests.rs worker unit tests with step-based DSL
├── channel.rs HybridChannel (ArrayQueue + SegQueue), Sender/Receiver
├── config.rs RuntimeConfig, BackoffPolicy
├── address_map.rs AddressMap (RwLock<HashMap>), Placement (round-robin)
├── error.rs Error type
└── python.rs PyO3 bindings (feature = "python")
wasm/
├── Cargo.toml separate crate, depends on swactor with no_random
├── src/lib.rs wasm-bindgen wrapper (SwactorRuntime)
└── test.mjs Node.js test suite
examples/
├── hello.rs echo actor
├── ring.rs ring topology
└── python/
├── hello_single_thread.py minimal Python example
├── hello_async.py multi-threaded + asyncio
└── getting_started.ipynb Jupyter notebook
tests/
├── runtime_api.rs single + multi-thread integration tests
├── stats_demo.rs stats snapshot tests
└── test_python.py Python binding tests
```
## Building & Testing
```bash
# Rust
cargo test # run all tests
cargo run --example hello # run an example
cargo bench # benchmarks (criterion)
# Python bindings (requires Rust toolchain on PATH)
uv pip install . # build + install
uv run python3 tests/test_python.py # run Python tests
# WASM bindings
cd wasm && wasm-pack build --target nodejs
node test.mjs # run WASM tests
```
## Feature Flags
| Flag | Default | What it does |
|------|---------|--------------|
| `getrandom` | yes | System RNG for actor addresses |
| `no_random` | no | Deterministic counter (for WASM / reproducible tests) |
| `python` | no | PyO3 bindings, builds cdylib wheel |
## Connectome analysis
Spectral analysis of the internal dependency graph, producing a Connectome Complexity Index (CCI) and visual dashboards.
```sh ```sh
cargo build # Generate the dependency DAG
cargo test cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
cargo test --features stress # stress tests
cargo run --bin bench --release # benchmarks # Run spectral analysis (outputs to docs/connectome/)
cargo run --example hello 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.

138
docs/actor-model.md Normal file
View file

@ -0,0 +1,138 @@
# Actor Model
Swactor's actor model is intentionally minimal. An actor is a struct that
implements one trait, receives one message type, and communicates only
through `Ctx`.
## Defining an Actor
```rust
use swactor::actor::ActorInterface;
use swactor::runtime::Ctx;
#[derive(Debug, Default, Clone)]
struct Ping { return_addr: ActorAddress }
#[derive(Debug, Default, Clone)]
struct Pong;
struct MyActor {
count: usize,
}
impl ActorInterface for MyActor {
type Incoming = Ping;
type Response = Pong; // not enforced at runtime — a documentation hint
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
let _ = ctx.send(msg.return_addr, Pong);
}
}
```
That's it. No lifecycle hooks, no supervision trees, no async. Just a
`handle` method.
## The Traits
```
┌─ Message ─────────────────────────────────────────────────────────────────┐
│ │
│ trait Message: 'static + Sized + Clone + Send + Sync {} │
│ │
│ Blanket-implemented for any type that meets the bounds. │
│ You never implement this manually. │
│ │
│ Why Clone + Send + Sync? │
│ Clone — messages may be duplicated (Python bindings, stats, etc.) │
│ Send — messages cross thread boundaries │
│ Sync — required by the type-erased Any + Send path │
│ │
└───────────────────────────────────────────────────────────────────────────┘
┌─ ActorInterface ──────────────────────────────────────────────────────────┐
│ │
│ trait ActorInterface: 'static + Send { │
│ type Incoming: Message; │
│ type Response: Message; │
│ fn handle(&mut self, ctx: &Ctx, msg: Self::Incoming); │
│ } │
│ │
│ This is what you implement. The actor owns mutable state (&mut self) │
│ and receives typed messages. │
│ │
│ Actors are Send but NOT Sync — only one worker thread ever touches │
│ a given actor. │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
## Type Erasure
Actors in the runtime are stored as `Box<dyn AnyActor>`, which erases the
concrete type. Messages are stored as `Box<dyn Any + Send>`. Type checking
happens at delivery time via `downcast`:
```
compile time runtime
─────────── ───────
ctx.send(addr, msg)
│
v
Box::new(msg) as Box<dyn Any + Send> -- type erased here
│
v
enqueued in mailbox (VecDeque<Box<dyn Any + Send>>)
│
v
actor.handle_any(ctx, msg)
│
v
msg.downcast::<A::Incoming>() -- type recovered here
│
┌────┴────┐
│ │
ok err
│ │
v v
A.handle silently dropped
(ctx,msg)
```
Why silent drop? In a dynamic system (especially with Python bindings),
type mismatches aren't crashes — they're routing errors. The actor simply
ignores messages it doesn't understand.
## ActorAddress
```
┌─ ActorAddress ────────────────────────────────────────────────────────────┐
│ │
│ pub struct ActorAddress(pub [u8; 32]); │
│ │
│ 32 random bytes — globally unique, no coordination needed. │
│ Generated via get_random() (system RNG or deterministic counter │
│ for WASM builds). │
│ │
│ Derives: Debug, Default, Clone, Copy, PartialEq, Eq, Hash │
│ │
│ Used as keys in: │
│ AddressMap (actor → worker lookup) │
│ ActorPool (actor → mailbox + state) │
│ InboxRegistry (external inbox lookup) │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
## Where Things Live in the Code
| Concept | File | Key lines |
|---------|------|-----------|
| `Message` trait | `src/actor.rs` | blanket impl |
| `ActorInterface` trait | `src/actor.rs` | user-facing trait |
| `ActorAddress` | `src/actor.rs` | 32-byte random ID |
| `Actor<A>` wrapper | `src/actor.rs` | wraps user state |
| `AnyActor` trait | `src/actor.rs` | type-erased handler |
| `ActorPool` | `src/worker/mod.rs` | per-worker storage |
| `ActorSlot` | `src/worker/mod.rs` | mailbox + actor pair |

124
docs/channels.md Normal file
View file

@ -0,0 +1,124 @@
# Channels & Shared State
All communication between workers (and between the `Runtime` and workers)
goes through lock-free channels. There are no mutexes in the hot path.
## HybridChannel
The core primitive. A lock-free MPSC queue with bounded fast path and
unbounded overflow.
```
┌─ HybridChannel<T> ───────────────────────────────────────────────────────┐
│ │
│ ┌─ ring: ArrayQueue<T> (crossbeam) ─────────────────────────────────┐ │
│ │ Pre-allocated, fixed capacity, lock-free CAS │ │
│ │ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │
│ │ └───┴───┴───┴───┴───┴───┴───┴───┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ overflow: SegQueue<T> (crossbeam) ───────────────────────────────┐ │
│ │ Unbounded linked-list queue, lock-free │ │
│ │ Only used when ring is full │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ push(v): │
│ ring.push(v) → Ok: done │
│ ring.push(v) → Err: overflow.push(v) │
│ │
│ pop(): │
│ ring.pop() → Some: return it │
│ overflow.pop() → Some: return it │
│ otherwise → None │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
The fast path (ring) avoids allocation. The overflow (SegQueue) acts as a
safety net — the system never drops messages due to capacity, but
performance degrades under sustained overflow.
## Sender and Receiver
```
┌─ Sender<T> ──────────┐ ┌─ Receiver<T> ────────────┐
│ │ │ │
│ queue: Arc<Hybrid> │──same──▶│ queue: Arc<Hybrid> │
│ │ Arc │ │
│ try_send(v) → push │ │ try_recv() → pop │
│ │ │ │
│ Clone: new_sender() │ │ Single consumer │
│ (clones the Arc) │ │ (not Clone) │
│ │ │ │
└───────────────────────┘ └───────────────────────────┘
```
`Sender` is `Clone` — multiple producers can send into the same channel.
`Receiver` is not `Clone` — exactly one consumer drains it.
## What Channels Exist
Each worker gets two inbound channels, created at `Runtime::new()` time:
```
Per Worker:
transfer channel: carries Envelope (messages to actors)
Senders: Runtime, other workers (via TickContext)
Receiver: this Worker
spawn channel: carries (ActorAddress, Box<dyn AnyActor>)
Senders: Runtime, other workers (via TickContext)
Receiver: this Worker
```
For N workers, the runtime holds N transfer senders and N spawn senders.
Every worker can reach every other worker's queues through `TickContext`.
## AddressMap
Global directory mapping actor addresses to the worker that owns them.
```
┌─ AddressMap ──────────────────────────────────────────────────────────────┐
│ │
│ RwLock< HashMap<ActorAddress, WorkerId> > │
│ │
│ Read path (very frequent): │
│ Every ctx.send() and Runtime.send_to() does a lookup. │
│ RwLock allows concurrent readers — no contention. │
│ │
│ Write path (rare): │
│ Only on spawn. Takes exclusive lock briefly. │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
## Placement
Decides which worker gets a newly spawned actor.
```
┌─ Placement ───────────────────────────────────────────────────────────────┐
│ │
│ next: AtomicUsize │
│ num_workers: usize │
│ │
│ next_worker() → WorkerId( next.fetch_add(1) % num_workers ) │
│ │
│ Simple round-robin. No load balancing, no affinity. │
│ Actors stay on their assigned worker for life. │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
## Where Things Live in the Code
| Concept | File |
|---------|------|
| `HybridChannel`, `Sender`, `Receiver` | `src/channel.rs` |
| `AddressMap`, `WorkerId`, `Placement` | `src/address_map.rs` |
| `Envelope` | `src/runtime.rs` |
| `InboxRegistry` | `src/runtime.rs` |
| `RuntimeConfig`, `BackoffPolicy` | `src/config.rs` |

76
docs/connectome.md Normal file
View file

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View file

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

View file

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

185
docs/runtime.md Normal file
View file

@ -0,0 +1,185 @@
# Runtime Architecture
The `Runtime` is the main entry point. It creates workers, owns the shared
infrastructure, and provides the public API for spawning actors and sending
messages.
## Structure
```
┌─ Runtime ─────────────────────────────────────────────────────────────────┐
│ │
│ config: RuntimeConfig -- tunable knobs (see config.rs) │
│ is_running: AtomicBool -- shutdown flag, read by all workers │
│ │
│ ┌─ Shared State (lives on Arc<Runtime>) ──────────────────────────────┐ │
│ │ │ │
│ │ address_map: Arc<AddressMap> -- actor -> worker lookup │ │
│ │ inbox_registry: Arc<InboxRegistry> -- external inbox delivery │ │
│ │ placement: Placement -- round-robin worker picker │ │
│ │ worker_stats: Vec<Arc<WorkerStats>> -- atomic stat counters │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Channel Endpoints ─────────────────────────────────────────────────┐ │
│ │ │ │
│ │ transfer_txs: Vec<Sender<Envelope>> -- one per worker (messages) │ │
│ │ spawn_txs: Vec<Sender<(Addr,Box)>> -- one per worker (spawns) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Mode ──────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ SINGLE-THREADED: single_worker: Some(RefCell<Worker>) │ │
│ │ MULTI-THREADED: pending_workers: Some(Vec<Worker>) │ │
│ │ │ │
│ │ After run() is called, both are None — workers move to threads. │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
## Two Modes of Operation
```
SINGLE-THREADED MULTI-THREADED
────────────── ──────────────
let rt = Runtime::new(config); let mut config = RuntimeConfig::default();
config.num_threads = 4;
let rt = Runtime::new(config);
rt.spawn(my_actor)?; rt.spawn(my_actor)?;
rt.send_to(addr, msg)?; rt.send_to(addr, msg)?;
loop { rt.tick(); } let handle = rt.run()?;
^ ^
| |
caller drives each tick workers run on their own threads
handle.join() blocks until shutdown
```
Single-threaded mode keeps the `Worker` inline and requires the caller to
call `rt.tick()` to advance the simulation. This is useful for deterministic
testing, WASM, or game loops where you want frame-level control.
Multi-threaded mode consumes the `Runtime` via `run()`, wraps it in an
`Arc`, and spawns one OS thread per worker. Returns a `RuntimeHandle`.
## Ctx — the Actor Syscall Interface
When an actor's `handle()` method runs, it receives a `&Ctx`. This is the
only way for actors to interact with the outside world.
```
┌─ Ctx<'a> ─────────────────────────────────────────────────────────────────┐
│ │
│ inner: &dyn ContextInner -- polymorphic dispatch │
│ self_addr: ActorAddress -- address of the current actor │
│ │
│ ┌─ Public API ────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ctx.self_addr() -> ActorAddress │ │
│ │ ctx.send(addr, msg) -> Result<(), Error> │ │
│ │ ctx.spawn(actor) -> Result<ActorAddress, Error> │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ ContextInner dispatch ─────────────────────────────────────────────┐ │
│ │ │ │
│ │ In single-threaded mode: inner = &Runtime │ │
│ │ send → transfer_txs[wid], spawn → spawn_txs[wid] │ │
│ │ │ │
│ │ In multi-threaded mode: inner = &WorkerContext │ │
│ │ send → pending_local (same worker) or transfer_txs (cross) │ │
│ │ spawn → spawn_txs[target_wid] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
The `ContextInner` trait is the object-safe bridge. It's not public — actors
interact only through the typed `Ctx` wrapper.
## Inbox — Receiving Messages Outside the Runtime
`Inbox<M>` lets external code (the "main" thread, a game loop, an HTTP
handler, etc.) receive typed messages from actors.
```
┌─ Creation ─────────────────────────────────────────────────────────────┐
│ │
│ let inbox = rt.new_inbox::<MyResponse>()?; │
│ │
│ Under the hood: │
│ addr = ActorAddress::new_random() │
│ receiver = Receiver::<M>::new(capacity) │
│ sender = receiver.new_sender() │
│ inbox_registry.register(addr, Arc::new(sender)) │
│ │
└────────────────────────────────────────────────────────────────────────┘
┌─ Usage ────────────────────────────────────────────────────────────────┐
│ │
│ // give inbox.addr() to actors so they know where to reply │
│ rt.send_to(greeter, GreetMsg { return_addr: *inbox.addr() })?; │
│ │
│ // poll for responses │
│ if let Some(msg) = inbox.try_recv() { ... } │
│ │
└────────────────────────────────────────────────────────────────────────┘
┌─ Delivery Path ────────────────────────────────────────────────────────┐
│ │
│ actor calls ctx.send(inbox_addr, response) │
│ │ │
│ v │
│ address_map.lookup(inbox_addr) → None (inboxes aren't actors) │
│ │ │
│ v │
│ inbox_registry.try_deliver(addr, msg) │
│ │ │
│ v │
│ downcast Box<Any> → M, push into Receiver<M> │
│ │
└────────────────────────────────────────────────────────────────────────┘
```
## RuntimeHandle
Returned by `run()`. Holds `Arc<Runtime>` and the thread `JoinHandle`s.
```
┌─ RuntimeHandle ───────────────────────────────────────────────────────────┐
│ │
│ runtime: Arc<Runtime> -- still usable for spawn/send/stats │
│ threads: Vec<JoinHandle<()>> -- one per worker │
│ │
│ handle.shutdown() → runtime.is_running.store(false) │
│ handle.join() → waits for all worker threads to exit │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
## RuntimeStats
`rt.stats()` (or `handle.runtime.stats()`) returns a snapshot:
```
┌─ RuntimeStats ────────────────────────────────────────────────────────────┐
│ │
│ num_workers: usize │
│ actors: Vec<(ActorAddress, worker_id)> -- from AddressMap snapshot │
│ workers: Vec<WorkerInfo> │
│ ├─ id: usize │
│ ├─ num_actors: usize -- from atomic counter │
│ ├─ mailbox_depth: usize -- total queued messages │
│ └─ messages_processed: u64 -- cumulative count │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
Stats are published by workers via atomic stores at the end of each tick,
so they're always slightly stale but never block.

View file

@ -12,3 +12,10 @@ dev = ["jupyter", "ipykernel"]
[tool.maturin] [tool.maturin]
features = ["python"] features = ["python"]
[tool.uv]
cache-keys = [
{ file = "pyproject.toml" },
{ file = "Cargo.toml" },
{ file = "src/**/*.rs" },
]

125
spectral_report.txt Normal file
View file

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

View file

@ -1,6 +1,6 @@
use std::any::Any; use 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)
}
}

View file

@ -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 ───────────────────────────────────────────────────────────

View file

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

View file

@ -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
} }
} }

View file

@ -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
View file

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

File diff suppressed because it is too large Load diff

View file

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