Compare commits
3 commits
042ccd0db0
...
6897d71e4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6897d71e4b | ||
|
|
89be164c19 | ||
| de0f3960f4 |
19 changed files with 4314 additions and 120 deletions
298
README.md
298
README.md
|
|
@ -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
138
docs/actor-model.md
Normal 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
124
docs/channels.md
Normal 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
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
|
||||||
|
|
||||||
|
========================================================================
|
||||||
185
docs/runtime.md
Normal file
185
docs/runtime.md
Normal 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.
|
||||||
|
|
@ -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
125
spectral_report.txt
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
========================================================================
|
||||||
|
SPECTRAL ANALYSIS REPORT — Dependency DAG
|
||||||
|
========================================================================
|
||||||
|
|
||||||
|
GRAPH SUMMARY
|
||||||
|
----------------------------------------
|
||||||
|
Nodes: 36
|
||||||
|
Directed edges: 78
|
||||||
|
Modules: 8
|
||||||
|
Connected components: 2
|
||||||
|
Modules: error, config, channel, actor, address_map, runtime, worker, python
|
||||||
|
|
||||||
|
LAPLACIAN EIGENVALUE SPECTRUM
|
||||||
|
----------------------------------------
|
||||||
|
lambda_ 0 = 0.0000
|
||||||
|
lambda_ 1 = 0.0000 <-- Fiedler value (lambda_2)
|
||||||
|
lambda_ 2 = 0.1864
|
||||||
|
lambda_ 3 = 0.4814
|
||||||
|
lambda_ 4 = 0.6124
|
||||||
|
lambda_ 5 = 0.7986
|
||||||
|
lambda_ 6 = 0.8319
|
||||||
|
lambda_ 7 = 1.0046
|
||||||
|
lambda_ 8 = 1.2394
|
||||||
|
lambda_ 9 = 1.3690
|
||||||
|
lambda_10 = 1.4527
|
||||||
|
lambda_11 = 1.6261
|
||||||
|
lambda_12 = 2.3213
|
||||||
|
lambda_13 = 2.3936
|
||||||
|
lambda_14 = 2.9092
|
||||||
|
lambda_15 = 3.1570
|
||||||
|
lambda_16 = 3.2200
|
||||||
|
lambda_17 = 3.3902
|
||||||
|
lambda_18 = 3.4799
|
||||||
|
lambda_19 = 3.6052
|
||||||
|
lambda_20 = 3.8848
|
||||||
|
lambda_21 = 4.1863
|
||||||
|
lambda_22 = 4.7070
|
||||||
|
lambda_23 = 5.1732
|
||||||
|
lambda_24 = 5.5865
|
||||||
|
lambda_25 = 5.7959
|
||||||
|
lambda_26 = 5.8550
|
||||||
|
lambda_27 = 6.1829
|
||||||
|
lambda_28 = 6.4619
|
||||||
|
lambda_29 = 6.8986
|
||||||
|
lambda_30 = 7.3807
|
||||||
|
lambda_31 = 7.8965
|
||||||
|
lambda_32 = 9.1602
|
||||||
|
lambda_33 = 11.1710
|
||||||
|
lambda_34 = 14.0475
|
||||||
|
lambda_35 = 15.5333
|
||||||
|
|
||||||
|
Spectral gap (lambda_max - lambda_2): 15.5333
|
||||||
|
Fiedler value (algebraic connectivity): 0.0000
|
||||||
|
|
||||||
|
FIEDLER VECTOR — SPECTRAL BISECTION
|
||||||
|
----------------------------------------
|
||||||
|
Partition A (Fiedler < 0):
|
||||||
|
HybridChannel [channel ] f = -0.0000
|
||||||
|
RuntimeConfig [config ] f = -0.0000
|
||||||
|
SenderT [runtime ] f = -0.0000
|
||||||
|
PyRuntimeHandle [python ] f = -0.0000
|
||||||
|
PyRuntimeConfig [python ] f = -0.0000
|
||||||
|
Placement [address_map ] f = -0.0000
|
||||||
|
Message [actor ] f = -0.0000
|
||||||
|
PyActorAddress [python ] f = -0.0000
|
||||||
|
PyRuntime [python ] f = -0.0000
|
||||||
|
Envelope [runtime ] f = -0.0000
|
||||||
|
PyActor [python ] f = -0.0000
|
||||||
|
TickContext [worker ] f = -0.0000
|
||||||
|
Sender [channel ] f = -0.0000
|
||||||
|
InboxRegistry [runtime ] f = -0.0000
|
||||||
|
Runtime [runtime ] f = -0.0000
|
||||||
|
Receiver [channel ] f = -0.0000
|
||||||
|
────────────────────────────────────
|
||||||
|
Partition B (Fiedler >= 0):
|
||||||
|
Error [error ] f = +0.0000
|
||||||
|
Ctx [actor ] f = +0.0000
|
||||||
|
Effect [python ] f = +0.0000
|
||||||
|
BackoffPolicy [config ] f = +0.0000
|
||||||
|
ActorAddress [actor ] f = +0.0000
|
||||||
|
AddressMap [address_map ] f = +0.0000
|
||||||
|
ContextInner [actor ] f = +0.0000
|
||||||
|
Worker [worker ] f = +0.0000
|
||||||
|
ActorPool [worker ] f = +0.0000
|
||||||
|
WorkerContext [worker ] f = +0.0000
|
||||||
|
RuntimeHandle [runtime ] f = +0.0000
|
||||||
|
PyInbox [python ] f = +0.0000
|
||||||
|
ActorInterface [actor ] f = +0.0000
|
||||||
|
AnyActor [actor ] f = +0.0000
|
||||||
|
WorkerId [address_map ] f = +0.0000
|
||||||
|
Inbox [runtime ] f = +0.0000
|
||||||
|
PyCtx [python ] f = +0.0000
|
||||||
|
PyMsg [python ] f = +0.0000
|
||||||
|
Actor [actor ] f = +0.0000
|
||||||
|
Mailbox [worker ] f = +1.0000
|
||||||
|
|
||||||
|
MODULE COUPLING MATRIX (directed edge counts)
|
||||||
|
----------------------------------------
|
||||||
|
error config channel actoraddress_map runtime worker python
|
||||||
|
error 0 0 0 0 0 0 0 0
|
||||||
|
config 0 1 0 0 0 0 0 0
|
||||||
|
channel 0 0 3 0 0 1 0 0
|
||||||
|
actor 2 0 0 7 0 0 0 0
|
||||||
|
address_map 0 0 0 1 2 0 0 0
|
||||||
|
runtime 2 1 2 6 2 6 1 0
|
||||||
|
worker 1 1 2 9 4 3 3 0
|
||||||
|
python 0 0 0 5 0 3 0 10
|
||||||
|
|
||||||
|
Cross-module edges: 46 / 78 (59.0%)
|
||||||
|
|
||||||
|
CONNECTOME COMPLEXITY INDEX (CCI)
|
||||||
|
----------------------------------------
|
||||||
|
Sub-metric Raw Normalized Weight Contrib
|
||||||
|
──────────────────────────────────────── ────────── ────────── ──────── ────────
|
||||||
|
Algebraic connectivity (lambda_2/n) 0.0000 0.0000 0.25 0.0000
|
||||||
|
Spectral entropy (H/log2(k)) 4.6411 0.9123 0.25 0.2281
|
||||||
|
Edge density (|E|/n(n-1)) 0.0619 0.0619 0.15 0.0093
|
||||||
|
Cross-module coupling ratio 0.5897 0.5897 0.20 0.1179
|
||||||
|
Spectral radius (rho/(n-1)) 6.6762 0.1907 0.15 0.0286
|
||||||
|
──────────────────────────────────────── ────────── ────────── ──────── ────────
|
||||||
|
CCI (weighted sum) 1.00 0.3839
|
||||||
|
|
||||||
|
Interpretation: MODERATE complexity — typical well-structured codebase
|
||||||
|
|
||||||
|
========================================================================
|
||||||
47
src/actor.rs
47
src/actor.rs
|
|
@ -1,6 +1,6 @@
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
|
||||||
use crate::runtime::Ctx;
|
use crate::Error;
|
||||||
|
|
||||||
/// The primary trait defining data that can be passed to and from actor processes
|
/// The primary trait defining data that can be passed to and from actor processes
|
||||||
pub trait Message: 'static + Sized + Clone + Send + Sync {}
|
pub trait Message: 'static + Sized + Clone + Send + Sync {}
|
||||||
|
|
@ -49,3 +49,48 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Object-safe inner trait for sending type-erased messages.
|
||||||
|
pub(crate) trait ContextInner {
|
||||||
|
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
|
||||||
|
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error>;
|
||||||
|
fn mailbox_waterlevel(&self) -> usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actor syscall interface — passed to `ActorInterface::handle()`.
|
||||||
|
///
|
||||||
|
/// Wraps a `&dyn ContextInner` to solve the object-safety problem while
|
||||||
|
/// providing a typed public API.
|
||||||
|
pub struct Ctx<'a> {
|
||||||
|
inner: &'a dyn ContextInner,
|
||||||
|
self_addr: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Ctx<'a> {
|
||||||
|
pub(crate) fn new(inner: &'a dyn ContextInner, self_addr: ActorAddress) -> Self {
|
||||||
|
Self { inner, self_addr }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "python")]
|
||||||
|
pub(crate) fn raw_inner(&self) -> &dyn ContextInner {
|
||||||
|
self.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the address of the actor currently being ticked.
|
||||||
|
pub fn self_addr(&self) -> ActorAddress {
|
||||||
|
self.self_addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a typed message to an actor address.
|
||||||
|
pub fn send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
|
||||||
|
self.inner.send_any(addr, Box::new(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a new actor, returning its address.
|
||||||
|
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
|
||||||
|
let addr = ActorAddress::new_random();
|
||||||
|
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
|
||||||
|
self.inner.spawn_any(addr, boxed)?;
|
||||||
|
Ok(addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ use std::cell::RefCell;
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
use pyo3::types::PyModule;
|
use pyo3::types::PyModule;
|
||||||
|
|
||||||
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor};
|
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Ctx};
|
||||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
use crate::config::{BackoffPolicy, RuntimeConfig};
|
||||||
use crate::runtime::{Ctx, Inbox, Runtime, RuntimeHandle};
|
use crate::runtime::{Inbox, Runtime, RuntimeHandle};
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
// ─── PyMsg newtype ───────────────────────────────────────────────────────────
|
// ─── PyMsg newtype ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -65,43 +65,8 @@ impl RuntimeHandle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actor syscall interface — passed to `ActorInterface::handle()`.
|
// Re-export Ctx and ContextInner for backwards compatibility
|
||||||
///
|
pub use crate::actor::{ContextInner, Ctx};
|
||||||
/// Wraps a `&dyn ContextInner` to solve the object-safety problem while
|
|
||||||
/// providing a typed public API.
|
|
||||||
pub struct Ctx<'a> {
|
|
||||||
inner: &'a dyn ContextInner,
|
|
||||||
self_addr: ActorAddress,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Ctx<'a> {
|
|
||||||
pub(crate) fn new(inner: &'a dyn ContextInner, self_addr: ActorAddress) -> Self {
|
|
||||||
Self { inner, self_addr }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "python")]
|
|
||||||
pub(crate) fn raw_inner(&self) -> &dyn ContextInner {
|
|
||||||
self.inner
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the address of the actor currently being ticked.
|
|
||||||
pub fn self_addr(&self) -> ActorAddress {
|
|
||||||
self.self_addr
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a typed message to an actor address.
|
|
||||||
pub fn send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
|
|
||||||
self.inner.send_any(addr, Box::new(msg))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a new actor, returning its address.
|
|
||||||
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
|
|
||||||
let addr = ActorAddress::new_random();
|
|
||||||
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
|
|
||||||
self.inner.spawn_any(addr, boxed)?;
|
|
||||||
Ok(addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Type-erased sender for external inboxes.
|
/// Type-erased sender for external inboxes.
|
||||||
pub(crate) trait SenderT: Send + Sync {
|
pub(crate) trait SenderT: Send + Sync {
|
||||||
|
|
@ -287,7 +252,7 @@ impl Runtime {
|
||||||
inbox_registry: &rt_clone.inbox_registry,
|
inbox_registry: &rt_clone.inbox_registry,
|
||||||
config: &rt_clone.config,
|
config: &rt_clone.config,
|
||||||
};
|
};
|
||||||
worker.run(&tc, &rt_clone.is_running, &rt_clone.config.backoff_policy);
|
worker.run(&tc, &rt_clone.is_running);
|
||||||
});
|
});
|
||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
|
|
@ -400,14 +365,6 @@ impl InboxRegistry {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Object-safe inner trait for sending type-erased messages.
|
|
||||||
pub(crate) trait ContextInner {
|
|
||||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
|
|
||||||
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error>;
|
|
||||||
fn mailbox_waterlevel(&self) -> usize;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
impl ContextInner for Runtime {
|
impl ContextInner for Runtime {
|
||||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
||||||
match self.address_map.lookup(&addr) {
|
match self.address_map.lookup(&addr) {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use crate::actor::{ActorAddress, AnyActor, Message};
|
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, Message};
|
||||||
use crate::address_map::{AddressMap, Placement, WorkerId};
|
use crate::address_map::{AddressMap, Placement, WorkerId};
|
||||||
use crate::channel::{Receiver, Sender};
|
use crate::channel::{Receiver, Sender};
|
||||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
use crate::config::RuntimeConfig;
|
||||||
use crate::runtime::{ContextInner, Ctx, Envelope, InboxRegistry};
|
use crate::runtime::{Envelope, InboxRegistry};
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
/// Per-worker stats published via atomics. Readable from any thread.
|
/// Per-worker stats published via atomics. Readable from any thread.
|
||||||
|
|
@ -90,12 +90,7 @@ impl Worker {
|
||||||
{
|
{
|
||||||
let worker_ctx = WorkerContext {
|
let worker_ctx = WorkerContext {
|
||||||
worker_id: self.id,
|
worker_id: self.id,
|
||||||
address_map: tc.address_map,
|
tc,
|
||||||
transfer_txs: tc.transfer_txs,
|
|
||||||
spawn_txs: tc.spawn_txs,
|
|
||||||
placement: tc.placement,
|
|
||||||
inbox_registry: tc.inbox_registry,
|
|
||||||
config: tc.config,
|
|
||||||
pending_local: &pending_local,
|
pending_local: &pending_local,
|
||||||
};
|
};
|
||||||
processed = self.pool.tick_all(&worker_ctx);
|
processed = self.pool.tick_all(&worker_ctx);
|
||||||
|
|
@ -121,7 +116,8 @@ impl Worker {
|
||||||
did_work
|
did_work
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool, backoff: &BackoffPolicy) {
|
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool) {
|
||||||
|
let backoff = &tc.config.backoff_policy;
|
||||||
let mut idle_count: u32 = 0;
|
let mut idle_count: u32 = 0;
|
||||||
while is_running.load(Ordering::Acquire) {
|
while is_running.load(Ordering::Acquire) {
|
||||||
let did_work = self.tick_once(tc);
|
let did_work = self.tick_once(tc);
|
||||||
|
|
@ -151,18 +147,13 @@ impl Worker {
|
||||||
/// Cross-worker sends go through the transfer queue.
|
/// Cross-worker sends go through the transfer queue.
|
||||||
struct WorkerContext<'a> {
|
struct WorkerContext<'a> {
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
address_map: &'a AddressMap,
|
tc: &'a TickContext<'a>,
|
||||||
transfer_txs: &'a [Sender<Envelope>],
|
|
||||||
spawn_txs: &'a [Sender<(ActorAddress, Box<dyn AnyActor>)>],
|
|
||||||
placement: &'a Placement,
|
|
||||||
inbox_registry: &'a InboxRegistry,
|
|
||||||
config: &'a RuntimeConfig,
|
|
||||||
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextInner for WorkerContext<'_> {
|
impl ContextInner for WorkerContext<'_> {
|
||||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
||||||
match self.address_map.lookup(&addr) {
|
match self.tc.address_map.lookup(&addr) {
|
||||||
Some(wid) if wid == self.worker_id => {
|
Some(wid) if wid == self.worker_id => {
|
||||||
// Same worker: buffer for local delivery (after current tick round)
|
// Same worker: buffer for local delivery (after current tick round)
|
||||||
self.pending_local.borrow_mut().push((addr, msg));
|
self.pending_local.borrow_mut().push((addr, msg));
|
||||||
|
|
@ -171,26 +162,26 @@ impl ContextInner for WorkerContext<'_> {
|
||||||
Some(wid) => {
|
Some(wid) => {
|
||||||
// Cross worker: envelope through transfer queue
|
// Cross worker: envelope through transfer queue
|
||||||
let envelope = Envelope::new(addr, msg);
|
let envelope = Envelope::new(addr, msg);
|
||||||
let _ = self.transfer_txs[wid.as_usize()].try_send(envelope);
|
let _ = self.tc.transfer_txs[wid.as_usize()].try_send(envelope);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Try inbox registry (external inboxes)
|
// Try inbox registry (external inboxes)
|
||||||
self.inbox_registry.try_deliver(addr, msg)
|
self.tc.inbox_registry.try_deliver(addr, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error> {
|
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error> {
|
||||||
let worker_id = self.placement.next_worker();
|
let worker_id = self.tc.placement.next_worker();
|
||||||
self.address_map.insert(addr, worker_id);
|
self.tc.address_map.insert(addr, worker_id);
|
||||||
self.spawn_txs[worker_id.as_usize()]
|
self.tc.spawn_txs[worker_id.as_usize()]
|
||||||
.try_send((addr, actor))
|
.try_send((addr, actor))
|
||||||
.map_err(|_| Error::from("Spawn queue full"))
|
.map_err(|_| Error::from("Spawn queue full"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mailbox_waterlevel(&self) -> usize {
|
fn mailbox_waterlevel(&self) -> usize {
|
||||||
self.config.mailbox_waterlevel
|
self.tc.config.mailbox_waterlevel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use crate::actor::{ActorAddress, AnyActor};
|
use crate::actor::{ActorAddress, AnyActor, Ctx};
|
||||||
use crate::address_map::{AddressMap, Placement, WorkerId};
|
use crate::address_map::{AddressMap, Placement, WorkerId};
|
||||||
use crate::channel::Receiver;
|
use crate::channel::Receiver;
|
||||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
use crate::config::RuntimeConfig;
|
||||||
use crate::runtime::{Ctx, Envelope, InboxRegistry};
|
use crate::runtime::{Envelope, InboxRegistry};
|
||||||
|
|
||||||
use super::{TickContext, Worker, WorkerStats};
|
use super::{TickContext, Worker, WorkerStats};
|
||||||
|
|
||||||
|
|
@ -399,7 +399,6 @@ fn run_loop_stops_on_shutdown() {
|
||||||
let mut worker = Worker::new(WorkerId(0), transfer_rx, spawn_rx, stats);
|
let mut worker = Worker::new(WorkerId(0), transfer_rx, spawn_rx, stats);
|
||||||
|
|
||||||
let is_running = AtomicBool::new(false);
|
let is_running = AtomicBool::new(false);
|
||||||
let backoff = BackoffPolicy::default();
|
|
||||||
let address_map = AddressMap::new();
|
let address_map = AddressMap::new();
|
||||||
let placement = Placement::new(1);
|
let placement = Placement::new(1);
|
||||||
let inbox_registry = InboxRegistry::new();
|
let inbox_registry = InboxRegistry::new();
|
||||||
|
|
@ -415,6 +414,6 @@ fn run_loop_stops_on_shutdown() {
|
||||||
};
|
};
|
||||||
|
|
||||||
thread::scope(|s| {
|
thread::scope(|s| {
|
||||||
s.spawn(|| worker.run(&tc, &is_running, &backoff));
|
s.spawn(|| worker.run(&tc, &is_running));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
tools/spectral/.gitignore
vendored
Normal file
2
tools/spectral/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__
|
||||||
|
output/*
|
||||||
1463
tools/spectral/spectral_analysis.py
Normal file
1463
tools/spectral/spectral_analysis.py
Normal file
File diff suppressed because it is too large
Load diff
727
tools/spectral/test_spectral.py
Normal file
727
tools/spectral/test_spectral.py
Normal file
|
|
@ -0,0 +1,727 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Comprehensive tests for the spectral analysis tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from spectral_analysis import (
|
||||||
|
AnalysisResult,
|
||||||
|
ComplexityMetrics,
|
||||||
|
DependencyGraph,
|
||||||
|
Edge,
|
||||||
|
ModuleCouplingResult,
|
||||||
|
Node,
|
||||||
|
SpectralResults,
|
||||||
|
build_adjacency,
|
||||||
|
build_laplacian,
|
||||||
|
compute_complexity_metrics,
|
||||||
|
compute_module_coupling,
|
||||||
|
compute_spectral,
|
||||||
|
compute_spectral_entropy,
|
||||||
|
count_connected_components,
|
||||||
|
generate_report,
|
||||||
|
get_node_ordering,
|
||||||
|
metrics_to_dict,
|
||||||
|
parse_dot,
|
||||||
|
run_analysis,
|
||||||
|
symmetrize,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_graph(
|
||||||
|
names: list[str],
|
||||||
|
modules: list[str],
|
||||||
|
edge_pairs: list[tuple[str, str]],
|
||||||
|
module_order: list[str] | None = None,
|
||||||
|
) -> DependencyGraph:
|
||||||
|
"""Build a DependencyGraph from names, module assignments, and edges."""
|
||||||
|
assert len(names) == len(modules)
|
||||||
|
graph = DependencyGraph()
|
||||||
|
seen_modules: list[str] = []
|
||||||
|
for name, mod in zip(names, modules):
|
||||||
|
graph.nodes.append(Node(name=name, module=mod))
|
||||||
|
graph.node_to_module[name] = mod
|
||||||
|
if mod not in seen_modules:
|
||||||
|
seen_modules.append(mod)
|
||||||
|
if module_order is not None:
|
||||||
|
graph.modules = module_order
|
||||||
|
else:
|
||||||
|
graph.modules = seen_modules
|
||||||
|
for src, tgt in edge_pairs:
|
||||||
|
src_mod = graph.node_to_module.get(src, "")
|
||||||
|
tgt_mod = graph.node_to_module.get(tgt, "")
|
||||||
|
cross = src_mod != tgt_mod
|
||||||
|
graph.edges.append(Edge(
|
||||||
|
source=src, target=tgt, label="dep",
|
||||||
|
edge_type="field", cross_module=cross,
|
||||||
|
))
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
# ─── DOT Parser Tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestDotParser(unittest.TestCase):
|
||||||
|
def test_minimal_dot(self):
|
||||||
|
dot = '''digraph test {
|
||||||
|
subgraph cluster_mod1 {
|
||||||
|
label="mod1";
|
||||||
|
A [label="A", fillcolor="#fff"];
|
||||||
|
}
|
||||||
|
A -> A [label="self", style=dashed, color="#666", penwidth=1];
|
||||||
|
}'''
|
||||||
|
g = parse_dot(dot)
|
||||||
|
self.assertEqual(len(g.nodes), 1)
|
||||||
|
self.assertEqual(g.nodes[0].name, "A")
|
||||||
|
self.assertEqual(g.nodes[0].module, "mod1")
|
||||||
|
self.assertEqual(len(g.edges), 1)
|
||||||
|
|
||||||
|
def test_two_module_dot(self):
|
||||||
|
dot = '''digraph test {
|
||||||
|
subgraph cluster_alpha {
|
||||||
|
label="alpha";
|
||||||
|
X [label="X"];
|
||||||
|
Y [label="Y"];
|
||||||
|
}
|
||||||
|
subgraph cluster_beta {
|
||||||
|
label="beta";
|
||||||
|
Z [label="Z"];
|
||||||
|
}
|
||||||
|
X -> Y [label="dep", style=dashed, color="#666", penwidth=1];
|
||||||
|
X -> Z [label="dep", style=solid, color="#00f", penwidth=1.5];
|
||||||
|
}'''
|
||||||
|
g = parse_dot(dot)
|
||||||
|
self.assertEqual(len(g.nodes), 3)
|
||||||
|
self.assertEqual(len(g.modules), 2)
|
||||||
|
self.assertEqual(g.modules, ["alpha", "beta"])
|
||||||
|
self.assertEqual(g.node_to_module["X"], "alpha")
|
||||||
|
self.assertEqual(g.node_to_module["Z"], "beta")
|
||||||
|
|
||||||
|
# Edge classification
|
||||||
|
intra = [e for e in g.edges if not e.cross_module]
|
||||||
|
cross = [e for e in g.edges if e.cross_module]
|
||||||
|
self.assertEqual(len(intra), 1)
|
||||||
|
self.assertEqual(len(cross), 1)
|
||||||
|
|
||||||
|
def test_trait_impl_classification(self):
|
||||||
|
dot = '''digraph test {
|
||||||
|
subgraph cluster_m {
|
||||||
|
label="m";
|
||||||
|
A [label="A"];
|
||||||
|
B [label="B"];
|
||||||
|
}
|
||||||
|
A -> B [label="impl", style=dotted, color="#666", penwidth=1];
|
||||||
|
}'''
|
||||||
|
g = parse_dot(dot)
|
||||||
|
self.assertEqual(g.edges[0].edge_type, "trait_impl")
|
||||||
|
|
||||||
|
def test_real_deps_dot(self):
|
||||||
|
"""Parse the real deps.dot and verify expected counts."""
|
||||||
|
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||||
|
if not os.path.exists(dot_path):
|
||||||
|
self.skipTest("deps.dot not found")
|
||||||
|
with open(dot_path) as f:
|
||||||
|
dot = f.read()
|
||||||
|
g = parse_dot(dot)
|
||||||
|
self.assertEqual(len(g.nodes), 36, f"Expected 36 nodes, got {len(g.nodes)}")
|
||||||
|
self.assertEqual(len(g.edges), 89, f"Expected 89 edges, got {len(g.edges)}")
|
||||||
|
self.assertEqual(len(g.modules), 8, f"Expected 8 modules, got {len(g.modules)}")
|
||||||
|
|
||||||
|
def test_empty_dot(self):
|
||||||
|
dot = "digraph empty {}"
|
||||||
|
g = parse_dot(dot)
|
||||||
|
self.assertEqual(len(g.nodes), 0)
|
||||||
|
self.assertEqual(len(g.edges), 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Matrix Construction Tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestMatrixConstruction(unittest.TestCase):
|
||||||
|
def test_two_node_adjacency(self):
|
||||||
|
g = _make_graph(["A", "B"], ["m", "m"], [("A", "B")])
|
||||||
|
order = get_node_ordering(g)
|
||||||
|
A = build_adjacency(g, order)
|
||||||
|
self.assertEqual(A.shape, (2, 2))
|
||||||
|
idx_a = order.index("A")
|
||||||
|
idx_b = order.index("B")
|
||||||
|
self.assertEqual(A[idx_a, idx_b], 1.0)
|
||||||
|
self.assertEqual(A[idx_b, idx_a], 0.0)
|
||||||
|
|
||||||
|
def test_symmetrize_directed(self):
|
||||||
|
A = np.array([[0, 1, 0],
|
||||||
|
[0, 0, 1],
|
||||||
|
[0, 0, 0]], dtype=float)
|
||||||
|
S = symmetrize(A)
|
||||||
|
expected = np.array([[0, 1, 0],
|
||||||
|
[1, 0, 1],
|
||||||
|
[0, 1, 0]], dtype=float)
|
||||||
|
np.testing.assert_array_equal(S, expected)
|
||||||
|
|
||||||
|
def test_symmetrize_idempotent(self):
|
||||||
|
"""Symmetrizing an already-symmetric matrix should not change it."""
|
||||||
|
A = np.array([[0, 1, 1],
|
||||||
|
[1, 0, 1],
|
||||||
|
[1, 1, 0]], dtype=float)
|
||||||
|
S = symmetrize(A)
|
||||||
|
np.testing.assert_array_equal(S, A)
|
||||||
|
|
||||||
|
def test_laplacian_p3(self):
|
||||||
|
"""Path graph P3: A-B-C."""
|
||||||
|
A_sym = np.array([[0, 1, 0],
|
||||||
|
[1, 0, 1],
|
||||||
|
[0, 1, 0]], dtype=float)
|
||||||
|
L = build_laplacian(A_sym)
|
||||||
|
expected = np.array([[1, -1, 0],
|
||||||
|
[-1, 2, -1],
|
||||||
|
[0, -1, 1]], dtype=float)
|
||||||
|
np.testing.assert_array_equal(L, expected)
|
||||||
|
|
||||||
|
def test_laplacian_k3(self):
|
||||||
|
"""Complete graph K3."""
|
||||||
|
A_sym = np.array([[0, 1, 1],
|
||||||
|
[1, 0, 1],
|
||||||
|
[1, 1, 0]], dtype=float)
|
||||||
|
L = build_laplacian(A_sym)
|
||||||
|
expected = np.array([[2, -1, -1],
|
||||||
|
[-1, 2, -1],
|
||||||
|
[-1, -1, 2]], dtype=float)
|
||||||
|
np.testing.assert_array_equal(L, expected)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Spectral Analysis Tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSpectralAnalysis(unittest.TestCase):
|
||||||
|
def test_p3_eigenvalues(self):
|
||||||
|
"""Path P3 should have eigenvalues {0, 1, 3}."""
|
||||||
|
g = _make_graph(["A", "B", "C"], ["m", "m", "m"],
|
||||||
|
[("A", "B"), ("B", "C")])
|
||||||
|
s = compute_spectral(g)
|
||||||
|
np.testing.assert_allclose(sorted(s.eigenvalues), [0, 1, 3], atol=1e-10)
|
||||||
|
|
||||||
|
def test_k4_eigenvalues(self):
|
||||||
|
"""Complete K4 should have eigenvalues {0, 4, 4, 4}."""
|
||||||
|
names = ["A", "B", "C", "D"]
|
||||||
|
edges = [(a, b) for a in names for b in names if a != b]
|
||||||
|
g = _make_graph(names, ["m"] * 4, edges)
|
||||||
|
s = compute_spectral(g)
|
||||||
|
np.testing.assert_allclose(sorted(s.eigenvalues), [0, 4, 4, 4], atol=1e-10)
|
||||||
|
|
||||||
|
def test_star_s4_fiedler(self):
|
||||||
|
"""Star graph S4 (center + 3 leaves): lambda_2 = 1."""
|
||||||
|
g = _make_graph(
|
||||||
|
["C", "L1", "L2", "L3"], ["m"] * 4,
|
||||||
|
[("C", "L1"), ("C", "L2"), ("C", "L3")],
|
||||||
|
)
|
||||||
|
s = compute_spectral(g)
|
||||||
|
self.assertAlmostEqual(s.fiedler_value, 1.0, places=10)
|
||||||
|
|
||||||
|
def test_disconnected_graph(self):
|
||||||
|
"""Disconnected graph should have lambda_2 = 0."""
|
||||||
|
g = _make_graph(
|
||||||
|
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||||
|
[("A", "B"), ("C", "D")],
|
||||||
|
module_order=["m1", "m2"],
|
||||||
|
)
|
||||||
|
s = compute_spectral(g)
|
||||||
|
self.assertAlmostEqual(s.fiedler_value, 0.0, places=10)
|
||||||
|
|
||||||
|
def test_barbell_fiedler_separation(self):
|
||||||
|
"""Barbell graph: two K3 cliques connected by a bridge.
|
||||||
|
|
||||||
|
Fiedler vector should separate the two cliques (different signs).
|
||||||
|
"""
|
||||||
|
# Clique 1: A, B, C fully connected
|
||||||
|
# Clique 2: D, E, F fully connected
|
||||||
|
# Bridge: C-D
|
||||||
|
names = ["A", "B", "C", "D", "E", "F"]
|
||||||
|
edges = [
|
||||||
|
("A", "B"), ("A", "C"), ("B", "C"),
|
||||||
|
("D", "E"), ("D", "F"), ("E", "F"),
|
||||||
|
("C", "D"),
|
||||||
|
]
|
||||||
|
g = _make_graph(names, ["m1", "m1", "m1", "m2", "m2", "m2"], edges,
|
||||||
|
module_order=["m1", "m2"])
|
||||||
|
s = compute_spectral(g)
|
||||||
|
|
||||||
|
# Clique 1 nodes should have same sign, clique 2 opposite
|
||||||
|
order = s.node_names
|
||||||
|
fv = s.fiedler_vector
|
||||||
|
idx = {name: i for i, name in enumerate(order)}
|
||||||
|
|
||||||
|
clique1_signs = [np.sign(fv[idx[n]]) for n in ["A", "B", "C"]]
|
||||||
|
clique2_signs = [np.sign(fv[idx[n]]) for n in ["D", "E", "F"]]
|
||||||
|
|
||||||
|
# All in clique 1 should have the same sign
|
||||||
|
self.assertTrue(all(s == clique1_signs[0] for s in clique1_signs),
|
||||||
|
f"Clique 1 signs should be uniform: {clique1_signs}")
|
||||||
|
# All in clique 2 should have the same sign
|
||||||
|
self.assertTrue(all(s == clique2_signs[0] for s in clique2_signs),
|
||||||
|
f"Clique 2 signs should be uniform: {clique2_signs}")
|
||||||
|
# The two cliques should have opposite signs
|
||||||
|
self.assertNotEqual(clique1_signs[0], clique2_signs[0],
|
||||||
|
"Cliques should have opposite Fiedler signs")
|
||||||
|
|
||||||
|
def test_single_node(self):
|
||||||
|
g = _make_graph(["A"], ["m"], [])
|
||||||
|
s = compute_spectral(g)
|
||||||
|
self.assertEqual(s.fiedler_value, 0.0)
|
||||||
|
self.assertEqual(len(s.eigenvalues), 1)
|
||||||
|
|
||||||
|
def test_empty_graph(self):
|
||||||
|
g = DependencyGraph()
|
||||||
|
s = compute_spectral(g)
|
||||||
|
self.assertEqual(s.fiedler_value, 0.0)
|
||||||
|
self.assertEqual(len(s.eigenvalues), 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Module Coupling Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestModuleCoupling(unittest.TestCase):
|
||||||
|
def test_directed_counts(self):
|
||||||
|
g = _make_graph(
|
||||||
|
["A", "B", "C"], ["m1", "m1", "m2"],
|
||||||
|
[("A", "C"), ("B", "C"), ("C", "A")],
|
||||||
|
module_order=["m1", "m2"],
|
||||||
|
)
|
||||||
|
c = compute_module_coupling(g)
|
||||||
|
# m1->m2: 2 edges (A->C, B->C)
|
||||||
|
# m2->m1: 1 edge (C->A)
|
||||||
|
idx_m1 = c.module_names.index("m1")
|
||||||
|
idx_m2 = c.module_names.index("m2")
|
||||||
|
self.assertEqual(c.coupling_matrix[idx_m1, idx_m2], 2.0)
|
||||||
|
self.assertEqual(c.coupling_matrix[idx_m2, idx_m1], 1.0)
|
||||||
|
|
||||||
|
def test_cross_module_ratio(self):
|
||||||
|
g = _make_graph(
|
||||||
|
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||||
|
[("A", "B"), ("A", "C"), ("C", "D")],
|
||||||
|
module_order=["m1", "m2"],
|
||||||
|
)
|
||||||
|
c = compute_module_coupling(g)
|
||||||
|
# 1 cross-module edge (A->C) out of 3 total
|
||||||
|
self.assertEqual(c.cross_module_edges, 1)
|
||||||
|
self.assertEqual(c.total_edges, 3)
|
||||||
|
|
||||||
|
def test_intra_only(self):
|
||||||
|
g = _make_graph(
|
||||||
|
["A", "B"], ["m1", "m1"],
|
||||||
|
[("A", "B")],
|
||||||
|
module_order=["m1"],
|
||||||
|
)
|
||||||
|
c = compute_module_coupling(g)
|
||||||
|
self.assertEqual(c.cross_module_edges, 0)
|
||||||
|
self.assertEqual(c.coupling_matrix[0, 0], 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Complexity Metrics Tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestComplexityMetrics(unittest.TestCase):
|
||||||
|
def test_k4_spectral_entropy(self):
|
||||||
|
"""K4 has uniform positive eigenvalues {4,4,4} -> entropy = log2(3)."""
|
||||||
|
evals = np.array([0.0, 4.0, 4.0, 4.0])
|
||||||
|
H = compute_spectral_entropy(evals)
|
||||||
|
self.assertAlmostEqual(H, math.log2(3), places=10)
|
||||||
|
|
||||||
|
def test_star_entropy_less_than_complete(self):
|
||||||
|
"""Star graph has less uniform eigenvalues than complete graph."""
|
||||||
|
# Star S4: eigenvalues are 0, 1, 1, 4
|
||||||
|
star_evals = np.array([0.0, 1.0, 1.0, 4.0])
|
||||||
|
k4_evals = np.array([0.0, 4.0, 4.0, 4.0])
|
||||||
|
H_star = compute_spectral_entropy(star_evals)
|
||||||
|
H_k4 = compute_spectral_entropy(k4_evals)
|
||||||
|
self.assertLess(H_star, H_k4)
|
||||||
|
|
||||||
|
def test_cci_in_range(self):
|
||||||
|
"""CCI should always be in [0, 1]."""
|
||||||
|
for _ in range(20):
|
||||||
|
n = random.randint(2, 10)
|
||||||
|
names = [f"N{i}" for i in range(n)]
|
||||||
|
mods = [f"m{i % 3}" for i in range(n)]
|
||||||
|
edges = []
|
||||||
|
for _ in range(random.randint(1, n * 2)):
|
||||||
|
a, b = random.sample(names, 2)
|
||||||
|
edges.append((a, b))
|
||||||
|
g = _make_graph(names, mods, edges,
|
||||||
|
module_order=sorted(set(mods)))
|
||||||
|
result = run_analysis(g)
|
||||||
|
self.assertGreaterEqual(result.metrics.cci, 0.0,
|
||||||
|
"CCI should be >= 0")
|
||||||
|
self.assertLessEqual(result.metrics.cci, 1.0,
|
||||||
|
"CCI should be <= 1")
|
||||||
|
|
||||||
|
def test_cci_increases_with_coupling(self):
|
||||||
|
"""Adding cross-module edges should increase CCI."""
|
||||||
|
# Base graph: two modules, minimal coupling
|
||||||
|
g1 = _make_graph(
|
||||||
|
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||||
|
[("A", "B"), ("C", "D"), ("A", "C")],
|
||||||
|
module_order=["m1", "m2"],
|
||||||
|
)
|
||||||
|
# More coupling
|
||||||
|
g2 = _make_graph(
|
||||||
|
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||||
|
[("A", "B"), ("C", "D"), ("A", "C"), ("A", "D"),
|
||||||
|
("B", "C"), ("B", "D"), ("C", "A"), ("D", "B")],
|
||||||
|
module_order=["m1", "m2"],
|
||||||
|
)
|
||||||
|
r1 = run_analysis(g1)
|
||||||
|
r2 = run_analysis(g2)
|
||||||
|
self.assertLess(r1.metrics.cci, r2.metrics.cci)
|
||||||
|
|
||||||
|
def test_connected_components(self):
|
||||||
|
A_sym = np.array([
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 1],
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
], dtype=float)
|
||||||
|
self.assertEqual(count_connected_components(A_sym), 2)
|
||||||
|
|
||||||
|
def test_single_component(self):
|
||||||
|
A_sym = np.array([
|
||||||
|
[0, 1, 1],
|
||||||
|
[1, 0, 1],
|
||||||
|
[1, 1, 0],
|
||||||
|
], dtype=float)
|
||||||
|
self.assertEqual(count_connected_components(A_sym), 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Complexity Ladder ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestComplexityLadder(unittest.TestCase):
|
||||||
|
"""Verify CCI correctly orders synthetic codebases of increasing complexity."""
|
||||||
|
|
||||||
|
def _rung1_linear_chain(self) -> DependencyGraph:
|
||||||
|
"""5 nodes in a single module, linear chain A->B->C->D->E."""
|
||||||
|
return _make_graph(
|
||||||
|
["A", "B", "C", "D", "E"],
|
||||||
|
["m1"] * 5,
|
||||||
|
[("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")],
|
||||||
|
module_order=["m1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rung2_clean_tree(self) -> DependencyGraph:
|
||||||
|
"""6 nodes across 2 modules, tree with mostly intra-module edges."""
|
||||||
|
return _make_graph(
|
||||||
|
["R", "A", "B", "C", "D", "E"],
|
||||||
|
["core", "core", "core", "util", "util", "util"],
|
||||||
|
[
|
||||||
|
("R", "A"), ("A", "B"), ("R", "C"), # intra core
|
||||||
|
("D", "E"), # intra util
|
||||||
|
("R", "D"), ("C", "E"), # 2 cross edges
|
||||||
|
],
|
||||||
|
module_order=["core", "util"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rung3_layered_dag(self) -> DependencyGraph:
|
||||||
|
"""8 nodes across 3 modules in a layered architecture."""
|
||||||
|
return _make_graph(
|
||||||
|
["C1", "C2", "S1", "S2", "S3", "D1", "D2", "D3"],
|
||||||
|
["ctrl", "ctrl", "svc", "svc", "svc", "data", "data", "data"],
|
||||||
|
[
|
||||||
|
("C1", "C2"), # intra ctrl
|
||||||
|
("S1", "S2"), ("S2", "S3"), # intra svc
|
||||||
|
("D1", "D2"), ("D2", "D3"), # intra data
|
||||||
|
("C1", "S1"), ("C1", "S2"), ("C2", "S3"), # ctrl->svc
|
||||||
|
("S1", "D1"), ("S2", "D2"), ("S3", "D3"), # svc->data
|
||||||
|
],
|
||||||
|
module_order=["ctrl", "svc", "data"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rung4_diamond_cross(self) -> DependencyGraph:
|
||||||
|
"""10 nodes across 5 modules with diamond patterns and cross-coupling."""
|
||||||
|
return _make_graph(
|
||||||
|
["A1", "A2", "B1", "B2", "C1", "C2", "D1", "D2", "E1", "E2"],
|
||||||
|
["ma", "ma", "mb", "mb", "mc", "mc", "md", "md", "me", "me"],
|
||||||
|
[
|
||||||
|
("A1", "A2"), ("B1", "B2"), ("C1", "C2"), # intra
|
||||||
|
("D1", "D2"), ("E1", "E2"), # intra
|
||||||
|
# Diamonds across modules
|
||||||
|
("A1", "B1"), ("A1", "C1"), ("B1", "D1"), ("C1", "D1"),
|
||||||
|
("A2", "B2"), ("A2", "C2"), ("B2", "D2"), ("C2", "D2"),
|
||||||
|
# Extra cross-coupling
|
||||||
|
("D1", "E1"), ("D2", "E2"), ("B1", "E1"),
|
||||||
|
],
|
||||||
|
module_order=["ma", "mb", "mc", "md", "me"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rung5_hub_backlinks(self) -> DependencyGraph:
|
||||||
|
"""10 nodes across 5 modules, hub-dominated with back-edges."""
|
||||||
|
return _make_graph(
|
||||||
|
["Hub", "A1", "A2", "B1", "B2", "C1", "C2", "D1", "D2", "D3"],
|
||||||
|
["core", "sa", "sa", "sb", "sb", "sc", "sc", "sd", "sd", "sd"],
|
||||||
|
[
|
||||||
|
("A1", "A2"), ("B1", "B2"), ("C1", "C2"), # intra
|
||||||
|
("D1", "D2"), ("D2", "D3"), # intra
|
||||||
|
# Hub connections (cross-module)
|
||||||
|
("Hub", "A1"), ("Hub", "B1"), ("Hub", "C1"), ("Hub", "D1"),
|
||||||
|
("A1", "Hub"), ("B1", "Hub"), ("C1", "Hub"),
|
||||||
|
# Additional cross-module
|
||||||
|
("A1", "B1"), ("B1", "C1"), ("C1", "D1"),
|
||||||
|
("A2", "B2"), ("B2", "C2"), ("C2", "D2"),
|
||||||
|
("A1", "D1"), ("B2", "D3"),
|
||||||
|
],
|
||||||
|
module_order=["core", "sa", "sb", "sc", "sd"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rung6_dense_mesh(self) -> DependencyGraph:
|
||||||
|
"""10 nodes across 4 modules with heavy cross-module coupling."""
|
||||||
|
names = ["X1", "X2", "X3", "Y1", "Y2", "Y3", "Z1", "Z2", "W1", "W2"]
|
||||||
|
mods = ["mx", "mx", "mx", "my", "my", "my", "mz", "mz", "mw", "mw"]
|
||||||
|
# Dense cross-module edges
|
||||||
|
edges = [
|
||||||
|
# intra
|
||||||
|
("X1", "X2"), ("X2", "X3"), ("Y1", "Y2"), ("Y2", "Y3"),
|
||||||
|
("Z1", "Z2"), ("W1", "W2"),
|
||||||
|
# cross - nearly every module to every other
|
||||||
|
("X1", "Y1"), ("X1", "Z1"), ("X1", "W1"),
|
||||||
|
("X2", "Y2"), ("X2", "Z2"), ("X2", "W2"),
|
||||||
|
("X3", "Y3"), ("X3", "Z1"),
|
||||||
|
("Y1", "X1"), ("Y1", "Z1"), ("Y1", "W1"),
|
||||||
|
("Y2", "X2"), ("Y2", "Z2"),
|
||||||
|
("Y3", "X3"), ("Y3", "W2"),
|
||||||
|
("Z1", "X1"), ("Z1", "Y1"), ("Z1", "W1"),
|
||||||
|
("Z2", "X2"), ("Z2", "Y2"), ("Z2", "W2"),
|
||||||
|
("W1", "X1"), ("W1", "Y1"), ("W1", "Z1"),
|
||||||
|
("W2", "X2"), ("W2", "Y2"), ("W2", "Z2"),
|
||||||
|
]
|
||||||
|
return _make_graph(names, mods, edges,
|
||||||
|
module_order=["mx", "my", "mz", "mw"])
|
||||||
|
|
||||||
|
def test_complexity_ladder(self):
|
||||||
|
"""CCI must strictly increase across the ladder rungs."""
|
||||||
|
ladder = [
|
||||||
|
self._rung1_linear_chain(),
|
||||||
|
self._rung2_clean_tree(),
|
||||||
|
self._rung3_layered_dag(),
|
||||||
|
self._rung4_diamond_cross(),
|
||||||
|
self._rung5_hub_backlinks(),
|
||||||
|
self._rung6_dense_mesh(),
|
||||||
|
]
|
||||||
|
ccis = [run_analysis(g).metrics.cci for g in ladder]
|
||||||
|
for i in range(len(ccis) - 1):
|
||||||
|
self.assertLess(
|
||||||
|
ccis[i], ccis[i + 1],
|
||||||
|
f"Rung {i + 1} (CCI={ccis[i]:.4f}) should be less complex "
|
||||||
|
f"than rung {i + 2} (CCI={ccis[i + 1]:.4f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Perturbation Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestPerturbation(unittest.TestCase):
|
||||||
|
"""Test that CCI responds correctly to architectural changes on the real graph."""
|
||||||
|
|
||||||
|
def _load_real_graph(self) -> DependencyGraph:
|
||||||
|
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||||
|
if not os.path.exists(dot_path):
|
||||||
|
self.skipTest("deps.dot not found")
|
||||||
|
with open(dot_path) as f:
|
||||||
|
return parse_dot(f.read())
|
||||||
|
|
||||||
|
def test_remove_most_coupled_module(self):
|
||||||
|
"""Removing the runtime module should decrease CCI."""
|
||||||
|
g = self._load_real_graph()
|
||||||
|
original_cci = run_analysis(g).metrics.cci
|
||||||
|
|
||||||
|
# Remove runtime nodes and their edges
|
||||||
|
g2 = DependencyGraph()
|
||||||
|
g2.modules = [m for m in g.modules if m != "runtime"]
|
||||||
|
for node in g.nodes:
|
||||||
|
if node.module != "runtime":
|
||||||
|
g2.nodes.append(node)
|
||||||
|
g2.node_to_module[node.name] = node.module
|
||||||
|
runtime_nodes = {n.name for n in g.nodes if n.module == "runtime"}
|
||||||
|
for edge in g.edges:
|
||||||
|
if edge.source not in runtime_nodes and edge.target not in runtime_nodes:
|
||||||
|
src_mod = g2.node_to_module.get(edge.source, "")
|
||||||
|
tgt_mod = g2.node_to_module.get(edge.target, "")
|
||||||
|
g2.edges.append(Edge(
|
||||||
|
source=edge.source, target=edge.target, label=edge.label,
|
||||||
|
edge_type=edge.edge_type,
|
||||||
|
cross_module=src_mod != tgt_mod,
|
||||||
|
))
|
||||||
|
|
||||||
|
reduced_cci = run_analysis(g2).metrics.cci
|
||||||
|
self.assertLess(reduced_cci, original_cci,
|
||||||
|
f"Removing runtime should decrease CCI: "
|
||||||
|
f"{reduced_cci:.4f} vs {original_cci:.4f}")
|
||||||
|
|
||||||
|
def test_add_random_cross_edges(self):
|
||||||
|
"""Adding 10 random cross-module edges should increase CCI."""
|
||||||
|
g = self._load_real_graph()
|
||||||
|
original_cci = run_analysis(g).metrics.cci
|
||||||
|
|
||||||
|
g2 = copy.deepcopy(g)
|
||||||
|
random.seed(42)
|
||||||
|
node_names = [n.name for n in g2.nodes]
|
||||||
|
added = 0
|
||||||
|
attempts = 0
|
||||||
|
while added < 10 and attempts < 100:
|
||||||
|
src, tgt = random.sample(node_names, 2)
|
||||||
|
src_mod = g2.node_to_module[src]
|
||||||
|
tgt_mod = g2.node_to_module[tgt]
|
||||||
|
if src_mod != tgt_mod:
|
||||||
|
g2.edges.append(Edge(
|
||||||
|
source=src, target=tgt, label="added",
|
||||||
|
edge_type="field", cross_module=True,
|
||||||
|
))
|
||||||
|
added += 1
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
augmented_cci = run_analysis(g2).metrics.cci
|
||||||
|
self.assertGreater(augmented_cci, original_cci,
|
||||||
|
f"Adding cross-module edges should increase CCI: "
|
||||||
|
f"{augmented_cci:.4f} vs {original_cci:.4f}")
|
||||||
|
|
||||||
|
def test_merge_modules_decreases_cci(self):
|
||||||
|
"""Merging two small modules into one should decrease CCI.
|
||||||
|
|
||||||
|
Merging error + config into a single module reduces cross-module
|
||||||
|
edges (their mutual and outward coupling consolidates), lowering CCI.
|
||||||
|
"""
|
||||||
|
g = self._load_real_graph()
|
||||||
|
original_cci = run_analysis(g).metrics.cci
|
||||||
|
|
||||||
|
# Merge error and config into "error_config"
|
||||||
|
merge_set = {"error", "config"}
|
||||||
|
merged_name = "error_config"
|
||||||
|
|
||||||
|
g2 = DependencyGraph()
|
||||||
|
g2.modules = [merged_name if m in merge_set else m
|
||||||
|
for m in g.modules if m not in merge_set]
|
||||||
|
if merged_name not in g2.modules:
|
||||||
|
g2.modules.insert(0, merged_name)
|
||||||
|
# Deduplicate
|
||||||
|
seen = set()
|
||||||
|
g2.modules = [m for m in g2.modules if not (m in seen or seen.add(m))]
|
||||||
|
|
||||||
|
for node in g.nodes:
|
||||||
|
new_mod = merged_name if node.module in merge_set else node.module
|
||||||
|
g2.nodes.append(Node(name=node.name, module=new_mod))
|
||||||
|
g2.node_to_module[node.name] = new_mod
|
||||||
|
|
||||||
|
for edge in g.edges:
|
||||||
|
src_mod = g2.node_to_module.get(edge.source, "")
|
||||||
|
tgt_mod = g2.node_to_module.get(edge.target, "")
|
||||||
|
g2.edges.append(Edge(
|
||||||
|
source=edge.source, target=edge.target, label=edge.label,
|
||||||
|
edge_type=edge.edge_type,
|
||||||
|
cross_module=src_mod != tgt_mod,
|
||||||
|
))
|
||||||
|
|
||||||
|
merged_cci = run_analysis(g2).metrics.cci
|
||||||
|
self.assertLess(merged_cci, original_cci,
|
||||||
|
f"Merging error+config should decrease CCI: "
|
||||||
|
f"{merged_cci:.4f} vs {original_cci:.4f}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Integration Tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestIntegration(unittest.TestCase):
|
||||||
|
def test_full_pipeline_real_graph(self):
|
||||||
|
"""Run full pipeline on real deps.dot and sanity-check outputs."""
|
||||||
|
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||||
|
if not os.path.exists(dot_path):
|
||||||
|
self.skipTest("deps.dot not found")
|
||||||
|
with open(dot_path) as f:
|
||||||
|
graph = parse_dot(f.read())
|
||||||
|
|
||||||
|
result = run_analysis(graph)
|
||||||
|
|
||||||
|
# Basic sanity checks
|
||||||
|
self.assertEqual(result.metrics.n_nodes, 36)
|
||||||
|
self.assertEqual(result.metrics.n_edges, 89)
|
||||||
|
self.assertEqual(result.metrics.n_modules, 8)
|
||||||
|
|
||||||
|
# Connected graph -> lambda_2 > 0
|
||||||
|
self.assertGreater(result.spectral.fiedler_value, 0,
|
||||||
|
"Connected graph should have lambda_2 > 0")
|
||||||
|
|
||||||
|
# CCI should be in a reasonable range for a well-structured codebase
|
||||||
|
self.assertGreater(result.metrics.cci, 0.05)
|
||||||
|
self.assertLess(result.metrics.cci, 0.9)
|
||||||
|
|
||||||
|
# Eigenvalues should be non-negative (Laplacian property)
|
||||||
|
self.assertTrue(np.all(result.spectral.eigenvalues >= -1e-10),
|
||||||
|
"Laplacian eigenvalues should be non-negative")
|
||||||
|
|
||||||
|
# First eigenvalue should be 0
|
||||||
|
self.assertAlmostEqual(result.spectral.eigenvalues[0], 0.0, places=8)
|
||||||
|
|
||||||
|
def test_report_generation(self):
|
||||||
|
"""Verify report contains expected sections."""
|
||||||
|
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||||
|
if not os.path.exists(dot_path):
|
||||||
|
self.skipTest("deps.dot not found")
|
||||||
|
with open(dot_path) as f:
|
||||||
|
graph = parse_dot(f.read())
|
||||||
|
result = run_analysis(graph)
|
||||||
|
report = generate_report(result)
|
||||||
|
|
||||||
|
self.assertIn("GRAPH SUMMARY", report)
|
||||||
|
self.assertIn("LAPLACIAN EIGENVALUE SPECTRUM", report)
|
||||||
|
self.assertIn("FIEDLER VECTOR", report)
|
||||||
|
self.assertIn("MODULE COUPLING MATRIX", report)
|
||||||
|
self.assertIn("CONNECTOME COMPLEXITY INDEX", report)
|
||||||
|
|
||||||
|
def test_json_output(self):
|
||||||
|
"""Verify JSON output is well-formed and contains expected keys."""
|
||||||
|
g = _make_graph(
|
||||||
|
["A", "B", "C"], ["m1", "m1", "m2"],
|
||||||
|
[("A", "B"), ("A", "C")],
|
||||||
|
module_order=["m1", "m2"],
|
||||||
|
)
|
||||||
|
result = run_analysis(g)
|
||||||
|
d = metrics_to_dict(result)
|
||||||
|
|
||||||
|
self.assertIn("graph", d)
|
||||||
|
self.assertIn("spectral", d)
|
||||||
|
self.assertIn("module_coupling", d)
|
||||||
|
self.assertIn("metrics", d)
|
||||||
|
self.assertEqual(d["graph"]["n_nodes"], 3)
|
||||||
|
self.assertIsInstance(d["spectral"]["eigenvalues"], list)
|
||||||
|
self.assertIsInstance(d["metrics"]["cci"], float)
|
||||||
|
|
||||||
|
# Should be JSON-serializable
|
||||||
|
json_str = json.dumps(d)
|
||||||
|
self.assertIsInstance(json_str, str)
|
||||||
|
|
||||||
|
def test_dashboard_generation(self):
|
||||||
|
"""Verify dashboard PNG can be generated without errors."""
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
except ImportError:
|
||||||
|
self.skipTest("matplotlib not available")
|
||||||
|
|
||||||
|
g = _make_graph(
|
||||||
|
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||||
|
[("A", "B"), ("A", "C"), ("C", "D")],
|
||||||
|
module_order=["m1", "m2"],
|
||||||
|
)
|
||||||
|
result = run_analysis(g)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
from spectral_analysis import generate_dashboard
|
||||||
|
generate_dashboard(result, path)
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
self.assertGreater(os.path.getsize(path), 1000,
|
||||||
|
"Dashboard should be a non-trivial PNG")
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in a new issue