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
|
||||
(S)mall (W)ASM-compatible (actor) library
|
||||
|
||||
## Useful
|
||||
Small, WASM-compatible actor runtime for Rust, with Python and WebAssembly
|
||||
bindings.
|
||||
|
||||
View code dependency DAG
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
|
||||
```
|
||||
|
||||
## Quick example
|
||||
## Quick Start (Rust)
|
||||
|
||||
```rust
|
||||
use swactor::{
|
||||
Ctx,
|
||||
actor::{ActorAddress, ActorInterface},
|
||||
runtime::{Runtime, RuntimeConfig},
|
||||
};
|
||||
use swactor::actor::{ActorAddress, ActorInterface};
|
||||
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Greeter { num_greeted: usize }
|
||||
#[derive(Clone)]
|
||||
struct Greet { name: String, reply_to: ActorAddress }
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct GreetMessage { who: String, return_addr: ActorAddress }
|
||||
#[derive(Clone)]
|
||||
struct Greeting(String);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct GreetResponse(String);
|
||||
struct Greeter;
|
||||
|
||||
impl ActorInterface for Greeter {
|
||||
type Incoming = GreetMessage;
|
||||
type Response = GreetResponse;
|
||||
type Incoming = Greet;
|
||||
type Response = Greeting;
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: GreetMessage) {
|
||||
let res = GreetResponse(format!("Hello, {}!", msg.who));
|
||||
self.num_greeted += 1;
|
||||
if let Err(_) = ctx.send(msg.return_addr, res) {
|
||||
self.num_greeted -= 1;
|
||||
}
|
||||
fn handle(&mut self, ctx: &Ctx, msg: Greet) {
|
||||
let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
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, GreetMessage {
|
||||
who: "world".into(),
|
||||
return_addr: *inbox.addr(),
|
||||
}).unwrap();
|
||||
rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
|
||||
for _ in 0..3 { rt.tick(); }
|
||||
let resp = inbox.try_recv().expect("should have response");
|
||||
println!("{}", resp.0); // "Hello, world!"
|
||||
println!("{}", inbox.try_recv().unwrap().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
|
||||
cargo build
|
||||
cargo test
|
||||
cargo test --features stress # stress tests
|
||||
cargo run --bin bench --release # benchmarks
|
||||
cargo run --example hello
|
||||
# Generate the dependency DAG
|
||||
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
|
||||
|
||||
# Run spectral analysis (outputs to docs/connectome/)
|
||||
source .venv/bin/activate
|
||||
python tools/spectral/spectral_analysis.py deps.dot
|
||||
```
|
||||
|
||||
This produces a text report, an interactive HTML dashboard, and a static PNG dashboard in `docs/connectome/`. See [docs/connectome.md](docs/connectome.md) for details on the metrics and interpretation.
|
||||
|
|
|
|||
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]
|
||||
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 crate::runtime::Ctx;
|
||||
use crate::Error;
|
||||
|
||||
/// The primary trait defining data that can be passed to and from actor processes
|
||||
pub trait Message: 'static + Sized + Clone + Send + Sync {}
|
||||
|
|
@ -49,3 +49,48 @@ where
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Object-safe inner trait for sending type-erased messages.
|
||||
pub(crate) trait ContextInner {
|
||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
|
||||
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error>;
|
||||
fn mailbox_waterlevel(&self) -> usize;
|
||||
}
|
||||
|
||||
/// Actor syscall interface — passed to `ActorInterface::handle()`.
|
||||
///
|
||||
/// Wraps a `&dyn ContextInner` to solve the object-safety problem while
|
||||
/// providing a typed public API.
|
||||
pub struct Ctx<'a> {
|
||||
inner: &'a dyn ContextInner,
|
||||
self_addr: ActorAddress,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub(crate) fn new(inner: &'a dyn ContextInner, self_addr: ActorAddress) -> Self {
|
||||
Self { inner, self_addr }
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
pub(crate) fn raw_inner(&self) -> &dyn ContextInner {
|
||||
self.inner
|
||||
}
|
||||
|
||||
/// Returns the address of the actor currently being ticked.
|
||||
pub fn self_addr(&self) -> ActorAddress {
|
||||
self.self_addr
|
||||
}
|
||||
|
||||
/// Send a typed message to an actor address.
|
||||
pub fn send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
|
||||
self.inner.send_any(addr, Box::new(msg))
|
||||
}
|
||||
|
||||
/// Spawn a new actor, returning its address.
|
||||
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
|
||||
let addr = ActorAddress::new_random();
|
||||
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
|
||||
self.inner.spawn_any(addr, boxed)?;
|
||||
Ok(addr)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use std::cell::RefCell;
|
|||
use pyo3::prelude::*;
|
||||
use pyo3::types::PyModule;
|
||||
|
||||
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor};
|
||||
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Ctx};
|
||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
||||
use crate::runtime::{Ctx, Inbox, Runtime, RuntimeHandle};
|
||||
use crate::runtime::{Inbox, Runtime, RuntimeHandle};
|
||||
use crate::Error;
|
||||
|
||||
// ─── PyMsg newtype ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -65,43 +65,8 @@ impl RuntimeHandle {
|
|||
}
|
||||
}
|
||||
|
||||
/// Actor syscall interface — passed to `ActorInterface::handle()`.
|
||||
///
|
||||
/// Wraps a `&dyn ContextInner` to solve the object-safety problem while
|
||||
/// providing a typed public API.
|
||||
pub struct Ctx<'a> {
|
||||
inner: &'a dyn ContextInner,
|
||||
self_addr: ActorAddress,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub(crate) fn new(inner: &'a dyn ContextInner, self_addr: ActorAddress) -> Self {
|
||||
Self { inner, self_addr }
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
pub(crate) fn raw_inner(&self) -> &dyn ContextInner {
|
||||
self.inner
|
||||
}
|
||||
|
||||
/// Returns the address of the actor currently being ticked.
|
||||
pub fn self_addr(&self) -> ActorAddress {
|
||||
self.self_addr
|
||||
}
|
||||
|
||||
/// Send a typed message to an actor address.
|
||||
pub fn send<M: Message>(&self, addr: ActorAddress, msg: M) -> Result<(), Error> {
|
||||
self.inner.send_any(addr, Box::new(msg))
|
||||
}
|
||||
|
||||
/// Spawn a new actor, returning its address.
|
||||
pub fn spawn<A: ActorInterface>(&self, actor: A) -> Result<ActorAddress, Error> {
|
||||
let addr = ActorAddress::new_random();
|
||||
let boxed: Box<dyn AnyActor> = Box::new(Actor::new(actor));
|
||||
self.inner.spawn_any(addr, boxed)?;
|
||||
Ok(addr)
|
||||
}
|
||||
}
|
||||
// Re-export Ctx and ContextInner for backwards compatibility
|
||||
pub use crate::actor::{ContextInner, Ctx};
|
||||
|
||||
/// Type-erased sender for external inboxes.
|
||||
pub(crate) trait SenderT: Send + Sync {
|
||||
|
|
@ -287,7 +252,7 @@ impl Runtime {
|
|||
inbox_registry: &rt_clone.inbox_registry,
|
||||
config: &rt_clone.config,
|
||||
};
|
||||
worker.run(&tc, &rt_clone.is_running, &rt_clone.config.backoff_policy);
|
||||
worker.run(&tc, &rt_clone.is_running);
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
|
@ -400,14 +365,6 @@ impl InboxRegistry {
|
|||
|
||||
|
||||
|
||||
/// Object-safe inner trait for sending type-erased messages.
|
||||
pub(crate) trait ContextInner {
|
||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error>;
|
||||
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error>;
|
||||
fn mailbox_waterlevel(&self) -> usize;
|
||||
}
|
||||
|
||||
|
||||
impl ContextInner for Runtime {
|
||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
||||
match self.address_map.lookup(&addr) {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
|||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use crate::actor::{ActorAddress, AnyActor, Message};
|
||||
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx, Message};
|
||||
use crate::address_map::{AddressMap, Placement, WorkerId};
|
||||
use crate::channel::{Receiver, Sender};
|
||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
||||
use crate::runtime::{ContextInner, Ctx, Envelope, InboxRegistry};
|
||||
use crate::config::RuntimeConfig;
|
||||
use crate::runtime::{Envelope, InboxRegistry};
|
||||
use crate::Error;
|
||||
|
||||
/// Per-worker stats published via atomics. Readable from any thread.
|
||||
|
|
@ -90,12 +90,7 @@ impl Worker {
|
|||
{
|
||||
let worker_ctx = WorkerContext {
|
||||
worker_id: self.id,
|
||||
address_map: tc.address_map,
|
||||
transfer_txs: tc.transfer_txs,
|
||||
spawn_txs: tc.spawn_txs,
|
||||
placement: tc.placement,
|
||||
inbox_registry: tc.inbox_registry,
|
||||
config: tc.config,
|
||||
tc,
|
||||
pending_local: &pending_local,
|
||||
};
|
||||
processed = self.pool.tick_all(&worker_ctx);
|
||||
|
|
@ -121,7 +116,8 @@ impl Worker {
|
|||
did_work
|
||||
}
|
||||
|
||||
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool, backoff: &BackoffPolicy) {
|
||||
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool) {
|
||||
let backoff = &tc.config.backoff_policy;
|
||||
let mut idle_count: u32 = 0;
|
||||
while is_running.load(Ordering::Acquire) {
|
||||
let did_work = self.tick_once(tc);
|
||||
|
|
@ -151,18 +147,13 @@ impl Worker {
|
|||
/// Cross-worker sends go through the transfer queue.
|
||||
struct WorkerContext<'a> {
|
||||
worker_id: WorkerId,
|
||||
address_map: &'a AddressMap,
|
||||
transfer_txs: &'a [Sender<Envelope>],
|
||||
spawn_txs: &'a [Sender<(ActorAddress, Box<dyn AnyActor>)>],
|
||||
placement: &'a Placement,
|
||||
inbox_registry: &'a InboxRegistry,
|
||||
config: &'a RuntimeConfig,
|
||||
tc: &'a TickContext<'a>,
|
||||
pending_local: &'a RefCell<Vec<(ActorAddress, Box<dyn Any + Send>)>>,
|
||||
}
|
||||
|
||||
impl ContextInner for WorkerContext<'_> {
|
||||
fn send_any(&self, addr: ActorAddress, msg: Box<dyn Any + Send>) -> Result<(), Error> {
|
||||
match self.address_map.lookup(&addr) {
|
||||
match self.tc.address_map.lookup(&addr) {
|
||||
Some(wid) if wid == self.worker_id => {
|
||||
// Same worker: buffer for local delivery (after current tick round)
|
||||
self.pending_local.borrow_mut().push((addr, msg));
|
||||
|
|
@ -171,26 +162,26 @@ impl ContextInner for WorkerContext<'_> {
|
|||
Some(wid) => {
|
||||
// Cross worker: envelope through transfer queue
|
||||
let envelope = Envelope::new(addr, msg);
|
||||
let _ = self.transfer_txs[wid.as_usize()].try_send(envelope);
|
||||
let _ = self.tc.transfer_txs[wid.as_usize()].try_send(envelope);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
// Try inbox registry (external inboxes)
|
||||
self.inbox_registry.try_deliver(addr, msg)
|
||||
self.tc.inbox_registry.try_deliver(addr, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_any(&self, addr: ActorAddress, actor: Box<dyn AnyActor>) -> Result<(), Error> {
|
||||
let worker_id = self.placement.next_worker();
|
||||
self.address_map.insert(addr, worker_id);
|
||||
self.spawn_txs[worker_id.as_usize()]
|
||||
let worker_id = self.tc.placement.next_worker();
|
||||
self.tc.address_map.insert(addr, worker_id);
|
||||
self.tc.spawn_txs[worker_id.as_usize()]
|
||||
.try_send((addr, actor))
|
||||
.map_err(|_| Error::from("Spawn queue full"))
|
||||
}
|
||||
|
||||
fn mailbox_waterlevel(&self) -> usize {
|
||||
self.config.mailbox_waterlevel
|
||||
self.tc.config.mailbox_waterlevel
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use crate::actor::{ActorAddress, AnyActor};
|
||||
use crate::actor::{ActorAddress, AnyActor, Ctx};
|
||||
use crate::address_map::{AddressMap, Placement, WorkerId};
|
||||
use crate::channel::Receiver;
|
||||
use crate::config::{BackoffPolicy, RuntimeConfig};
|
||||
use crate::runtime::{Ctx, Envelope, InboxRegistry};
|
||||
use crate::config::RuntimeConfig;
|
||||
use crate::runtime::{Envelope, InboxRegistry};
|
||||
|
||||
use super::{TickContext, Worker, WorkerStats};
|
||||
|
||||
|
|
@ -399,7 +399,6 @@ fn run_loop_stops_on_shutdown() {
|
|||
let mut worker = Worker::new(WorkerId(0), transfer_rx, spawn_rx, stats);
|
||||
|
||||
let is_running = AtomicBool::new(false);
|
||||
let backoff = BackoffPolicy::default();
|
||||
let address_map = AddressMap::new();
|
||||
let placement = Placement::new(1);
|
||||
let inbox_registry = InboxRegistry::new();
|
||||
|
|
@ -415,6 +414,6 @@ fn run_loop_stops_on_shutdown() {
|
|||
};
|
||||
|
||||
thread::scope(|s| {
|
||||
s.spawn(|| worker.run(&tc, &is_running, &backoff));
|
||||
s.spawn(|| worker.run(&tc, &is_running));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
2
tools/spectral/.gitignore
vendored
Normal file
2
tools/spectral/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
__pycache__
|
||||
output/*
|
||||
1463
tools/spectral/spectral_analysis.py
Normal file
1463
tools/spectral/spectral_analysis.py
Normal file
File diff suppressed because it is too large
Load diff
727
tools/spectral/test_spectral.py
Normal file
727
tools/spectral/test_spectral.py
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Comprehensive tests for the spectral analysis tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from spectral_analysis import (
|
||||
AnalysisResult,
|
||||
ComplexityMetrics,
|
||||
DependencyGraph,
|
||||
Edge,
|
||||
ModuleCouplingResult,
|
||||
Node,
|
||||
SpectralResults,
|
||||
build_adjacency,
|
||||
build_laplacian,
|
||||
compute_complexity_metrics,
|
||||
compute_module_coupling,
|
||||
compute_spectral,
|
||||
compute_spectral_entropy,
|
||||
count_connected_components,
|
||||
generate_report,
|
||||
get_node_ordering,
|
||||
metrics_to_dict,
|
||||
parse_dot,
|
||||
run_analysis,
|
||||
symmetrize,
|
||||
)
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_graph(
|
||||
names: list[str],
|
||||
modules: list[str],
|
||||
edge_pairs: list[tuple[str, str]],
|
||||
module_order: list[str] | None = None,
|
||||
) -> DependencyGraph:
|
||||
"""Build a DependencyGraph from names, module assignments, and edges."""
|
||||
assert len(names) == len(modules)
|
||||
graph = DependencyGraph()
|
||||
seen_modules: list[str] = []
|
||||
for name, mod in zip(names, modules):
|
||||
graph.nodes.append(Node(name=name, module=mod))
|
||||
graph.node_to_module[name] = mod
|
||||
if mod not in seen_modules:
|
||||
seen_modules.append(mod)
|
||||
if module_order is not None:
|
||||
graph.modules = module_order
|
||||
else:
|
||||
graph.modules = seen_modules
|
||||
for src, tgt in edge_pairs:
|
||||
src_mod = graph.node_to_module.get(src, "")
|
||||
tgt_mod = graph.node_to_module.get(tgt, "")
|
||||
cross = src_mod != tgt_mod
|
||||
graph.edges.append(Edge(
|
||||
source=src, target=tgt, label="dep",
|
||||
edge_type="field", cross_module=cross,
|
||||
))
|
||||
return graph
|
||||
|
||||
|
||||
# ─── DOT Parser Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestDotParser(unittest.TestCase):
|
||||
def test_minimal_dot(self):
|
||||
dot = '''digraph test {
|
||||
subgraph cluster_mod1 {
|
||||
label="mod1";
|
||||
A [label="A", fillcolor="#fff"];
|
||||
}
|
||||
A -> A [label="self", style=dashed, color="#666", penwidth=1];
|
||||
}'''
|
||||
g = parse_dot(dot)
|
||||
self.assertEqual(len(g.nodes), 1)
|
||||
self.assertEqual(g.nodes[0].name, "A")
|
||||
self.assertEqual(g.nodes[0].module, "mod1")
|
||||
self.assertEqual(len(g.edges), 1)
|
||||
|
||||
def test_two_module_dot(self):
|
||||
dot = '''digraph test {
|
||||
subgraph cluster_alpha {
|
||||
label="alpha";
|
||||
X [label="X"];
|
||||
Y [label="Y"];
|
||||
}
|
||||
subgraph cluster_beta {
|
||||
label="beta";
|
||||
Z [label="Z"];
|
||||
}
|
||||
X -> Y [label="dep", style=dashed, color="#666", penwidth=1];
|
||||
X -> Z [label="dep", style=solid, color="#00f", penwidth=1.5];
|
||||
}'''
|
||||
g = parse_dot(dot)
|
||||
self.assertEqual(len(g.nodes), 3)
|
||||
self.assertEqual(len(g.modules), 2)
|
||||
self.assertEqual(g.modules, ["alpha", "beta"])
|
||||
self.assertEqual(g.node_to_module["X"], "alpha")
|
||||
self.assertEqual(g.node_to_module["Z"], "beta")
|
||||
|
||||
# Edge classification
|
||||
intra = [e for e in g.edges if not e.cross_module]
|
||||
cross = [e for e in g.edges if e.cross_module]
|
||||
self.assertEqual(len(intra), 1)
|
||||
self.assertEqual(len(cross), 1)
|
||||
|
||||
def test_trait_impl_classification(self):
|
||||
dot = '''digraph test {
|
||||
subgraph cluster_m {
|
||||
label="m";
|
||||
A [label="A"];
|
||||
B [label="B"];
|
||||
}
|
||||
A -> B [label="impl", style=dotted, color="#666", penwidth=1];
|
||||
}'''
|
||||
g = parse_dot(dot)
|
||||
self.assertEqual(g.edges[0].edge_type, "trait_impl")
|
||||
|
||||
def test_real_deps_dot(self):
|
||||
"""Parse the real deps.dot and verify expected counts."""
|
||||
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||
if not os.path.exists(dot_path):
|
||||
self.skipTest("deps.dot not found")
|
||||
with open(dot_path) as f:
|
||||
dot = f.read()
|
||||
g = parse_dot(dot)
|
||||
self.assertEqual(len(g.nodes), 36, f"Expected 36 nodes, got {len(g.nodes)}")
|
||||
self.assertEqual(len(g.edges), 89, f"Expected 89 edges, got {len(g.edges)}")
|
||||
self.assertEqual(len(g.modules), 8, f"Expected 8 modules, got {len(g.modules)}")
|
||||
|
||||
def test_empty_dot(self):
|
||||
dot = "digraph empty {}"
|
||||
g = parse_dot(dot)
|
||||
self.assertEqual(len(g.nodes), 0)
|
||||
self.assertEqual(len(g.edges), 0)
|
||||
|
||||
|
||||
# ─── Matrix Construction Tests ────────────────────────────────────────────────
|
||||
|
||||
class TestMatrixConstruction(unittest.TestCase):
|
||||
def test_two_node_adjacency(self):
|
||||
g = _make_graph(["A", "B"], ["m", "m"], [("A", "B")])
|
||||
order = get_node_ordering(g)
|
||||
A = build_adjacency(g, order)
|
||||
self.assertEqual(A.shape, (2, 2))
|
||||
idx_a = order.index("A")
|
||||
idx_b = order.index("B")
|
||||
self.assertEqual(A[idx_a, idx_b], 1.0)
|
||||
self.assertEqual(A[idx_b, idx_a], 0.0)
|
||||
|
||||
def test_symmetrize_directed(self):
|
||||
A = np.array([[0, 1, 0],
|
||||
[0, 0, 1],
|
||||
[0, 0, 0]], dtype=float)
|
||||
S = symmetrize(A)
|
||||
expected = np.array([[0, 1, 0],
|
||||
[1, 0, 1],
|
||||
[0, 1, 0]], dtype=float)
|
||||
np.testing.assert_array_equal(S, expected)
|
||||
|
||||
def test_symmetrize_idempotent(self):
|
||||
"""Symmetrizing an already-symmetric matrix should not change it."""
|
||||
A = np.array([[0, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 0]], dtype=float)
|
||||
S = symmetrize(A)
|
||||
np.testing.assert_array_equal(S, A)
|
||||
|
||||
def test_laplacian_p3(self):
|
||||
"""Path graph P3: A-B-C."""
|
||||
A_sym = np.array([[0, 1, 0],
|
||||
[1, 0, 1],
|
||||
[0, 1, 0]], dtype=float)
|
||||
L = build_laplacian(A_sym)
|
||||
expected = np.array([[1, -1, 0],
|
||||
[-1, 2, -1],
|
||||
[0, -1, 1]], dtype=float)
|
||||
np.testing.assert_array_equal(L, expected)
|
||||
|
||||
def test_laplacian_k3(self):
|
||||
"""Complete graph K3."""
|
||||
A_sym = np.array([[0, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 0]], dtype=float)
|
||||
L = build_laplacian(A_sym)
|
||||
expected = np.array([[2, -1, -1],
|
||||
[-1, 2, -1],
|
||||
[-1, -1, 2]], dtype=float)
|
||||
np.testing.assert_array_equal(L, expected)
|
||||
|
||||
|
||||
# ─── Spectral Analysis Tests ─────────────────────────────────────────────────
|
||||
|
||||
class TestSpectralAnalysis(unittest.TestCase):
|
||||
def test_p3_eigenvalues(self):
|
||||
"""Path P3 should have eigenvalues {0, 1, 3}."""
|
||||
g = _make_graph(["A", "B", "C"], ["m", "m", "m"],
|
||||
[("A", "B"), ("B", "C")])
|
||||
s = compute_spectral(g)
|
||||
np.testing.assert_allclose(sorted(s.eigenvalues), [0, 1, 3], atol=1e-10)
|
||||
|
||||
def test_k4_eigenvalues(self):
|
||||
"""Complete K4 should have eigenvalues {0, 4, 4, 4}."""
|
||||
names = ["A", "B", "C", "D"]
|
||||
edges = [(a, b) for a in names for b in names if a != b]
|
||||
g = _make_graph(names, ["m"] * 4, edges)
|
||||
s = compute_spectral(g)
|
||||
np.testing.assert_allclose(sorted(s.eigenvalues), [0, 4, 4, 4], atol=1e-10)
|
||||
|
||||
def test_star_s4_fiedler(self):
|
||||
"""Star graph S4 (center + 3 leaves): lambda_2 = 1."""
|
||||
g = _make_graph(
|
||||
["C", "L1", "L2", "L3"], ["m"] * 4,
|
||||
[("C", "L1"), ("C", "L2"), ("C", "L3")],
|
||||
)
|
||||
s = compute_spectral(g)
|
||||
self.assertAlmostEqual(s.fiedler_value, 1.0, places=10)
|
||||
|
||||
def test_disconnected_graph(self):
|
||||
"""Disconnected graph should have lambda_2 = 0."""
|
||||
g = _make_graph(
|
||||
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||
[("A", "B"), ("C", "D")],
|
||||
module_order=["m1", "m2"],
|
||||
)
|
||||
s = compute_spectral(g)
|
||||
self.assertAlmostEqual(s.fiedler_value, 0.0, places=10)
|
||||
|
||||
def test_barbell_fiedler_separation(self):
|
||||
"""Barbell graph: two K3 cliques connected by a bridge.
|
||||
|
||||
Fiedler vector should separate the two cliques (different signs).
|
||||
"""
|
||||
# Clique 1: A, B, C fully connected
|
||||
# Clique 2: D, E, F fully connected
|
||||
# Bridge: C-D
|
||||
names = ["A", "B", "C", "D", "E", "F"]
|
||||
edges = [
|
||||
("A", "B"), ("A", "C"), ("B", "C"),
|
||||
("D", "E"), ("D", "F"), ("E", "F"),
|
||||
("C", "D"),
|
||||
]
|
||||
g = _make_graph(names, ["m1", "m1", "m1", "m2", "m2", "m2"], edges,
|
||||
module_order=["m1", "m2"])
|
||||
s = compute_spectral(g)
|
||||
|
||||
# Clique 1 nodes should have same sign, clique 2 opposite
|
||||
order = s.node_names
|
||||
fv = s.fiedler_vector
|
||||
idx = {name: i for i, name in enumerate(order)}
|
||||
|
||||
clique1_signs = [np.sign(fv[idx[n]]) for n in ["A", "B", "C"]]
|
||||
clique2_signs = [np.sign(fv[idx[n]]) for n in ["D", "E", "F"]]
|
||||
|
||||
# All in clique 1 should have the same sign
|
||||
self.assertTrue(all(s == clique1_signs[0] for s in clique1_signs),
|
||||
f"Clique 1 signs should be uniform: {clique1_signs}")
|
||||
# All in clique 2 should have the same sign
|
||||
self.assertTrue(all(s == clique2_signs[0] for s in clique2_signs),
|
||||
f"Clique 2 signs should be uniform: {clique2_signs}")
|
||||
# The two cliques should have opposite signs
|
||||
self.assertNotEqual(clique1_signs[0], clique2_signs[0],
|
||||
"Cliques should have opposite Fiedler signs")
|
||||
|
||||
def test_single_node(self):
|
||||
g = _make_graph(["A"], ["m"], [])
|
||||
s = compute_spectral(g)
|
||||
self.assertEqual(s.fiedler_value, 0.0)
|
||||
self.assertEqual(len(s.eigenvalues), 1)
|
||||
|
||||
def test_empty_graph(self):
|
||||
g = DependencyGraph()
|
||||
s = compute_spectral(g)
|
||||
self.assertEqual(s.fiedler_value, 0.0)
|
||||
self.assertEqual(len(s.eigenvalues), 0)
|
||||
|
||||
|
||||
# ─── Module Coupling Tests ────────────────────────────────────────────────────
|
||||
|
||||
class TestModuleCoupling(unittest.TestCase):
|
||||
def test_directed_counts(self):
|
||||
g = _make_graph(
|
||||
["A", "B", "C"], ["m1", "m1", "m2"],
|
||||
[("A", "C"), ("B", "C"), ("C", "A")],
|
||||
module_order=["m1", "m2"],
|
||||
)
|
||||
c = compute_module_coupling(g)
|
||||
# m1->m2: 2 edges (A->C, B->C)
|
||||
# m2->m1: 1 edge (C->A)
|
||||
idx_m1 = c.module_names.index("m1")
|
||||
idx_m2 = c.module_names.index("m2")
|
||||
self.assertEqual(c.coupling_matrix[idx_m1, idx_m2], 2.0)
|
||||
self.assertEqual(c.coupling_matrix[idx_m2, idx_m1], 1.0)
|
||||
|
||||
def test_cross_module_ratio(self):
|
||||
g = _make_graph(
|
||||
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||
[("A", "B"), ("A", "C"), ("C", "D")],
|
||||
module_order=["m1", "m2"],
|
||||
)
|
||||
c = compute_module_coupling(g)
|
||||
# 1 cross-module edge (A->C) out of 3 total
|
||||
self.assertEqual(c.cross_module_edges, 1)
|
||||
self.assertEqual(c.total_edges, 3)
|
||||
|
||||
def test_intra_only(self):
|
||||
g = _make_graph(
|
||||
["A", "B"], ["m1", "m1"],
|
||||
[("A", "B")],
|
||||
module_order=["m1"],
|
||||
)
|
||||
c = compute_module_coupling(g)
|
||||
self.assertEqual(c.cross_module_edges, 0)
|
||||
self.assertEqual(c.coupling_matrix[0, 0], 1.0)
|
||||
|
||||
|
||||
# ─── Complexity Metrics Tests ─────────────────────────────────────────────────
|
||||
|
||||
class TestComplexityMetrics(unittest.TestCase):
|
||||
def test_k4_spectral_entropy(self):
|
||||
"""K4 has uniform positive eigenvalues {4,4,4} -> entropy = log2(3)."""
|
||||
evals = np.array([0.0, 4.0, 4.0, 4.0])
|
||||
H = compute_spectral_entropy(evals)
|
||||
self.assertAlmostEqual(H, math.log2(3), places=10)
|
||||
|
||||
def test_star_entropy_less_than_complete(self):
|
||||
"""Star graph has less uniform eigenvalues than complete graph."""
|
||||
# Star S4: eigenvalues are 0, 1, 1, 4
|
||||
star_evals = np.array([0.0, 1.0, 1.0, 4.0])
|
||||
k4_evals = np.array([0.0, 4.0, 4.0, 4.0])
|
||||
H_star = compute_spectral_entropy(star_evals)
|
||||
H_k4 = compute_spectral_entropy(k4_evals)
|
||||
self.assertLess(H_star, H_k4)
|
||||
|
||||
def test_cci_in_range(self):
|
||||
"""CCI should always be in [0, 1]."""
|
||||
for _ in range(20):
|
||||
n = random.randint(2, 10)
|
||||
names = [f"N{i}" for i in range(n)]
|
||||
mods = [f"m{i % 3}" for i in range(n)]
|
||||
edges = []
|
||||
for _ in range(random.randint(1, n * 2)):
|
||||
a, b = random.sample(names, 2)
|
||||
edges.append((a, b))
|
||||
g = _make_graph(names, mods, edges,
|
||||
module_order=sorted(set(mods)))
|
||||
result = run_analysis(g)
|
||||
self.assertGreaterEqual(result.metrics.cci, 0.0,
|
||||
"CCI should be >= 0")
|
||||
self.assertLessEqual(result.metrics.cci, 1.0,
|
||||
"CCI should be <= 1")
|
||||
|
||||
def test_cci_increases_with_coupling(self):
|
||||
"""Adding cross-module edges should increase CCI."""
|
||||
# Base graph: two modules, minimal coupling
|
||||
g1 = _make_graph(
|
||||
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||
[("A", "B"), ("C", "D"), ("A", "C")],
|
||||
module_order=["m1", "m2"],
|
||||
)
|
||||
# More coupling
|
||||
g2 = _make_graph(
|
||||
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||
[("A", "B"), ("C", "D"), ("A", "C"), ("A", "D"),
|
||||
("B", "C"), ("B", "D"), ("C", "A"), ("D", "B")],
|
||||
module_order=["m1", "m2"],
|
||||
)
|
||||
r1 = run_analysis(g1)
|
||||
r2 = run_analysis(g2)
|
||||
self.assertLess(r1.metrics.cci, r2.metrics.cci)
|
||||
|
||||
def test_connected_components(self):
|
||||
A_sym = np.array([
|
||||
[0, 1, 0, 0],
|
||||
[1, 0, 0, 0],
|
||||
[0, 0, 0, 1],
|
||||
[0, 0, 1, 0],
|
||||
], dtype=float)
|
||||
self.assertEqual(count_connected_components(A_sym), 2)
|
||||
|
||||
def test_single_component(self):
|
||||
A_sym = np.array([
|
||||
[0, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 0],
|
||||
], dtype=float)
|
||||
self.assertEqual(count_connected_components(A_sym), 1)
|
||||
|
||||
|
||||
# ─── Complexity Ladder ────────────────────────────────────────────────────────
|
||||
|
||||
class TestComplexityLadder(unittest.TestCase):
|
||||
"""Verify CCI correctly orders synthetic codebases of increasing complexity."""
|
||||
|
||||
def _rung1_linear_chain(self) -> DependencyGraph:
|
||||
"""5 nodes in a single module, linear chain A->B->C->D->E."""
|
||||
return _make_graph(
|
||||
["A", "B", "C", "D", "E"],
|
||||
["m1"] * 5,
|
||||
[("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")],
|
||||
module_order=["m1"],
|
||||
)
|
||||
|
||||
def _rung2_clean_tree(self) -> DependencyGraph:
|
||||
"""6 nodes across 2 modules, tree with mostly intra-module edges."""
|
||||
return _make_graph(
|
||||
["R", "A", "B", "C", "D", "E"],
|
||||
["core", "core", "core", "util", "util", "util"],
|
||||
[
|
||||
("R", "A"), ("A", "B"), ("R", "C"), # intra core
|
||||
("D", "E"), # intra util
|
||||
("R", "D"), ("C", "E"), # 2 cross edges
|
||||
],
|
||||
module_order=["core", "util"],
|
||||
)
|
||||
|
||||
def _rung3_layered_dag(self) -> DependencyGraph:
|
||||
"""8 nodes across 3 modules in a layered architecture."""
|
||||
return _make_graph(
|
||||
["C1", "C2", "S1", "S2", "S3", "D1", "D2", "D3"],
|
||||
["ctrl", "ctrl", "svc", "svc", "svc", "data", "data", "data"],
|
||||
[
|
||||
("C1", "C2"), # intra ctrl
|
||||
("S1", "S2"), ("S2", "S3"), # intra svc
|
||||
("D1", "D2"), ("D2", "D3"), # intra data
|
||||
("C1", "S1"), ("C1", "S2"), ("C2", "S3"), # ctrl->svc
|
||||
("S1", "D1"), ("S2", "D2"), ("S3", "D3"), # svc->data
|
||||
],
|
||||
module_order=["ctrl", "svc", "data"],
|
||||
)
|
||||
|
||||
def _rung4_diamond_cross(self) -> DependencyGraph:
|
||||
"""10 nodes across 5 modules with diamond patterns and cross-coupling."""
|
||||
return _make_graph(
|
||||
["A1", "A2", "B1", "B2", "C1", "C2", "D1", "D2", "E1", "E2"],
|
||||
["ma", "ma", "mb", "mb", "mc", "mc", "md", "md", "me", "me"],
|
||||
[
|
||||
("A1", "A2"), ("B1", "B2"), ("C1", "C2"), # intra
|
||||
("D1", "D2"), ("E1", "E2"), # intra
|
||||
# Diamonds across modules
|
||||
("A1", "B1"), ("A1", "C1"), ("B1", "D1"), ("C1", "D1"),
|
||||
("A2", "B2"), ("A2", "C2"), ("B2", "D2"), ("C2", "D2"),
|
||||
# Extra cross-coupling
|
||||
("D1", "E1"), ("D2", "E2"), ("B1", "E1"),
|
||||
],
|
||||
module_order=["ma", "mb", "mc", "md", "me"],
|
||||
)
|
||||
|
||||
def _rung5_hub_backlinks(self) -> DependencyGraph:
|
||||
"""10 nodes across 5 modules, hub-dominated with back-edges."""
|
||||
return _make_graph(
|
||||
["Hub", "A1", "A2", "B1", "B2", "C1", "C2", "D1", "D2", "D3"],
|
||||
["core", "sa", "sa", "sb", "sb", "sc", "sc", "sd", "sd", "sd"],
|
||||
[
|
||||
("A1", "A2"), ("B1", "B2"), ("C1", "C2"), # intra
|
||||
("D1", "D2"), ("D2", "D3"), # intra
|
||||
# Hub connections (cross-module)
|
||||
("Hub", "A1"), ("Hub", "B1"), ("Hub", "C1"), ("Hub", "D1"),
|
||||
("A1", "Hub"), ("B1", "Hub"), ("C1", "Hub"),
|
||||
# Additional cross-module
|
||||
("A1", "B1"), ("B1", "C1"), ("C1", "D1"),
|
||||
("A2", "B2"), ("B2", "C2"), ("C2", "D2"),
|
||||
("A1", "D1"), ("B2", "D3"),
|
||||
],
|
||||
module_order=["core", "sa", "sb", "sc", "sd"],
|
||||
)
|
||||
|
||||
def _rung6_dense_mesh(self) -> DependencyGraph:
|
||||
"""10 nodes across 4 modules with heavy cross-module coupling."""
|
||||
names = ["X1", "X2", "X3", "Y1", "Y2", "Y3", "Z1", "Z2", "W1", "W2"]
|
||||
mods = ["mx", "mx", "mx", "my", "my", "my", "mz", "mz", "mw", "mw"]
|
||||
# Dense cross-module edges
|
||||
edges = [
|
||||
# intra
|
||||
("X1", "X2"), ("X2", "X3"), ("Y1", "Y2"), ("Y2", "Y3"),
|
||||
("Z1", "Z2"), ("W1", "W2"),
|
||||
# cross - nearly every module to every other
|
||||
("X1", "Y1"), ("X1", "Z1"), ("X1", "W1"),
|
||||
("X2", "Y2"), ("X2", "Z2"), ("X2", "W2"),
|
||||
("X3", "Y3"), ("X3", "Z1"),
|
||||
("Y1", "X1"), ("Y1", "Z1"), ("Y1", "W1"),
|
||||
("Y2", "X2"), ("Y2", "Z2"),
|
||||
("Y3", "X3"), ("Y3", "W2"),
|
||||
("Z1", "X1"), ("Z1", "Y1"), ("Z1", "W1"),
|
||||
("Z2", "X2"), ("Z2", "Y2"), ("Z2", "W2"),
|
||||
("W1", "X1"), ("W1", "Y1"), ("W1", "Z1"),
|
||||
("W2", "X2"), ("W2", "Y2"), ("W2", "Z2"),
|
||||
]
|
||||
return _make_graph(names, mods, edges,
|
||||
module_order=["mx", "my", "mz", "mw"])
|
||||
|
||||
def test_complexity_ladder(self):
|
||||
"""CCI must strictly increase across the ladder rungs."""
|
||||
ladder = [
|
||||
self._rung1_linear_chain(),
|
||||
self._rung2_clean_tree(),
|
||||
self._rung3_layered_dag(),
|
||||
self._rung4_diamond_cross(),
|
||||
self._rung5_hub_backlinks(),
|
||||
self._rung6_dense_mesh(),
|
||||
]
|
||||
ccis = [run_analysis(g).metrics.cci for g in ladder]
|
||||
for i in range(len(ccis) - 1):
|
||||
self.assertLess(
|
||||
ccis[i], ccis[i + 1],
|
||||
f"Rung {i + 1} (CCI={ccis[i]:.4f}) should be less complex "
|
||||
f"than rung {i + 2} (CCI={ccis[i + 1]:.4f})"
|
||||
)
|
||||
|
||||
|
||||
# ─── Perturbation Tests ──────────────────────────────────────────────────────
|
||||
|
||||
class TestPerturbation(unittest.TestCase):
|
||||
"""Test that CCI responds correctly to architectural changes on the real graph."""
|
||||
|
||||
def _load_real_graph(self) -> DependencyGraph:
|
||||
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||
if not os.path.exists(dot_path):
|
||||
self.skipTest("deps.dot not found")
|
||||
with open(dot_path) as f:
|
||||
return parse_dot(f.read())
|
||||
|
||||
def test_remove_most_coupled_module(self):
|
||||
"""Removing the runtime module should decrease CCI."""
|
||||
g = self._load_real_graph()
|
||||
original_cci = run_analysis(g).metrics.cci
|
||||
|
||||
# Remove runtime nodes and their edges
|
||||
g2 = DependencyGraph()
|
||||
g2.modules = [m for m in g.modules if m != "runtime"]
|
||||
for node in g.nodes:
|
||||
if node.module != "runtime":
|
||||
g2.nodes.append(node)
|
||||
g2.node_to_module[node.name] = node.module
|
||||
runtime_nodes = {n.name for n in g.nodes if n.module == "runtime"}
|
||||
for edge in g.edges:
|
||||
if edge.source not in runtime_nodes and edge.target not in runtime_nodes:
|
||||
src_mod = g2.node_to_module.get(edge.source, "")
|
||||
tgt_mod = g2.node_to_module.get(edge.target, "")
|
||||
g2.edges.append(Edge(
|
||||
source=edge.source, target=edge.target, label=edge.label,
|
||||
edge_type=edge.edge_type,
|
||||
cross_module=src_mod != tgt_mod,
|
||||
))
|
||||
|
||||
reduced_cci = run_analysis(g2).metrics.cci
|
||||
self.assertLess(reduced_cci, original_cci,
|
||||
f"Removing runtime should decrease CCI: "
|
||||
f"{reduced_cci:.4f} vs {original_cci:.4f}")
|
||||
|
||||
def test_add_random_cross_edges(self):
|
||||
"""Adding 10 random cross-module edges should increase CCI."""
|
||||
g = self._load_real_graph()
|
||||
original_cci = run_analysis(g).metrics.cci
|
||||
|
||||
g2 = copy.deepcopy(g)
|
||||
random.seed(42)
|
||||
node_names = [n.name for n in g2.nodes]
|
||||
added = 0
|
||||
attempts = 0
|
||||
while added < 10 and attempts < 100:
|
||||
src, tgt = random.sample(node_names, 2)
|
||||
src_mod = g2.node_to_module[src]
|
||||
tgt_mod = g2.node_to_module[tgt]
|
||||
if src_mod != tgt_mod:
|
||||
g2.edges.append(Edge(
|
||||
source=src, target=tgt, label="added",
|
||||
edge_type="field", cross_module=True,
|
||||
))
|
||||
added += 1
|
||||
attempts += 1
|
||||
|
||||
augmented_cci = run_analysis(g2).metrics.cci
|
||||
self.assertGreater(augmented_cci, original_cci,
|
||||
f"Adding cross-module edges should increase CCI: "
|
||||
f"{augmented_cci:.4f} vs {original_cci:.4f}")
|
||||
|
||||
def test_merge_modules_decreases_cci(self):
|
||||
"""Merging two small modules into one should decrease CCI.
|
||||
|
||||
Merging error + config into a single module reduces cross-module
|
||||
edges (their mutual and outward coupling consolidates), lowering CCI.
|
||||
"""
|
||||
g = self._load_real_graph()
|
||||
original_cci = run_analysis(g).metrics.cci
|
||||
|
||||
# Merge error and config into "error_config"
|
||||
merge_set = {"error", "config"}
|
||||
merged_name = "error_config"
|
||||
|
||||
g2 = DependencyGraph()
|
||||
g2.modules = [merged_name if m in merge_set else m
|
||||
for m in g.modules if m not in merge_set]
|
||||
if merged_name not in g2.modules:
|
||||
g2.modules.insert(0, merged_name)
|
||||
# Deduplicate
|
||||
seen = set()
|
||||
g2.modules = [m for m in g2.modules if not (m in seen or seen.add(m))]
|
||||
|
||||
for node in g.nodes:
|
||||
new_mod = merged_name if node.module in merge_set else node.module
|
||||
g2.nodes.append(Node(name=node.name, module=new_mod))
|
||||
g2.node_to_module[node.name] = new_mod
|
||||
|
||||
for edge in g.edges:
|
||||
src_mod = g2.node_to_module.get(edge.source, "")
|
||||
tgt_mod = g2.node_to_module.get(edge.target, "")
|
||||
g2.edges.append(Edge(
|
||||
source=edge.source, target=edge.target, label=edge.label,
|
||||
edge_type=edge.edge_type,
|
||||
cross_module=src_mod != tgt_mod,
|
||||
))
|
||||
|
||||
merged_cci = run_analysis(g2).metrics.cci
|
||||
self.assertLess(merged_cci, original_cci,
|
||||
f"Merging error+config should decrease CCI: "
|
||||
f"{merged_cci:.4f} vs {original_cci:.4f}")
|
||||
|
||||
|
||||
# ─── Integration Tests ────────────────────────────────────────────────────────
|
||||
|
||||
class TestIntegration(unittest.TestCase):
|
||||
def test_full_pipeline_real_graph(self):
|
||||
"""Run full pipeline on real deps.dot and sanity-check outputs."""
|
||||
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||
if not os.path.exists(dot_path):
|
||||
self.skipTest("deps.dot not found")
|
||||
with open(dot_path) as f:
|
||||
graph = parse_dot(f.read())
|
||||
|
||||
result = run_analysis(graph)
|
||||
|
||||
# Basic sanity checks
|
||||
self.assertEqual(result.metrics.n_nodes, 36)
|
||||
self.assertEqual(result.metrics.n_edges, 89)
|
||||
self.assertEqual(result.metrics.n_modules, 8)
|
||||
|
||||
# Connected graph -> lambda_2 > 0
|
||||
self.assertGreater(result.spectral.fiedler_value, 0,
|
||||
"Connected graph should have lambda_2 > 0")
|
||||
|
||||
# CCI should be in a reasonable range for a well-structured codebase
|
||||
self.assertGreater(result.metrics.cci, 0.05)
|
||||
self.assertLess(result.metrics.cci, 0.9)
|
||||
|
||||
# Eigenvalues should be non-negative (Laplacian property)
|
||||
self.assertTrue(np.all(result.spectral.eigenvalues >= -1e-10),
|
||||
"Laplacian eigenvalues should be non-negative")
|
||||
|
||||
# First eigenvalue should be 0
|
||||
self.assertAlmostEqual(result.spectral.eigenvalues[0], 0.0, places=8)
|
||||
|
||||
def test_report_generation(self):
|
||||
"""Verify report contains expected sections."""
|
||||
dot_path = os.path.join(os.path.dirname(__file__), "..", "..", "deps.dot")
|
||||
if not os.path.exists(dot_path):
|
||||
self.skipTest("deps.dot not found")
|
||||
with open(dot_path) as f:
|
||||
graph = parse_dot(f.read())
|
||||
result = run_analysis(graph)
|
||||
report = generate_report(result)
|
||||
|
||||
self.assertIn("GRAPH SUMMARY", report)
|
||||
self.assertIn("LAPLACIAN EIGENVALUE SPECTRUM", report)
|
||||
self.assertIn("FIEDLER VECTOR", report)
|
||||
self.assertIn("MODULE COUPLING MATRIX", report)
|
||||
self.assertIn("CONNECTOME COMPLEXITY INDEX", report)
|
||||
|
||||
def test_json_output(self):
|
||||
"""Verify JSON output is well-formed and contains expected keys."""
|
||||
g = _make_graph(
|
||||
["A", "B", "C"], ["m1", "m1", "m2"],
|
||||
[("A", "B"), ("A", "C")],
|
||||
module_order=["m1", "m2"],
|
||||
)
|
||||
result = run_analysis(g)
|
||||
d = metrics_to_dict(result)
|
||||
|
||||
self.assertIn("graph", d)
|
||||
self.assertIn("spectral", d)
|
||||
self.assertIn("module_coupling", d)
|
||||
self.assertIn("metrics", d)
|
||||
self.assertEqual(d["graph"]["n_nodes"], 3)
|
||||
self.assertIsInstance(d["spectral"]["eigenvalues"], list)
|
||||
self.assertIsInstance(d["metrics"]["cci"], float)
|
||||
|
||||
# Should be JSON-serializable
|
||||
json_str = json.dumps(d)
|
||||
self.assertIsInstance(json_str, str)
|
||||
|
||||
def test_dashboard_generation(self):
|
||||
"""Verify dashboard PNG can be generated without errors."""
|
||||
try:
|
||||
import matplotlib
|
||||
except ImportError:
|
||||
self.skipTest("matplotlib not available")
|
||||
|
||||
g = _make_graph(
|
||||
["A", "B", "C", "D"], ["m1", "m1", "m2", "m2"],
|
||||
[("A", "B"), ("A", "C"), ("C", "D")],
|
||||
module_order=["m1", "m2"],
|
||||
)
|
||||
result = run_analysis(g)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
from spectral_analysis import generate_dashboard
|
||||
generate_dashboard(result, path)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
self.assertGreater(os.path.getsize(path), 1000,
|
||||
"Dashboard should be a non-trivial PNG")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue