From 470561e2b379025c3bb45d69b588dcc1c339ea66 Mon Sep 17 00:00:00 2001 From: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:58:08 -0500 Subject: [PATCH 1/2] fix: make evaluation and checkpoints fail closed --- .github/workflows/ci.yml | 40 + .gitignore | 11 + LICENSE | 21 + README.md | 306 ++--- SECURITY.md | 39 + docs/RESEARCH_SUMMARY.md | 171 ++- experiments/emergence_scaling_analysis.py | 828 +++++++++---- experiments/train_curiosity.py | 4 +- experiments/train_for_validation.py | 160 ++- experiments/train_neuromorphic.py | 6 +- experiments/train_specialization.py | 312 ++++- experiments/train_specialized.py | 189 ++- experiments/validate_hypothesis.py | 447 +++++-- experiments/validate_rigorously.py | 1297 ++++++++------------- pyproject.toml | 20 +- requirements.txt | 9 +- src/__init__.py | 6 +- src/agents/micro_agent.py | 205 +++- src/agents/specializations.py | 3 +- src/environment/cosmos.py | 127 +- src/evolution/distributed.py | 53 +- src/neuromorphic/__init__.py | 4 +- src/neuromorphic/energy.py | 11 +- src/swarm/graph.py | 395 ++++++- src/swarm/metrics.py | 6 +- src/swarm/specialized_graph.py | 241 +++- src/utils/checkpoint.py | 94 ++ src/validation/__init__.py | 10 +- src/validation/baselines.py | 30 +- src/validation/emergence.py | 63 +- src/validation/generalization.py | 5 + src/validation/scaling.py | 14 +- src/validation/synergy.py | 34 +- tests/test_checkpoint_safety.py | 322 +++++ tests/test_environment_safety.py | 70 ++ tests/test_research_diagnostics.py | 37 + 36 files changed, 3858 insertions(+), 1732 deletions(-) create mode 100644 .github/workflows/ci.yml mode change 100755 => 100644 .gitignore create mode 100644 LICENSE mode change 100755 => 100644 README.md create mode 100644 SECURITY.md mode change 100755 => 100644 docs/RESEARCH_SUMMARY.md create mode 100644 src/utils/checkpoint.py create mode 100644 tests/test_checkpoint_safety.py create mode 100644 tests/test_environment_safety.py create mode 100644 tests/test_research_diagnostics.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb315fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +"on": + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Python 3.11 tests + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" + + - name: Install project and test dependencies + run: python -m pip install -e ".[dev]" + + - name: Verify dependency compatibility + run: python -m pip check + + - name: Compile Python sources + run: python -m compileall -q src experiments tests + + - name: Run test suite + run: python -m pytest -q diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index fea18a3..7f41201 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,18 @@ wheels/ *.egg # Virtual environments +.venv/ venv/ ENV/ env/ +# Local configuration and credentials +.env +.env.* +!.env.example +*.pem +*.key + # IDE .idea/ .vscode/ @@ -46,6 +54,9 @@ logs/ checkpoints/ results/models/ results/validation/ +results/validation-local/ +results/behavioral-local/ +results/*.local.json # OS .DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4215f89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Noah Ingwers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 54c34d8..930985a --- a/README.md +++ b/README.md @@ -1,212 +1,144 @@ -# SEESWM: Self-Evolving Embodied Swarm World-Modeler +# SEESWM -*What if intelligence isn't a monolith, but a conversation?* +SEESWM is a research prototype for experimenting with small neural agents that +exchange messages in a simulated grid world. It includes multiple agent +specializations, graph topologies, world-model components, training scripts, +and exploratory evaluation utilities. ---- +> **Status:** proof of concept. This repository does not contain a trained model +> checkpoint, peer-reviewed result, safety evaluation, or evidence of a +> production-ready agent system. The checked-in results are small exploratory +> runs and should not be read as validation of emergent intelligence. -## The Problem with Modern AI +## Research question -Today's AI systems are architectural dictatorships. A single massive network processes everything - vision, language, reasoning, planning - through one homogeneous computational substrate. This works, but it's brittle. When GPT fails, the whole system fails. When a vision model hallucinates, there's no internal voice saying "wait, that doesn't make sense." +Can a graph of specialized agents develop useful coordination or division of +labor that a carefully matched monolithic model does not? -Biological brains evolved differently. Your visual cortex doesn't do language. Your hippocampus doesn't control your muscles. Specialized regions communicate through structured pathways, and somehow, from this cacophony of chatter, coherent thought emerges. Ant colonies solve optimization problems no individual ant could comprehend. Bee swarms make decisions through a democracy of waggles. +The codebase provides infrastructure for investigating that question. The +tracked evidence does not answer it yet: the committed comparison uses +untrained networks and its nominal baseline is not parameter matched. -What if we built AI the same way? +## What is implemented ---- +- perception, reasoning, memory, and planning agent variants; +- message passing over small-world, modular, hierarchical, and other graphs; +- a configurable resource-and-hazard grid environment; +- experimental world-model, metacognition, neuromorphic, and evolutionary + modules; +- ablation, scaling, generalization, statistics, and interpretability helpers; +- training and exploratory evaluation scripts under `experiments/`. -## The Hypothesis +These modules are experimental scaffolding. Their presence does not establish +that every proposed mechanism has been trained, validated, or integrated into a +single end-to-end system. -**Collective intelligence from many small specialized agents will exhibit emergent capabilities that equivalent-parameter monolithic models cannot achieve.** +## Tracked evidence -This is testable. Take 20 small neural networks, each with ~100K parameters. Connect them in a graph. Let them pass messages. Compare against a single 2M parameter network. +Two JSON artifacts are committed so the current claims can be audited directly. -If the hypothesis is wrong, the monolith wins - more concentrated compute, no communication overhead. +### Fresh-network comparison -If the hypothesis is right, something interesting happens. The swarm develops capabilities none of its members possess individually. The whole becomes greater than the sum of its parts. +[`results/hypothesis_validation.json`](results/hypothesis_validation.json) was +recorded on 2025-12-19. Its generator constructed new networks and evaluated +them without a training step. The artifact does not record a source revision or +seed. The current +[`validate_hypothesis.py`](experiments/validate_hypothesis.py) retains the +fresh-network scope but corrects the baseline sizing and role-count invariants, +so it is an extension of that experiment rather than a bit-for-bit reproducer. ---- +| Recorded experiment | Scope | Recorded result | +|---|---:|---| +| Random regression comparison | 10 trials | swarm MSE 0.4486 vs baseline MSE 0.4522; swarm lower in 7/10 trials | +| Internal aggregation-score sweep | 5 trials per size | highest mean score at 20 agents (0.0761) | +| Random topology sweep | 10 trials per topology | modular graph had the highest internal score (0.0705) | +| Grid-world rollouts | 10 episodes | reward 0.509 ± 2.235; survival 94 ± 18 steps; exploration 1.56% ± 0.72% | -## What We Built +The comparison is **not equivalent-parameter**: the artifact records 563,520 +swarm parameters and 44,752 baseline parameters. The MSE difference is small, +and no confidence interval or significance test is recorded for it. -SEESWM is an architecture for testing this hypothesis. It has three core ideas: +### Untrained scaling sweep -**1. Micro-Agents with Specializations** +[`results/emergence/emergence_scaling_results.json`](results/emergence/emergence_scaling_results.json) +was recorded on 2026-01-03 for 4, 10, 20, 50, 100, and 150 agents. The artifact +does not record a checkpoint identifier or invocation arguments, and no model +checkpoint is tracked in this repository. It should therefore be treated as an +untrained/default scaling sweep unless independent provenance is supplied. -Instead of one network that does everything, we have many small networks that do specific things. Perception agents extract features. Reasoning agents draw inferences. Memory agents store and retrieve. Planning agents select actions. Each has architectural biases suited to its role - attention for perception, working memory for reasoning, key-value stores for memory. +The recorded mean reward peaks at 10 agents (1.236) and falls to 0.800 at 100 +agents, while both parameter count and message count increase with swarm size. +The historical artifact's “phase transition” labels came from a local +slope-change heuristic; they are descriptive flags, not statistical evidence +of a physical or learned phase transition. The old plot is retained beside the +artifact for provenance, but is not presented here as a research result. -**2. Message Passing on Graphs** +## What these artifacts do not show -Agents don't share weights or hidden states. They communicate by sending messages through a graph topology. Small-world networks balance local clustering with global shortcuts. Hierarchical structures create information flow from perception to action. The topology itself becomes a design choice that affects what collective behaviors can emerge. +- a trained swarm outperforming a trained, compute-matched baseline; +- 100% transfer or generalization to unseen environments; +- statistically validated emergent specialization; +- robustness, graceful degradation, alignment, or safety properties; +- results outside one simulated grid-world family; +- reproducibility across hardware, dependency versions, or independent teams. -**3. Grounding in Simulated Worlds** +## Reproduce and extend -Abstract benchmarks miss something important about intelligence: it evolved to keep organisms alive. Our agents operate in a grid world with resources to collect, hazards to avoid, and survival pressures that demand coordination. The world model predicts what happens next, and prediction errors drive curiosity - the intrinsic motivation to explore. - ---- - -## Does It Work? - -We built a rigorous validation framework with seven criteria. After training for 1000 epochs: - -| Criterion | Result | -|-----------|--------| -| **Ablations** | Swarm: 1.0 reward vs Single Agent: -0.01 reward (p < 0.001) | -| **Scaling** | Peak at 10 agents (1.24), declines to 0.80 at 100 agents | -| **Synergy** | Consistent high performance indicates effective coordination | -| **Baselines** | Beats all 5 baselines: single agent, ensemble, centralized, independent, random (10/10 wins) | -| **Generalization** | Transfers to unseen environments (100% transfer efficiency) | -| **Emergence** | Specialization Index 3x higher than random (p < 0.001, Cohen's d = 5.22) | -| **Interpretability** | Agent importance varies 5x (top agent: 0.29, median: 0.06) | - -**Score: 7/7 criteria addressed. But this is proof-of-concept, not publication-ready.** - -### How We Measure Emergence - -Claiming "emergence" without rigorous methodology invites skepticism. Here's our approach: - -**1. Specialization Index (SI)** - Between-agent variance / within-agent variance -- High SI means different agents behave differently, but each agent is internally consistent -- Trained swarm: SI = 0.123, Random baseline: SI = 0.041 -- **3x higher specialization than random initialization** - -**2. Null Hypothesis Testing** -- We compare trained swarms against 30 randomly-initialized swarms -- P-value < 0.001: Trained specialization is NOT random variance -- Effect size (Cohen's d) = 5.22: This is a HUGE effect (>0.8 is considered "large") - -**3. Role Clustering** -- Hierarchical clustering on agent action distributions identifies 6 distinct behavioral roles -- Cluster sizes: [4, 2, 5, 4, 2, 3] agents - non-uniform distribution indicates genuine specialization - -**4. Behavioral Diversity (BD)** - Mean pairwise Jensen-Shannon divergence -- BD ranges from 0 (identical) to 1 (maximally different); trained swarm achieves 0.57 -- This indicates substantial differentiation: agents are doing genuinely different things, not noisy copies - -The swarm doesn't just outperform alternatives - we can now explain *why*. The architecture matters: replacing the swarm with a single network of equivalent parameters causes performance to collapse. Agents show distinct behavioral patterns. The collective succeeds where individuals fail. - ---- - -## Why This Matters - -If this works - really works, with measurable synergy and emergent behaviors - it suggests a different path for AI development. Instead of scaling monolithic models to trillions of parameters, we could scale collectives of specialized agents. This has practical advantages: - -**Interpretability**: When reasoning happens through message passing between discrete agents, you can inspect the conversation. Which agent said what? What information flowed where? This is harder with a single network's hidden states. - -**Robustness**: If one agent fails or produces nonsense, others can compensate or override. There's no single point of failure. - -**Modularity**: Add new capabilities by adding new agent types. Remove capabilities by removing agents. The system adapts its structure to the task. - -**Efficiency**: Not every problem needs every capability. A swarm can activate relevant specialists and let others idle. Neuromorphic implementations could be radically more energy-efficient. - -But these advantages only matter if the core hypothesis holds. Does collective intelligence actually emerge? That's what we're trying to find out. - ---- - -## Try It Yourself +Python 3.10 or 3.11 is recommended. ```bash -pip install torch numpy networkx scipy scikit-learn pyyaml tqdm - -# Train a swarm -python experiments/train_for_validation.py --epochs 1000 - -# Run the validation suite -python experiments/validate_rigorously.py --model results/models/swarm_trained_*.pt - -# Run all tests -pytest tests/ -v +python3.11 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e '.[dev,analysis,viz]' + +# Unit tests +python -m pytest + +# Re-run the fresh-network experiments +python experiments/validate_hypothesis.py --device cpu --seed 0 \ + --output results/hypothesis_validation.local.json + +# Quick fresh-random behavioral/scaling smoke test +python experiments/emergence_scaling_analysis.py --device cpu --seed 0 --quick \ + --output results/behavioral-local + +# Train a schema-v2 candidate checkpoint, then evaluate that exact policy +python experiments/train_for_validation.py --device cpu \ + --save results/models/candidate.pt +python experiments/validate_rigorously.py --device cpu --seeds 10 --episodes 10 \ + --max-steps 100 --model results/models/candidate.pt \ + --output results/validation-local ``` -The codebase includes six phases of implementation: foundation, world modeling, specialization, meta-cognition, neuromorphic computing, and evolutionary scaling. Each builds on the last, and each is independently testable. - ---- - -## Scaling Behavior - -We tested swarms from 4 to 150 agents (all untrained, to isolate architectural effects): - -| Agents | Params | Reward | Messages/step | Finding | -|--------|--------|--------|---------------|---------| -| 4 | 417K | 1.09 | 12 | Baseline | -| 10 | 1.04M | **1.24** | 30 | **Peak performance** | -| 20 | 2.09M | 1.04 | 60 | Still efficient | -| 50 | 5.21M | 1.01 | 150 | Slight decline | -| 100 | 10.4M | 0.80 | 300 | Coordination breakdown begins | -| 150 | 15.6M | 0.85 | 450 | High variance | - -**Key Findings:** -1. **Performance peaks at 10-20 agents** for untrained swarms -2. **Phase transitions** detected at 10, 20, 50, and 100 agents -3. **Coordination overhead scales O(n)** - 3 messages per agent per step -4. **Without training, larger swarms struggle** - this underscores the value of learned coordination - -The scaling trend follows: `reward ~ -0.096 * log(agents)` (R² = 0.70) for random initialization. This means **training is essential** - the architectural advantage doesn't come for free. - ---- - -## What Didn't Work - -**Fully-connected topologies failed.** Early experiments with all-to-all messaging caused coordination collapse. With N agents each sending to N-1 others, message volume scaled O(N²) and agents couldn't learn to filter signal from noise. Small-world topology (average degree ~4) was necessary for stable coordination. This suggests the communication bottleneck isn't a bug but a feature: it forces agents to compress and prioritize information. - -**Homogeneous agent types underperformed.** When all agents shared the same architecture (no perception/reasoning/memory/planning split), specialization still emerged but was weaker (SI ~0.05 vs 0.12 with architectural diversity). The inductive biases matter. - -**Random message content hurt more than no messages.** Ablating message passing entirely caused ~10% performance drop. But replacing learned messages with random noise caused ~25% drop. Agents learn to rely on message structure; corrupting it is worse than removing it. - ---- - -## Why Does This Work? (Hypothesis) - -We don't yet have a complete theoretical explanation, but we hypothesize: - -**The message-passing bottleneck acts as an information bottleneck.** Agents can't share raw hidden states; they must compress observations into discrete messages. This forces each agent to learn what information is relevant to transmit, analogous to how biological neural pathways evolved limited bandwidth. The compression may prevent overfitting and encourage learning of transferable abstractions. - -**Specialization emerges from credit assignment.** In a monolithic network, gradients flow uniformly. In a swarm, agents that contribute useful messages receive stronger learning signals (via policy gradient). This creates a natural pressure toward division of labor: agents that are "good at" perceiving get reinforced for perception, creating a feedback loop toward specialization. - -These are hypotheses, not results. Testing them requires measuring mutual information between observations and messages vs. observations and hidden states. We haven't done that yet. - ---- - -## Limitations (What's Missing for Publication) - -**Single environment.** All results come from one 64x64 grid world. We don't know if findings transfer to environments with different coordination demands, continuous action spaces, or partial observability structures. - -**Untrained scaling curves.** The scaling table shows random initialization behavior. We don't know if the 10-agent peak holds after training, or if trained 100-agent swarms learn to coordinate where untrained ones fail. - -**Weak baselines.** The "single agent" baseline is a random-init MLP. Cohen's d = 5.22 against random init is less impressive than it sounds. A fair comparison needs: -- Trained single agent with equivalent compute budget -- Standard MARL baselines (QMIX, MAPPO, COMA) -- Ablations on topology (small-world vs ring vs hierarchical vs random) - -**Unverified theory.** The information bottleneck hypothesis is stated but not tested. "Message passing forces compression" is plausible, but we haven't measured whether it's actually happening. - -**One task structure.** Survival/foraging doesn't require tight coordination. An environment where agents must share learned representations (not just observations) to succeed would be a stronger test. - ---- - -## What's Next - -The hypothesis has initial support. To make it publication-ready: - -1. **Harder environments**: Tasks requiring information sharing to succeed, not just parallel foraging -2. **Trained scaling curves**: Does the 10-agent peak hold after 1000 epochs? Train at 4/10/20/50/100 agents -3. **Real baselines**: QMIX, MAPPO, COMA comparisons with matched compute -4. **Topology ablations**: Small-world vs ring vs hierarchical vs random graphs -5. **Information bottleneck measurement**: Actually compute MI(observations, messages) vs MI(observations, hidden states) -6. **Trained single-agent comparison**: Give a 2M parameter MLP the same training budget - -The core question: Does swarm coordination provide advantages that can't be replicated by a well-trained monolith? - ---- - -## References - -1. LeCun, Y. (2022). A Path Towards Autonomous Machine Intelligence. *Meta AI*. -2. Stanley, K. O., & Miikkulainen, R. (2002). Evolving Neural Networks through Augmenting Topologies. *Evolutionary Computation*. -3. Williams, P. L., & Beer, R. D. (2010). Nonnegative Decomposition of Multivariate Information. *arXiv*. -4. Tishby, N., & Zaslavsky, N. (2015). Deep Learning and the Information Bottleneck Principle. *IEEE Information Theory Workshop*. -5. Lowe, R., Wu, Y., Tamar, A., Harb, J., Abbeel, P., & Mordatch, I. (2017). Multi-Agent Actor-Critic for Mixed Cooperative-Competitive Environments. *NeurIPS*. -6. Foerster, J., Assael, I. A., de Freitas, N., & Whiteson, S. (2016). Learning to Communicate with Deep Multi-Agent Reinforcement Learning. *NeurIPS*. - ---- - -MIT License +Write new outputs to a separate path so the committed evidence remains intact. +The candidate writers `train_for_validation.py`, `train_specialized.py`, and +`train_specialization.py` write restricted-loader-compatible schema-v2 +checkpoints containing the swarm configuration, policy head, environment, and +native-type metrics. Other historical training scripts have not been migrated +and their outputs are not accepted by the schema-v2 evaluator. The evaluator +requires PyTorch 2.10 or newer, uses the restricted loader, bounds checkpoint +size, and rejects older schemas or any configuration, topology, agent, edge, or +policy mismatch instead of falling back to random weights. Its diagnostic +report records the checkpoint SHA-256. These controls do not make arbitrary +third-party `.pt` files safe; use only a checkpoint with trusted provenance and +a verified digest. See [`SECURITY.md`](SECURITY.md). The diagnostic is still not +a substitute for trained controls or independent validation. + +## Minimum credible next experiment + +1. Train the swarm and a parameter- and compute-matched monolithic baseline. +2. Pre-register primary metrics, seeds, stopping rules, and exclusion criteria. +3. Evaluate on tasks that require information sharing, plus held-out world + layouts and at least one established multi-agent benchmark. +4. Report per-seed results, confidence intervals, effect sizes, and failures. +5. Publish checkpoints, raw trajectories, environment versions, and an exact + dependency lock. + +Until then, SEESWM is best understood as an experimental framework and source +of testable hypotheses, not evidence that collective intelligence has emerged. + +## License + +[MIT](LICENSE). This project is not affiliated with or endorsed by any cited +researcher, institution, benchmark, or model provider. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..66dcc3b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security policy + +## Reporting a vulnerability + +Please use [GitHub's private vulnerability-reporting flow](https://github.com/noah-ing/SEESWM/security/advisories/new). +If that flow is unavailable, open a public issue that asks only for a private +contact channel. Do not include exploit details, credentials, or private data +in a public issue. + +This proof of concept is not a production security boundary. Reports affecting +the current `main` branch are in scope; older snapshots and third-party forks +are not maintained. + +## Checkpoint boundary + +PyTorch checkpoint files are executable-format-adjacent inputs and should not +be treated as trustworthy merely because they use a `.pt` or `.pth` extension. +The schema-v2 evaluator: + +- requires PyTorch 2.10 or newer; +- uses the restricted `weights_only` loader; +- rejects files larger than 512 MiB; +- accepts only known configuration, topology, policy, and state structures; +- records the evaluated file's SHA-256 digest. + +These controls reduce risk; they do not make arbitrary third-party checkpoints +safe or prevent every denial-of-service condition. Evaluate only checkpoints +you created or whose digest and provenance you independently verified. Never +fall back to `weights_only=False` for an untrusted file. + +The minimum version follows PyTorch's +[restricted-loader security advisory](https://github.com/pytorch/pytorch/security/advisories/GHSA-63cw-57p8-fm3p). +PyTorch's [serialization guidance](https://docs.pytorch.org/docs/main/notes/serialization.html#torch-load-with-weights-only) +also documents the restricted loader's remaining denial-of-service and memory +safety limitations. + +No API key, cloud credential, or external service is required to run the test +suite or the local grid-world experiments. Keep local values in ignored `.env` +files and never commit them. diff --git a/docs/RESEARCH_SUMMARY.md b/docs/RESEARCH_SUMMARY.md old mode 100755 new mode 100644 index 1f3ec4c..8087c96 --- a/docs/RESEARCH_SUMMARY.md +++ b/docs/RESEARCH_SUMMARY.md @@ -1,105 +1,66 @@ -# SEESWM: Collective Intelligence from Specialized Agent Swarms - -## The Hypothesis - -**Collective intelligence from many small specialized agents will exhibit emergent capabilities that equivalent-parameter monolithic models cannot achieve.** - ---- - -## Method - -We implement a swarm of 20 micro-agents (each ~100K parameters, 2M total) connected via a small-world graph topology. Each agent has architectural biases suited to its role: attention mechanisms for perception agents, transformer-style blocks for reasoning agents, key-value stores for memory agents, and goal-conditioning for planning agents. Agents communicate through 3 rounds of message passing per timestep, with no shared weights or direct access to each other's hidden states. - -The swarm is trained via policy gradient (REINFORCE with baseline) on a survival task in a 64x64 grid world containing resources (energy, food, water, materials), hazards, and respawning dynamics. The intrinsic motivation comes from a JEPA-style world model that provides curiosity bonuses for unpredicted state transitions. - -We compare against five baselines: (1) single agent with equivalent parameters, (2) ensemble of independent agents, (3) centralized controller, (4) swarm without message passing, and (5) random policy. We measure emergence through three metrics: - -- **Specialization Index (SI)**: Ratio of between-agent to within-agent behavioral variance -- **Behavioral Diversity (BD)**: Mean pairwise Jensen-Shannon divergence between agent action distributions -- **Role Clustering**: Hierarchical clustering to identify distinct behavioral roles - -Crucially, we test against a **null hypothesis**: we compare trained swarm specialization against 30 randomly-initialized swarms to establish statistical significance. - ---- - -## Key Result - -**The trained swarm shows statistically significant emergent specialization.** - -| Metric | Trained Swarm | Random Baseline | Statistical Test | -|--------|---------------|-----------------|------------------| -| Specialization Index | 0.123 | 0.041 | p < 0.001, Cohen's d = 5.22 | -| Behavioral Diversity | 0.572 | ~0.45 | Higher diversity learned | -| Distinct Roles | 6 clusters | — | Hierarchical clustering | -| Swarm vs Single Agent | **1.0 reward** | **-0.01 reward** | 100x performance gap | -| vs. All Baselines | 10/10 wins | — | Head-to-head comparison | -| Agent Importance Range | 0.29 (top) to 0.06 (median) | — | 5x variance in contribution | - -![Scaling analysis showing phase transitions](../results/emergence/scaling_analysis.png) - -**Scaling experiments reveal phase transitions at 10, 20, 50, and 100 agents.** Untrained swarms peak at 10 agents, with performance declining logarithmically beyond that point (R² = 0.70). This underscores that the architectural advantage requires learned coordination. - -**What didn't work:** Fully-connected topologies caused coordination collapse (O(N²) message volume overwhelmed agents). Small-world structure with average degree ~4 was necessary. Random message noise hurt more than no messages at all (-25% vs -10%), suggesting agents learn to rely on message structure. - ---- - -## Why Does This Work? (Hypothesis) - -We hypothesize two mechanisms: - -1. **Information bottleneck**: Agents can't share raw hidden states; they must compress observations into messages. This forces learning of relevant features and may prevent overfitting, similar to how biological neural pathways evolved limited bandwidth. - -2. **Credit assignment drives specialization**: In a swarm, agents that contribute useful messages receive stronger policy gradient signals. This creates feedback loops: agents "good at" perception get reinforced for perception, naturally producing division of labor. - -These are hypotheses, not verified mechanisms. Testing requires measuring MI(observations, messages) vs MI(observations, hidden states). - ---- - -## Implications for AI Safety and Alignment - -The swarm architecture offers several properties relevant to AI safety: - -1. **Interpretability through Modularity**: When reasoning happens via explicit message passing between discrete agents, the "conversation" is inspectable. We can trace which agent contributed what information and how it influenced the final decision. This is fundamentally more transparent than probing hidden states in a monolithic network. - -2. **Graceful Degradation**: In ablation studies, removing individual agents or communication pathways causes proportional—not catastrophic—performance drops. There's no single point of failure. This contrasts with brittle learned features in large models that can cause complete failure when perturbed. - -3. **Emergent Checks and Balances**: The division of labor creates implicit verification—a perception agent's claim must be coherent enough for reasoning agents to act on. Bad information gets filtered through multiple specialized perspectives before affecting output. - -4. **Scalable Oversight**: Rather than monitoring one opaque decision-making process, we can monitor the communication graph. Anomalous messaging patterns (e.g., an agent that suddenly dominates or goes silent) could serve as early warning indicators. - -However, emergence also creates alignment challenges. Specialization develops without explicit supervision—agents find their roles through training dynamics, not design. Understanding *why* particular role assignments emerged, and whether they're robust to distributional shift, remains an open problem. - ---- - -## Limitations - -- **Single environment**: All results from one grid world. Unknown if findings transfer. -- **Weak baselines**: Cohen's d = 5.22 is against random init, not trained alternatives. -- **Untrained scaling**: The scaling table shows architectural bias, not learned behavior. -- **Unverified theory**: Information bottleneck hypothesis is stated, not tested. - ---- - -## Next Experiments - -To make this publication-ready: - -1. **Harder environments**: Tasks where information sharing is required, not just helpful -2. **Trained scaling curves**: Train at 4/10/20/50/100 agents, compare peaks -3. **Real baselines**: QMIX, MAPPO, COMA with matched compute budget -4. **Trained single-agent**: 2M parameter MLP with same training budget -5. **Information bottleneck measurement**: Compute MI(observations, messages) empirically -6. **Topology ablations**: Small-world vs ring vs hierarchical vs random - -The core question: Does swarm coordination provide advantages a well-trained monolith cannot replicate? - ---- - -## Contact - -This is a proof-of-concept exploring whether collective intelligence can be architected, not just hoped for. The hypothesis is validated; the question now is how far it scales. - ---- - -*Implementation: ~10K lines of PyTorch, 6 development phases, 104 tests, full validation suite.* -*Results reproducible via: `python experiments/emergence_scaling_analysis.py`* +# SEESWM evidence note + +This note describes only the evidence committed to this repository. It replaces +an earlier draft that characterized untracked trained-model results as validated +findings. + +## Available artifacts + +### `results/hypothesis_validation.json` + +- Timestamp: 2025-12-19. +- The generating script creates fresh networks and contains no training step. +- The artifact records neither a source revision nor a seed. The current script + corrects its baseline sizing and agent-role invariants, so exact reproduction + of the historical values is not claimed. +- In the ten random-regression trials, swarm MSE was 0.4486 ± 0.0117 and + baseline MSE was 0.4522 ± 0.0124; the swarm was lower in seven trials. +- The recorded parameter counts are 563,520 for the swarm and 44,752 for the + baseline, so this is not an equivalent-parameter comparison. +- The remaining experiments are small untrained sweeps over agent count, + topology, and ten grid-world episodes. + +### `results/emergence/emergence_scaling_results.json` + +- Timestamp: 2026-01-03. +- Covers 4, 10, 20, 50, 100, and 150 fresh swarms. +- The artifact does not identify a checkpoint or invocation, and no checkpoint + is tracked in the repository. +- Mean reward peaks at 1.236 for 10 agents in this artifact and is 0.800 for 100 + agents. This is a descriptive untrained scaling observation. +- Historical “phase transition” entries are outputs of a slope-change + heuristic, not hypothesis-test results. The current script calls them + `slope_change_flags`. + +## Checkpoint and evaluator status + +The candidate writers `train_for_validation.py`, `train_specialized.py`, and +`train_specialization.py` use a versioned primitive/tensor-only checkpoint +schema; older training scripts remain legacy-only. The evaluators require +PyTorch 2.10 or newer, use its restricted loader, cap accepted file size, +reconstruct the saved topology and policy head, strict-load every component +used by the evaluated policy, and reject mismatches. Value-head and world-model +state may be retained for provenance but are not evaluated by the policy +diagnostic. Legacy schemas are rejected. A `.pt` file still requires trusted +provenance and a verified digest; these controls are not a sandbox. The report +records the checkpoint SHA-256 and does not certify emergence, generalization, +or baseline superiority. + +## Unsupported conclusions + +The committed artifacts do not support claims of trained emergent +specialization, a 100-fold performance advantage, 100% transfer efficiency, +robustness, scalable oversight, or superiority to trained standard baselines. +They also do not establish that agent messages are causally useful. + +## Required evidence for stronger claims + +- versioned trained checkpoints and full training configurations; +- matched parameter, compute, data, and optimization budgets; +- established MARL baselines and tasks requiring coordination; +- multiple seeds with uncertainty estimates and predefined tests; +- raw trajectories, evaluation logs, environment versions, and dependency lock; +- explicit message-passing and specialization ablations. + +See the repository README for reproduction commands and the current project +scope. diff --git a/experiments/emergence_scaling_analysis.py b/experiments/emergence_scaling_analysis.py index e049dfc..98113f7 100755 --- a/experiments/emergence_scaling_analysis.py +++ b/experiments/emergence_scaling_analysis.py @@ -1,57 +1,124 @@ #!/usr/bin/env python3 """ -Rigorous Emergence Detection & Scaling Analysis for SEESWM. +Exploratory behavioral-pattern and scaling analysis for SEESWM. -This script provides interviewer-proof methodology for: -1. Measuring and defining "division of labor" with clear metrics -2. Distinguishing genuine specialization from random behavioral variance +This script provides utilities for: +1. Measuring candidate "division of labor" with explicit metrics +2. Comparing candidate specialization with random behavioral variance 3. Visualizing agent roles over time -4. Running scaling experiments with phase transition detection +4. Running scaling experiments with heuristic slope-change flags -Key Innovation: We compare against NULL HYPOTHESIS (random/untrained baseline) -to prove specialization is learned, not random. +When a trained checkpoint is supplied, the candidate model can be compared with +randomly initialized swarms. Without `--model`, all results describe random +initialization and cannot demonstrate learned specialization. Metrics Defined: - Specialization Index (SI): Between-agent variance / within-agent variance - Role Consistency (RC): 1 - mean(within-agent action entropy) - Behavioral Diversity (BD): Mean pairwise Jensen-Shannon divergence between agent action distributions -- Communication Efficiency (CE): Performance gain / messages sent Usage: - python experiments/emergence_scaling_analysis.py --device cpu --output results/emergence + python experiments/emergence_scaling_analysis.py --device cpu --output results/behavioral-local """ import argparse import json import logging -import os +import random +import subprocess import sys -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path -from typing import Dict, List, Tuple, Optional, Any -from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple from dataclasses import dataclass, field import numpy as np import torch import torch.nn as nn from scipy import stats -from scipy.spatial.distance import jensenshannon, pdist, squareform -from scipy.cluster.hierarchy import linkage, fcluster, dendrogram -from scipy.stats import entropy, permutation_test +from scipy.cluster.hierarchy import fcluster, linkage +from scipy.spatial.distance import jensenshannon +from scipy.stats import entropy # Add project root to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from src.swarm.graph import SwarmGraph, SwarmConfig, TopologyType -from src.environment.cosmos import CosmosEnvironment +from src.swarm.graph import ( + SwarmGraph, + SwarmConfig, + TopologyType, + swarm_config_from_dict, + swarm_config_to_dict, +) +from src.environment.cosmos import EnvironmentConfig +from experiments.validate_rigorously import ( + build_candidate, + build_environment, + environment_config_to_dict, + load_checkpoint_with_digest, + set_swarm_eval, +) logging.basicConfig( level=logging.INFO, format='%(asctime)s | %(levelname)s | %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) -logger = logging.getLogger('emergence_analysis') +logger = logging.getLogger('behavioral_analysis') + + +def _analysis_source_provenance() -> Dict[str, Any]: + """Return the current revision and dirty flag without requiring Git.""" + repository = Path(__file__).resolve().parent.parent + try: + revision = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + cwd=repository, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + status = subprocess.run( + ['git', 'status', '--porcelain', '--untracked-files=normal'], + cwd=repository, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return {'revision': None, 'dirty': None} + return { + 'revision': revision.stdout.strip() if revision.returncode == 0 else None, + 'dirty': bool(status.stdout.strip()) if status.returncode == 0 else None, + } + + +def _random_control_seeds(candidate_seed: int, count: int) -> List[int]: + """Choose declared nonnegative control seeds excluding the candidate seed.""" + seeds: List[int] = [] + value = 0 + while len(seeds) < count: + if value != candidate_seed: + seeds.append(value) + value += 1 + return seeds + + +def _seed_everything(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + + +def _balanced_role_counts(num_agents: int) -> Tuple[int, int, int, int]: + """Assign every agent to exactly one of the four declared roles.""" + if num_agents < 1: + raise ValueError("num_agents must be positive") + base, remainder = divmod(num_agents, 4) + counts = [base + int(index < remainder) for index in range(4)] + return counts[0], counts[1], counts[2], counts[3] # ============================================================================= @@ -66,7 +133,6 @@ class AgentBehaviorProfile: action_distribution: np.ndarray # Normalized action counts output_mean: float output_std: float - message_frequency: float activation_patterns: List[np.ndarray] = field(default_factory=list) @@ -78,10 +144,12 @@ class SpecializationMetrics: role_consistency: float # RC: 1 - mean(within-agent entropy) behavioral_diversity: float # BD: mean pairwise JS divergence - # Statistical validation - null_hypothesis_si: float # SI for random baseline - p_value: float # Significance vs null - effect_size: float # Cohen's d + # Fresh-random comparison (descriptive; not independent validation) + random_control_si: Optional[float] + monte_carlo_p_value: Optional[float] + standardized_null_difference: Optional[float] + null_sample_count: int + random_comparison_performed: bool # Cluster analysis num_distinct_roles: int # Number of behavioral clusters @@ -102,52 +170,98 @@ class ScalingDataPoint: mean_reward: float std_reward: float - # Emergence metrics + # Behavioral-pattern metrics specialization_index: float behavioral_diversity: float - # Efficiency metrics - messages_per_step: float + # Architectural work proxy + agent_forward_calls_per_step: int compute_time_ms: float - # Synergy - synergy_score: float - @dataclass -class PhaseTransition: - """Detected phase transition.""" +class SlopeChangeFlag: + """Heuristic local slope-change flag; not evidence of a phase transition.""" agent_count: int metric_name: str before_slope: float after_slope: float magnitude: float - confidence: float + relative_change_score: float # ============================================================================= -# EMERGENCE DETECTION +# BEHAVIORAL-PATTERN ANALYSIS # ============================================================================= class EmergenceAnalyzer: """ - Rigorous emergence detection with null hypothesis testing. + Exploratory behavioral-pattern analysis with a random-init comparison. - Key methodology: - 1. Collect behavioral data from trained swarm - 2. Collect behavioral data from RANDOM (untrained) swarm - 3. Compare specialization metrics using permutation tests + Methodology: + 1. Collect behavioral data from a candidate swarm + 2. Collect behavioral data from randomly initialized controls + 3. Compare descriptive specialization metrics 4. Cluster agents into distinct roles - 5. Visualize role evolution over training + + These diagnostics do not establish emergence. A trained candidate must use + the exact policy head restored from its checkpoint; representation vectors + are never treated as environment actions directly. """ def __init__( self, device: str = "cpu", num_actions: int = 5, + policy_head: Optional[nn.Module] = None, + policy_head_spec: Optional[Dict[str, Any]] = None, + candidate_seed: int = 0, ): self.device = device self.num_actions = num_actions + self.policy_head = policy_head + self.policy_head_spec = policy_head_spec + self.candidate_seed = candidate_seed + + def _policy_logits(self, representation: torch.Tensor) -> torch.Tensor: + """Map a swarm representation to exactly ``num_actions`` logits.""" + logits = ( + self.policy_head(representation) + if self.policy_head is not None + else representation + ) + if logits.ndim != 2 or logits.shape[0] != 1: + raise ValueError(f"invalid policy-logit shape: {tuple(logits.shape)}") + if logits.shape[-1] != self.num_actions: + raise ValueError( + "a policy head is required when swarm output_dim differs from " + f"the {self.num_actions}-action environment" + ) + return logits + + def _fresh_policy_head(self) -> Optional[nn.Module]: + """Create a randomly initialized control head matching the candidate.""" + if self.policy_head_spec is None: + return None + policy_type = self.policy_head_spec.get("type") + input_dim = int(self.policy_head_spec["input_dim"]) + hidden_dim = int(self.policy_head_spec["hidden_dim"]) + num_actions = int(self.policy_head_spec["num_actions"]) + if num_actions != self.num_actions: + raise ValueError("control policy action count does not match the analyzer") + if policy_type == "mlp_relu": + policy = nn.Sequential( + nn.Linear(input_dim, hidden_dim), + nn.ReLU(), + nn.Linear(hidden_dim, num_actions), + ) + elif policy_type == "policy_head_tanh": + from src.training import PolicyHead + + policy = PolicyHead(input_dim, num_actions, hidden_dim=hidden_dim) + else: + raise ValueError(f"unsupported policy head type: {policy_type!r}") + return policy.to(self.device).eval() def collect_behavioral_data( self, @@ -155,18 +269,22 @@ def collect_behavioral_data( env_config: Dict, num_episodes: int = 50, steps_per_episode: int = 100, + evaluation_seed: Optional[int] = None, ) -> Dict[int, AgentBehaviorProfile]: """ Collect comprehensive behavioral data from swarm. Returns dict mapping agent_id -> AgentBehaviorProfile """ - env = CosmosEnvironment(**env_config) + if num_episodes < 1 or steps_per_episode < 1: + raise ValueError("behavioral sampling counts must be positive") + if evaluation_seed is not None: + _seed_everything(evaluation_seed) + env = build_environment(env_config) # Per-agent tracking agent_actions = {i: [] for i in swarm.agents.keys()} agent_outputs = {i: [] for i in swarm.agents.keys()} - agent_messages = {i: 0 for i in swarm.agents.keys()} for ep in range(num_episodes): obs = env.reset() @@ -176,20 +294,21 @@ def collect_behavioral_data( obs_tensor = obs[0].to_tensor(self.device).unsqueeze(0) with torch.no_grad(): - action_logits = swarm.step(obs_tensor) + representation = swarm.step(obs_tensor) + action_logits = self._policy_logits(representation) action = action_logits.argmax(dim=-1).item() # Track per-agent behaviors for agent_id, agent in swarm.agents.items(): if hasattr(agent, 'last_output') and agent.last_output is not None: - out = agent.last_output.cpu().numpy().flatten() + agent_output = agent.last_output + out = agent_output.cpu().numpy().flatten() agent_outputs[agent_id].append(out) - # Derive action preference from output - if len(out) >= self.num_actions: - preferred_action = np.argmax(out[:self.num_actions]) - else: - preferred_action = int(out.mean() > 0) + # Map each agent representation through the same + # candidate policy head before deriving a preference. + agent_logits = self._policy_logits(agent_output) + preferred_action = int(agent_logits.argmax(dim=-1).item()) agent_actions[agent_id].append(preferred_action) obs, rewards, dones = env.step([action]) @@ -226,7 +345,6 @@ def collect_behavioral_data( action_distribution=action_dist, output_mean=output_mean, output_std=output_std, - message_frequency=agent_messages.get(agent_id, 0) / (num_episodes * steps_per_episode), activation_patterns=outputs[:100] if outputs else [], # Keep subset ) @@ -253,8 +371,8 @@ def compute_specialization_index( # Between-agent variance: variance of agent means between_var = np.var(agent_means) - # Within-agent variance: mean of individual agent stds - within_var = np.mean([p.output_std for p in profiles.values()]) + # Within-agent variance: mean of squared per-agent standard deviations. + within_var = np.mean([p.output_std**2 for p in profiles.values()]) # SI = between / within (higher = more specialized) si = between_var / (within_var + 1e-8) @@ -294,7 +412,7 @@ def compute_behavioral_diversity( BD = Mean pairwise Jensen-Shannon divergence between agent action distributions. - High BD means agents have genuinely different behavioral strategies. + High BD means the observed action distributions differ more. """ if len(profiles) < 2: return 0.0 @@ -359,53 +477,69 @@ def cluster_agents_into_roles( n_clusters = best_k labels = fcluster(Z, n_clusters, criterion='maxclust') + # ``maxclust`` is an upper bound and can return fewer clusters. Report + # only labels that actually occurred, normalized to 1..k. + unique_labels = sorted(int(label) for label in np.unique(labels)) + normalized = { + label: index + 1 for index, label in enumerate(unique_labels) + } + normalized_labels = [normalized[int(label)] for label in labels] - return list(labels), n_clusters + return normalized_labels, len(unique_labels) - def run_null_hypothesis_test( + def run_random_init_comparison( self, trained_profiles: Dict[int, AgentBehaviorProfile], swarm_config: Dict, env_config: Dict, num_permutations: int = 100, - ) -> Tuple[float, float, float]: + num_episodes: int = 50, + steps_per_episode: int = 100, + ) -> Tuple[float, float, Optional[float], int]: """ - Test specialization against null hypothesis (random baseline). + Compare the candidate SI with fresh random-initialization controls. - Null hypothesis: Observed specialization is no different from random initialization. + This is an exploratory Monte Carlo comparison, not an independently + validated hypothesis test. - Returns (null_si, p_value, effect_size) + Returns (null_si, plus-one Monte Carlo p, standardized difference, n). """ trained_si = self.compute_specialization_index(trained_profiles) # Generate null distribution by testing random (untrained) swarms null_sis = [] - for seed in range(num_permutations): - torch.manual_seed(seed) - np.random.seed(seed) - - # Create random swarm (not trained) - num_agents = swarm_config.get('num_agents', 20) - config = SwarmConfig( - num_agents=num_agents, - input_dim=swarm_config.get('input_dim', 137), - hidden_dim=swarm_config.get('hidden_dim', 128), - output_dim=swarm_config.get('output_dim', 5), - message_dim=swarm_config.get('hidden_dim', 128), - topology=TopologyType.SMALL_WORLD, - num_perception=num_agents // 4, - num_reasoning=num_agents // 4, - num_memory=num_agents // 4, - num_planning=num_agents - 3 * (num_agents // 4), + for seed in _random_control_seeds(self.candidate_seed, num_permutations): + _seed_everything(seed) + + # Match the candidate architecture and topology while using fresh, + # untrained parameters for both the swarm and policy head. + if self.policy_head_spec and self.policy_head_spec.get("type") == "policy_head_tanh": + from src.swarm.specialized_graph import ( + SpecializedSwarmGraph, + specialized_swarm_config_from_dict, + ) + + config = specialized_swarm_config_from_dict(swarm_config) + random_swarm = SpecializedSwarmGraph(config, device=self.device) + else: + config = swarm_config_from_dict(swarm_config) + random_swarm = SwarmGraph(config, device=self.device) + set_swarm_eval(random_swarm) + random_analyzer = EmergenceAnalyzer( + device=self.device, + num_actions=self.num_actions, + policy_head=self._fresh_policy_head(), + policy_head_spec=self.policy_head_spec, + candidate_seed=seed, ) - random_swarm = SwarmGraph(config, device=self.device) # Collect behavioral data from random swarm - random_profiles = self.collect_behavioral_data( + random_profiles = random_analyzer.collect_behavioral_data( random_swarm, env_config, - num_episodes=10, # Fewer for null distribution - steps_per_episode=50, + num_episodes=num_episodes, + steps_per_episode=steps_per_episode, + evaluation_seed=self.candidate_seed, ) null_si = self.compute_specialization_index(random_profiles) @@ -415,13 +549,25 @@ def run_null_hypothesis_test( null_mean = null_sis.mean() null_std = null_sis.std() - # P-value: proportion of null samples >= trained - p_value = (null_sis >= trained_si).mean() - - # Effect size: Cohen's d - effect_size = (trained_si - null_mean) / (null_std + 1e-8) + # Plus-one correction prevents an impossible zero p-value and gives the + # finite Monte Carlo resolution explicitly. + exceedances = int(np.sum(null_sis >= trained_si)) + p_value = (exceedances + 1) / (len(null_sis) + 1) + + # This is not Cohen's d: there is one candidate aggregate and a random + # control distribution, so report only a standardized null difference. + standardized_difference = ( + None + if null_std <= np.finfo(float).eps + else float((trained_si - null_mean) / null_std) + ) - return float(null_mean), float(p_value), float(effect_size) + return ( + float(null_mean), + float(p_value), + standardized_difference, + int(len(null_sis)), + ) def analyze_full( self, @@ -429,12 +575,19 @@ def analyze_full( swarm_config: Dict, env_config: Dict, num_episodes: int = 50, + steps_per_episode: int = 100, run_null_test: bool = True, ) -> SpecializationMetrics: """Run complete specialization analysis.""" - logger.info("Collecting behavioral data from trained swarm...") - profiles = self.collect_behavioral_data(swarm, env_config, num_episodes) + logger.info("Collecting behavioral data from candidate swarm...") + profiles = self.collect_behavioral_data( + swarm, + env_config, + num_episodes=num_episodes, + steps_per_episode=steps_per_episode, + evaluation_seed=self.candidate_seed, + ) logger.info("Computing specialization metrics...") si = self.compute_specialization_index(profiles) @@ -446,30 +599,57 @@ def analyze_full( logger.info(f" Behavioral Diversity (BD): {bd:.4f}") # Null hypothesis test - null_si, p_value, effect_size = 0.0, 1.0, 0.0 + random_control_si = None + monte_carlo_p_value = None + standardized_null_difference = None + null_sample_count = 0 + random_comparison_performed = False if run_null_test: - logger.info("Running null hypothesis test (this may take a while)...") - null_si, p_value, effect_size = self.run_null_hypothesis_test( - profiles, swarm_config, env_config, num_permutations=30 + logger.info("Running fresh-random comparison (this may take a while)...") + ( + random_control_si, + monte_carlo_p_value, + standardized_null_difference, + null_sample_count, + ) = self.run_random_init_comparison( + profiles, + swarm_config, + env_config, + num_permutations=30, + num_episodes=num_episodes, + steps_per_episode=steps_per_episode, ) + random_comparison_performed = True - sig = "***" if p_value < 0.001 else "**" if p_value < 0.01 else "*" if p_value < 0.05 else "" - logger.info(f" Null SI: {null_si:.4f}, p-value: {p_value:.4f}{sig}") - logger.info(f" Effect size (Cohen's d): {effect_size:.4f}") + logger.info( + " Random-control SI: %.4f, plus-one Monte Carlo p=%.4f (n=%d)", + random_control_si, + monte_carlo_p_value, + null_sample_count, + ) + if standardized_null_difference is None: + logger.info(" Standardized null difference: undefined (zero null SD)") + else: + logger.info( + " Standardized null difference: %.4f", + standardized_null_difference, + ) # Cluster analysis - logger.info("Clustering agents into roles...") + logger.info("Clustering observed action distributions...") cluster_labels, num_roles = self.cluster_agents_into_roles(profiles) cluster_sizes = [cluster_labels.count(i+1) for i in range(num_roles)] - logger.info(f" Detected {num_roles} distinct roles: {cluster_sizes}") + logger.info(f" Returned {num_roles} behavioral cluster(s): {cluster_sizes}") return SpecializationMetrics( specialization_index=si, role_consistency=rc, behavioral_diversity=bd, - null_hypothesis_si=null_si, - p_value=p_value, - effect_size=effect_size, + random_control_si=random_control_si, + monte_carlo_p_value=monte_carlo_p_value, + standardized_null_difference=standardized_null_difference, + null_sample_count=null_sample_count, + random_comparison_performed=random_comparison_performed, num_distinct_roles=num_roles, cluster_labels=cluster_labels, cluster_sizes=cluster_sizes, @@ -483,13 +663,13 @@ def analyze_full( class ScalingAnalyzer: """ - Scaling analysis with phase transition detection. + Random-initialization scaling analysis with heuristic slope-change flags. Measures: 1. Performance vs agent count 2. Specialization vs agent count - 3. Communication overhead vs agent count - 4. Phase transitions where behavior qualitatively changes + 3. Agent-forward-call work proxy vs agent count + 4. Local slope changes for follow-up analysis """ def __init__( @@ -508,9 +688,13 @@ def evaluate_swarm( swarm: SwarmGraph, env_config: Dict, num_episodes: int = 20, + evaluation_seed: int = 0, ) -> Dict[str, float]: - """Evaluate swarm performance.""" - env = CosmosEnvironment(**env_config) + """Evaluate a fresh policy under a declared environment seed.""" + if num_episodes < 1: + raise ValueError("num_episodes must be positive") + _seed_everything(evaluation_seed) + env = build_environment(env_config) rewards = [] steps_list = [] @@ -526,6 +710,14 @@ def evaluate_swarm( with torch.no_grad(): action_logits = swarm.step(obs_tensor) + if ( + action_logits.ndim != 2 + or action_logits.shape != (1, self.base_output_dim) + ): + raise ValueError( + "scaling policy returned an invalid action-logit shape: " + f"{tuple(action_logits.shape)}" + ) action = action_logits.argmax(dim=-1).item() obs, rew, dones = env.step([action]) @@ -553,6 +745,11 @@ def run_scaling_sweep( ) -> List[ScalingDataPoint]: """Run scaling sweep across different agent counts.""" + if num_seeds < 1 or num_eval_episodes < 1: + raise ValueError("num_seeds and num_eval_episodes must be positive") + if not agent_counts or any(count < 1 for count in agent_counts): + raise ValueError("agent_counts must contain positive values") + data_points = [] for n_agents in agent_counts: @@ -563,12 +760,13 @@ def run_scaling_sweep( seed_rewards = [] seed_sis = [] seed_bds = [] + seed_compute_times = [] for seed in range(num_seeds): - torch.manual_seed(seed) - np.random.seed(seed) + _seed_everything(seed) # Create swarm + role_counts = _balanced_role_counts(n_agents) config = SwarmConfig( num_agents=n_agents, input_dim=self.base_input_dim, @@ -576,24 +774,35 @@ def run_scaling_sweep( output_dim=self.base_output_dim, message_dim=hidden_dim, topology=TopologyType.SMALL_WORLD, - num_perception=max(1, n_agents // 4), - num_reasoning=max(1, n_agents // 4), - num_memory=max(1, n_agents // 4), - num_planning=max(1, n_agents - 3 * max(1, n_agents // 4)), + num_perception=role_counts[0], + num_reasoning=role_counts[1], + num_memory=role_counts[2], + num_planning=role_counts[3], ) swarm = SwarmGraph(config, device=self.device) + set_swarm_eval(swarm) # Evaluate performance import time start_time = time.time() - perf = self.evaluate_swarm(swarm, env_config, num_eval_episodes) + perf = self.evaluate_swarm( + swarm, + env_config, + num_eval_episodes, + evaluation_seed=seed, + ) compute_time = (time.time() - start_time) * 1000 / num_eval_episodes + seed_compute_times.append(compute_time) seed_rewards.append(perf['mean_reward']) - # Quick emergence metrics + # Quick descriptive behavioral metrics profiles = self.emergence_analyzer.collect_behavioral_data( - swarm, env_config, num_episodes=10, steps_per_episode=50 + swarm, + env_config, + num_episodes=10, + steps_per_episode=50, + evaluation_seed=seed, ) si = self.emergence_analyzer.compute_specialization_index(profiles) bd = self.emergence_analyzer.compute_behavioral_diversity(profiles) @@ -605,7 +814,10 @@ def run_scaling_sweep( # Aggregate across seeds total_params = swarm.total_parameters - messages_per_step = n_agents * 3 # 3 message rounds, each agent sends 1 + agent_forward_calls_per_step = ( + len(swarm.input_agents) + + n_agents * swarm.config.message_passing_rounds + ) data_point = ScalingDataPoint( num_agents=n_agents, @@ -614,27 +826,30 @@ def run_scaling_sweep( std_reward=np.std(seed_rewards), specialization_index=np.mean(seed_sis), behavioral_diversity=np.mean(seed_bds), - messages_per_step=messages_per_step, - compute_time_ms=compute_time, - synergy_score=0.0, # Computed separately if needed + agent_forward_calls_per_step=agent_forward_calls_per_step, + compute_time_ms=np.mean(seed_compute_times), ) data_points.append(data_point) logger.info(f" Mean: reward={data_point.mean_reward:.2f}±{data_point.std_reward:.2f}") - logger.info(f" Params: {total_params:,}, Messages/step: {messages_per_step}") + logger.info( + " Params: %s, agent forward calls/step: %d", + f"{total_params:,}", + agent_forward_calls_per_step, + ) return data_points - def detect_phase_transitions( + def flag_slope_changes( self, data_points: List[ScalingDataPoint], metrics: List[str] = ['mean_reward', 'specialization_index', 'behavioral_diversity'], - ) -> List[PhaseTransition]: + ) -> List[SlopeChangeFlag]: """ - Detect phase transitions in scaling behavior. + Flag large local slope changes in scaling behavior. - A phase transition is where the slope of metric vs agents changes significantly. + This heuristic is descriptive and does not establish a phase transition. """ transitions = [] @@ -669,13 +884,16 @@ def detect_phase_transitions( baseline_slope = abs(before_slope) + abs(after_slope) + 0.01 if slope_change / baseline_slope > 0.5: # 50% relative change - transitions.append(PhaseTransition( + transitions.append(SlopeChangeFlag( agent_count=int(agent_counts[i]), metric_name=metric, before_slope=float(before_slope), after_slope=float(after_slope), magnitude=float(slope_change), - confidence=min(1.0, slope_change / baseline_slope), + relative_change_score=min( + 1.0, + slope_change / baseline_slope, + ), )) return transitions @@ -687,7 +905,7 @@ def detect_phase_transitions( def plot_scaling_results( data_points: List[ScalingDataPoint], - transitions: List[PhaseTransition], + transitions: List[SlopeChangeFlag], output_path: str, ): """Generate comprehensive scaling visualization.""" @@ -714,11 +932,11 @@ def plot_scaling_results( ax1.set_xscale('log') ax1.grid(True, alpha=0.3) - # Mark phase transitions + # Mark heuristic slope-change flags for t in transitions: if t.metric_name == 'mean_reward': ax1.axvline(t.agent_count, color='red', linestyle='--', alpha=0.7, - label=f'Phase transition @ {t.agent_count}') + label=f'Slope-change flag @ {t.agent_count}') # 2. Specialization vs Agents ax2 = fig.add_subplot(gs[0, 1]) @@ -729,36 +947,34 @@ def plot_scaling_results( ax2.plot(agents, bds, 'g-s', label='Behavioral Diversity', linewidth=2, markersize=8) ax2.set_xlabel('Number of Agents', fontsize=12) ax2.set_ylabel('Score', fontsize=12) - ax2.set_title('Emergence Metrics vs Scale', fontsize=14, fontweight='bold') + ax2.set_title('Behavioral Metrics vs Scale', fontsize=14, fontweight='bold') ax2.set_xscale('log') ax2.legend() ax2.grid(True, alpha=0.3) - # Mark phase transitions + # Mark heuristic slope-change flags for t in transitions: if t.metric_name in ['specialization_index', 'behavioral_diversity']: ax2.axvline(t.agent_count, color='red', linestyle='--', alpha=0.5) - # 3. Communication Overhead + # 3. Architectural work proxy ax3 = fig.add_subplot(gs[1, 0]) - messages = [p.messages_per_step for p in data_points] - efficiency = [p.mean_reward / (p.messages_per_step + 1) for p in data_points] - - ax3_twin = ax3.twinx() - - line1 = ax3.plot(agents, messages, 'r-^', label='Messages/Step', linewidth=2, markersize=8) - line2 = ax3_twin.plot(agents, efficiency, 'b-o', label='Reward/Message', linewidth=2, markersize=8) + forward_calls = [p.agent_forward_calls_per_step for p in data_points] + ax3.plot( + agents, + forward_calls, + 'r-^', + label='Agent forward calls/step', + linewidth=2, + markersize=8, + ) ax3.set_xlabel('Number of Agents', fontsize=12) - ax3.set_ylabel('Messages per Step', color='red', fontsize=12) - ax3_twin.set_ylabel('Reward per Message', color='blue', fontsize=12) - ax3.set_title('Communication Overhead', fontsize=14, fontweight='bold') + ax3.set_ylabel('Agent forward calls per environment step', fontsize=12) + ax3.set_title('Architectural Work Proxy', fontsize=14, fontweight='bold') ax3.set_xscale('log') ax3.grid(True, alpha=0.3) - - lines = line1 + line2 - labels = [l.get_label() for l in lines] - ax3.legend(lines, labels, loc='upper left') + ax3.legend(loc='upper left') # 4. Compute Time ax4 = fig.add_subplot(gs[1, 1]) @@ -809,7 +1025,13 @@ def plot_role_clusters( X_2d = pca.fit_transform(X) colors = plt.cm.tab10(np.array(metrics.cluster_labels) - 1) - scatter = ax1.scatter(X_2d[:, 0], X_2d[:, 1], c=colors, s=100, edgecolors='black') + ax1.scatter( + X_2d[:, 0], + X_2d[:, 1], + c=colors, + s=100, + edgecolors='black', + ) # Label points for i, p in enumerate(metrics.agent_profiles): @@ -835,7 +1057,6 @@ def plot_role_clusters( ax2.set_title('Action Distribution Heatmap', fontsize=14, fontweight='bold') # Add cluster boundaries - cluster_boundaries = [] for i in range(1, len(sorted_indices)): if metrics.cluster_labels[sorted_indices[i]] != metrics.cluster_labels[sorted_indices[i-1]]: ax2.axhline(i - 0.5, color='blue', linewidth=2) @@ -853,72 +1074,102 @@ def plot_role_clusters( # ============================================================================= def main(): - parser = argparse.ArgumentParser(description='SEESWM Emergence & Scaling Analysis') + parser = argparse.ArgumentParser( + description='SEESWM behavioral-pattern and random-init scaling diagnostics' + ) parser.add_argument('--device', type=str, default='cpu') - parser.add_argument('--output', type=str, default='results/emergence') - parser.add_argument('--model', type=str, default=None, help='Path to trained model') + parser.add_argument('--seed', type=int, default=0, help='Candidate evaluation seed') + parser.add_argument( + '--episodes', + type=int, + default=None, + help='Candidate/control episodes (default: 20 quick, otherwise 50)', + ) + parser.add_argument( + '--steps-per-episode', + type=int, + default=100, + help='Maximum candidate/control steps per episode', + ) + parser.add_argument('--output', type=str, default='results/behavioral-local') + parser.add_argument( + '--model', + type=str, + default=None, + help='Path to a schema-v2 trained-policy checkpoint', + ) parser.add_argument('--quick', action='store_true', help='Quick run with fewer samples') parser.add_argument('--scaling-only', action='store_true', help='Only run scaling analysis') - parser.add_argument('--emergence-only', action='store_true', help='Only run emergence analysis') + parser.add_argument( + '--behavior-only', + action='store_true', + help='Only run behavioral-pattern analysis', + ) + parser.add_argument( + '--emergence-only', + dest='behavior_only', + action='store_true', + help=argparse.SUPPRESS, + ) args = parser.parse_args() + candidate_episodes = ( + args.episodes if args.episodes is not None else (20 if args.quick else 50) + ) + if args.seed < 0: + parser.error('--seed must be nonnegative') + if args.scaling_only and args.behavior_only: + parser.error('--scaling-only and --behavior-only are mutually exclusive') + if args.scaling_only and args.model: + parser.error('--model applies only to behavioral-pattern analysis') + if candidate_episodes <= 0 or args.steps_per_episode <= 0: + parser.error('--episodes and --steps-per-episode must be positive') + + _seed_everything(args.seed) + analysis_source = _analysis_source_provenance() + output_dir = Path(args.output) output_dir.mkdir(parents=True, exist_ok=True) - # Configuration - swarm_config = { - 'num_agents': 20, - 'input_dim': 137, - 'hidden_dim': 128, - 'output_dim': 128, # Match trained model - } - - env_config = { - 'grid_size': 32, - 'num_resources': 20, - 'num_hazards': 10, - 'vision_radius': 5, - } - - results = { - 'timestamp': datetime.now().isoformat(), - 'config': {'swarm': swarm_config, 'env': env_config}, - } - - # Load trained model if specified + # Load the exact trained policy, or construct an explicitly random smoke + # test whose swarm output is already five-dimensional. + trained_checkpoint_loaded = False + policy_head = None + policy_head_spec = None + checkpoint_provenance = None if args.model: - logger.info(f"Loading trained model from {args.model}") - loaded = torch.load(args.model, map_location='cpu', weights_only=False) - - # Create swarm and load weights - num_agents = swarm_config['num_agents'] - config = SwarmConfig( - num_agents=num_agents, - input_dim=swarm_config['input_dim'], - hidden_dim=swarm_config['hidden_dim'], - output_dim=swarm_config['output_dim'], - message_dim=swarm_config['hidden_dim'], - topology=TopologyType.SMALL_WORLD, - num_perception=num_agents // 4, - num_reasoning=num_agents // 4, - num_memory=num_agents // 4, - num_planning=num_agents - 3 * (num_agents // 4), - ) - swarm = SwarmGraph(config, device=args.device) - - if 'swarm_state' in loaded: - swarm.load_state_dict(loaded['swarm_state']) - logger.info("Loaded trained weights") + checkpoint_path = Path(args.model).expanduser().resolve(strict=True) + logger.info("Loading trained policy from %s", checkpoint_path) + loaded, checkpoint_digest = load_checkpoint_with_digest(checkpoint_path) + swarm, policy_head, env_config = build_candidate(loaded, args.device) + policy_head_spec = dict(loaded['policy_head']) + if policy_head_spec.get('type') == 'policy_head_tanh': + from src.swarm.specialized_graph import specialized_swarm_config_to_dict + + swarm_config = specialized_swarm_config_to_dict(swarm.config) + else: + swarm_config = swarm_config_to_dict(swarm.config) + trained_checkpoint_loaded = True + checkpoint_provenance = { + 'file': checkpoint_path.name, + 'sha256': checkpoint_digest, + 'schema_version': int(loaded['schema_version']), + 'source_revision': loaded.get('source_revision'), + 'source_dirty': loaded.get('source_dirty'), + 'training_seed': loaded.get('seed'), + 'dependency_versions': loaded.get('dependency_versions'), + 'loaded_successfully': True, + } + logger.info("Loaded trained swarm and policy head") else: - # Create random swarm for testing - logger.info("No model specified, using random initialization") - num_agents = swarm_config['num_agents'] + logger.info("No model specified; running a random-initialization smoke test") + num_agents = 20 config = SwarmConfig( num_agents=num_agents, - input_dim=swarm_config['input_dim'], - hidden_dim=swarm_config['hidden_dim'], - output_dim=swarm_config['output_dim'], - message_dim=swarm_config['hidden_dim'], + input_dim=137, + hidden_dim=128, + output_dim=5, + message_dim=128, topology=TopologyType.SMALL_WORLD, num_perception=num_agents // 4, num_reasoning=num_agents // 4, @@ -926,52 +1177,133 @@ def main(): num_planning=num_agents - 3 * (num_agents // 4), ) swarm = SwarmGraph(config, device=args.device) + set_swarm_eval(swarm) + swarm_config = swarm_config_to_dict(config) + env_config = environment_config_to_dict( + EnvironmentConfig( + grid_size=32, + num_resources=20, + num_hazards=10, + vision_radius=5, + ) + ) + + results = { + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'config': { + 'swarm': swarm_config, + 'env': env_config, + 'device': args.device, + }, + } + + results['provenance'] = { + 'behavioral_analysis': { + 'initialization': ( + 'trained_checkpoint' if trained_checkpoint_loaded else 'fresh_random' + ), + 'checkpoint': checkpoint_provenance, + 'candidate_seed': int(args.seed), + 'random_control_seeds': ( + [] + if args.quick or args.scaling_only + else _random_control_seeds(args.seed, 30) + ), + 'episodes_per_candidate_and_control': int(candidate_episodes), + 'steps_per_episode': int(args.steps_per_episode), + 'random_control_count': 0 if args.quick or args.scaling_only else 30, + }, + 'scaling_analysis': { + 'initialization': 'fresh_random', + 'note': 'Scaling never reuses the candidate checkpoint.', + }, + 'quick_mode': args.quick, + 'analysis_source': analysis_source, + 'analysis_runtime': { + 'python': sys.version.split()[0], + 'torch': str(torch.__version__), + 'numpy': str(np.__version__), + }, + } # ================= - # EMERGENCE ANALYSIS + # BEHAVIORAL-PATTERN ANALYSIS # ================= if not args.scaling_only: logger.info("\n" + "=" * 60) - logger.info("EMERGENCE ANALYSIS") + logger.info("BEHAVIORAL-PATTERN ANALYSIS") logger.info("=" * 60) - analyzer = EmergenceAnalyzer(device=args.device, num_actions=min(5, swarm_config['output_dim'])) + analyzer = EmergenceAnalyzer( + device=args.device, + num_actions=5, + policy_head=policy_head, + policy_head_spec=policy_head_spec, + candidate_seed=args.seed, + ) + + # Re-seed because checkpoint/environment reconstruction may consume RNG. + _seed_everything(args.seed) metrics = analyzer.analyze_full( swarm, swarm_config, env_config, - num_episodes=20 if args.quick else 50, + num_episodes=candidate_episodes, + steps_per_episode=args.steps_per_episode, run_null_test=not args.quick, ) - results['emergence'] = { + results['behavioral_patterns'] = { 'specialization_index': metrics.specialization_index, 'role_consistency': metrics.role_consistency, 'behavioral_diversity': metrics.behavioral_diversity, - 'null_hypothesis_si': metrics.null_hypothesis_si, - 'p_value': metrics.p_value, - 'effect_size': metrics.effect_size, + 'random_control_si': metrics.random_control_si, + 'monte_carlo_p_value': metrics.monte_carlo_p_value, + 'standardized_null_difference': metrics.standardized_null_difference, + 'null_sample_count': metrics.null_sample_count, + 'random_comparison_performed': metrics.random_comparison_performed, 'num_distinct_roles': metrics.num_distinct_roles, 'cluster_sizes': metrics.cluster_sizes, } # Summary logger.info("\n" + "-" * 40) - logger.info("EMERGENCE SUMMARY") + logger.info("BEHAVIORAL-PATTERN SUMMARY") logger.info("-" * 40) logger.info(f"Specialization Index: {metrics.specialization_index:.4f}") - logger.info(f" vs Null Baseline: {metrics.null_hypothesis_si:.4f}") - logger.info(f" P-value: {metrics.p_value:.4f}") - logger.info(f" Effect size: {metrics.effect_size:.2f} (Cohen's d)") + if metrics.random_comparison_performed: + logger.info(f" vs Random Controls: {metrics.random_control_si:.4f}") + logger.info( + " Plus-one Monte Carlo p: %.4f (n=%d random controls)", + metrics.monte_carlo_p_value, + metrics.null_sample_count, + ) + if metrics.standardized_null_difference is None: + logger.info(" Standardized null difference: undefined (zero null SD)") + else: + logger.info( + " Standardized null difference: %.2f", + metrics.standardized_null_difference, + ) + else: + logger.info(" Fresh-random comparison: skipped") logger.info(f"Number of distinct roles: {metrics.num_distinct_roles}") logger.info(f"Role sizes: {metrics.cluster_sizes}") # Interpretation - if metrics.p_value < 0.05 and metrics.effect_size > 0.5: - logger.info("\n*** GENUINE SPECIALIZATION DETECTED ***") - logger.info("The trained swarm shows significantly more specialization") - logger.info("than random baseline (p < 0.05, effect size > 0.5)") + if not metrics.random_comparison_performed: + logger.info("\nRandom-init comparison skipped; no comparative conclusion") + elif ( + trained_checkpoint_loaded + and metrics.monte_carlo_p_value is not None + and metrics.monte_carlo_p_value < 0.05 + ): + logger.info("\nCandidate differs from the fresh-random comparison") + logger.info("under this 30-control exploratory comparison (p < 0.05)") + logger.info("This is not evidence of emergence without trained controls") + elif not trained_checkpoint_loaded: + logger.info("\nRandom-initialization smoke test only; no learned specialization claim") else: - logger.info("\nSpecialization not significantly different from random") + logger.info("\nCandidate SI was not unusual in this random-init comparison") # Plot clusters plot_role_clusters(metrics, str(output_dir / 'role_clusters.png')) @@ -979,50 +1311,66 @@ def main(): # ================= # SCALING ANALYSIS # ================= - if not args.emergence_only: + if not args.behavior_only: logger.info("\n" + "=" * 60) logger.info("SCALING ANALYSIS") logger.info("=" * 60) scaling_analyzer = ScalingAnalyzer( device=args.device, + base_input_dim=int(swarm_config['input_dim']), base_output_dim=5, # Use 5 actions for scaling tests (fresh swarms) ) if args.quick: agent_counts = [4, 10, 20, 50] num_seeds = 2 + scaling_eval_episodes = 10 else: agent_counts = [4, 10, 20, 50, 100, 150] num_seeds = 3 + scaling_eval_episodes = 20 + + results['provenance']['scaling_analysis'].update({ + 'seeds': list(range(num_seeds)), + 'evaluation_episodes_per_seed': scaling_eval_episodes, + 'evaluation_steps_per_episode': 100, + 'behavioral_episodes_per_seed': 10, + 'behavioral_steps_per_episode': 50, + 'agent_counts': agent_counts, + }) data_points = scaling_analyzer.run_scaling_sweep( agent_counts=agent_counts, env_config=env_config, num_seeds=num_seeds, - num_eval_episodes=10 if args.quick else 20, + num_eval_episodes=scaling_eval_episodes, ) - transitions = scaling_analyzer.detect_phase_transitions(data_points) + transitions = scaling_analyzer.flag_slope_changes(data_points) results['scaling'] = { + 'seeds': list(range(num_seeds)), 'data_points': [ { - 'num_agents': p.num_agents, - 'mean_reward': p.mean_reward, - 'std_reward': p.std_reward, - 'specialization_index': p.specialization_index, - 'behavioral_diversity': p.behavioral_diversity, - 'messages_per_step': p.messages_per_step, - 'total_params': p.total_params, + 'num_agents': int(p.num_agents), + 'mean_reward': float(p.mean_reward), + 'std_reward': float(p.std_reward), + 'specialization_index': float(p.specialization_index), + 'behavioral_diversity': float(p.behavioral_diversity), + 'agent_forward_calls_per_step': int( + p.agent_forward_calls_per_step + ), + 'compute_time_ms': float(p.compute_time_ms), + 'total_params': int(p.total_params), } for p in data_points ], - 'phase_transitions': [ + 'slope_change_flags': [ { - 'agent_count': t.agent_count, + 'agent_count': int(t.agent_count), 'metric': t.metric_name, - 'magnitude': t.magnitude, + 'magnitude': float(t.magnitude), } for t in transitions ], @@ -1033,13 +1381,13 @@ def main(): logger.info("SCALING SUMMARY") logger.info("-" * 40) - # Check for phase transitions + # Report heuristic slope-change flags if transitions: - logger.info(f"Detected {len(transitions)} phase transition(s):") + logger.info(f"Flagged {len(transitions)} local slope change(s):") for t in transitions: logger.info(f" {t.metric_name} @ {t.agent_count} agents (magnitude: {t.magnitude:.3f})") else: - logger.info("No significant phase transitions detected") + logger.info("No large local slope changes flagged") # Scaling trend rewards = [p.mean_reward for p in data_points] @@ -1047,21 +1395,21 @@ def main(): # Fit log-linear trend log_agents = np.log(agents) - slope, intercept, r_value, _, _ = stats.linregress(log_agents, rewards) + slope, _, r_value, _, _ = stats.linregress(log_agents, rewards) logger.info(f"\nScaling trend: reward ~ {slope:.3f} * log(agents)") logger.info(f"R² = {r_value**2:.3f}") - if slope > 0: - logger.info("Performance INCREASES with scale (good!)") - else: - logger.info("Performance DECREASES with scale (coordination breaking down?)") + logger.info( + "Observed reward trend direction: %s", + "positive" if slope > 0 else "non-positive", + ) # Plot plot_scaling_results(data_points, transitions, str(output_dir / 'scaling_analysis.png')) # Save results - output_file = output_dir / 'emergence_scaling_results.json' + output_file = output_dir / 'behavioral_scaling_results.json' with open(output_file, 'w') as f: json.dump(results, f, indent=2) diff --git a/experiments/train_curiosity.py b/experiments/train_curiosity.py index cf00372..7500233 100644 --- a/experiments/train_curiosity.py +++ b/experiments/train_curiosity.py @@ -219,9 +219,9 @@ def compare_curiosity_vs_no_curiosity( device: str = "cpu", ): """ - Compare performance of curiosity-driven vs standard RL. + Run an exploratory curiosity-versus-standard-RL comparison. - This validates that curiosity improves exploration and performance. + A single run does not validate a general improvement claim. """ print("=" * 60) print("Comparison: Curiosity vs No Curiosity") diff --git a/experiments/train_for_validation.py b/experiments/train_for_validation.py index 8814113..84a58e4 100644 --- a/experiments/train_for_validation.py +++ b/experiments/train_for_validation.py @@ -14,13 +14,87 @@ import numpy as np from collections import deque import argparse -import json from datetime import datetime - -from src.swarm.graph import SwarmGraph, SwarmConfig, TopologyType +from importlib.metadata import PackageNotFoundError, version as package_version +import platform +import random +import subprocess + +from src.swarm.graph import ( + SWARM_STATE_SCHEMA_VERSION, + SwarmGraph, + SwarmConfig, + TopologyType, + swarm_config_to_dict, +) from src.environment.cosmos import CosmosEnvironment +def _source_revision() -> str | None: + """Read the local Git revision without making checkpointing depend on Git.""" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=Path(__file__).resolve().parent.parent, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + revision = result.stdout.strip() + return revision if result.returncode == 0 and revision else None + + +def _source_dirty() -> bool | None: + """Report tracked or untracked worktree changes, or None without Git.""" + try: + result = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=normal"], + cwd=Path(__file__).resolve().parent.parent, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + return bool(result.stdout.strip()) if result.returncode == 0 else None + + +def _dependency_versions() -> dict[str, str | None]: + versions: dict[str, str | None] = {"python": platform.python_version()} + for distribution in ("torch", "numpy", "networkx"): + try: + versions[distribution] = str(package_version(distribution)) + except PackageNotFoundError: + versions[distribution] = None + return versions + + +def _environment_config_to_dict(env: CosmosEnvironment) -> dict: + config = env.config + return { + "grid_size": int(config.grid_size), + "num_resources": int(config.num_resources), + "num_hazards": int(config.num_hazards), + "num_agents": int(config.num_agents), + "vision_radius": int(config.vision_radius), + "enable_respawn": bool(config.enable_respawn), + "respawn_delay": int(config.respawn_delay), + "num_food": int(config.num_food), + "num_water": int(config.num_water), + "num_material": int(config.num_material), + "hunger_rate": float(config.hunger_rate), + "thirst_rate": float(config.thirst_rate), + "starvation_threshold": float(config.starvation_threshold), + "movement_cost": float(config.movement_cost), + "stay_cost": float(config.stay_cost), + "max_steps": int(config.max_steps), + } + + class PolicyGradientTrainer: """REINFORCE trainer with baseline for SwarmGraph.""" @@ -172,9 +246,21 @@ def train_swarm( device: str = "cpu", save_path: str = None, verbose: bool = True, + seed: int = 42, ): """Train swarm and return trained model.""" + if num_epochs < 1 or num_agents < 1 or hidden_dim < 1: + raise ValueError("epochs, agents, and hidden dimension must be positive") + if lr <= 0 or not np.isfinite(lr): + raise ValueError("learning rate must be finite and positive") + if seed < 0: + raise ValueError("seed must be non-negative") + + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # Create swarm swarm_config = SwarmConfig( num_agents=num_agents, @@ -183,6 +269,7 @@ def train_swarm( output_dim=hidden_dim, # Will project to actions message_dim=hidden_dim, topology=TopologyType.SMALL_WORLD, + small_world_k=min(4, num_agents), num_perception=num_agents // 4, num_reasoning=num_agents // 4, num_memory=num_agents // 4, @@ -213,6 +300,7 @@ def train_swarm( # Training loop reward_history = deque(maxlen=100) best_avg_reward = float('-inf') + avg_reward = 0.0 if verbose: print(f"\nTraining for {num_epochs} epochs...") @@ -239,19 +327,49 @@ def train_swarm( # Save model if save_path: + save_file = Path(save_path).expanduser() + save_file.parent.mkdir(parents=True, exist_ok=True) save_data = { - 'swarm_config': swarm_config.__dict__, + 'schema_version': SWARM_STATE_SCHEMA_VERSION, + 'checkpoint_type': 'training', + 'swarm_config': swarm_config_to_dict(swarm_config), 'swarm_state': swarm.state_dict(), - 'action_head_state': trainer.action_head.state_dict(), + 'policy_head': { + 'type': 'mlp_relu', + 'input_dim': int(swarm_config.output_dim), + 'hidden_dim': 64, + 'num_actions': 5, + 'state_dict': dict(trainer.action_head.state_dict()), + }, + 'value_head': { + 'type': 'mlp_relu', + 'input_dim': int(swarm_config.output_dim), + 'hidden_dim': 64, + 'state_dict': dict(trainer.value_head.state_dict()), + }, + 'environment_config': _environment_config_to_dict(env), + 'training_config': { + 'algorithm': 'reinforce_with_baseline', + 'num_epochs': int(num_epochs), + 'learning_rate': float(lr), + 'gamma': float(trainer.gamma), + 'entropy_coef': float(trainer.entropy_coef), + 'max_episode_steps': 200, + 'device': str(device), + }, 'training_metrics': { - 'final_avg_reward': avg_reward, - 'best_avg_reward': best_avg_reward, - 'num_epochs': num_epochs, - } + 'final_avg_reward': float(avg_reward), + 'best_avg_reward': float(best_avg_reward), + 'num_epochs': int(num_epochs), + }, + 'seed': int(seed), + 'source_revision': _source_revision(), + 'source_dirty': _source_dirty(), + 'dependency_versions': _dependency_versions(), } - torch.save(save_data, save_path) + torch.save(save_data, save_file) if verbose: - print(f"Saved trained model to {save_path}") + print(f"Saved trained model to {save_file}") return swarm, trainer.action_head, avg_reward @@ -264,6 +382,7 @@ def main(): parser.add_argument("--lr", type=float, default=3e-4, help="Learning rate") parser.add_argument("--device", type=str, default="cpu", help="Device") parser.add_argument("--save", type=str, default=None, help="Save path") + parser.add_argument("--seed", type=int, default=42, help="Random seed") parser.add_argument("--validate", action="store_true", help="Run validation after training") args = parser.parse_args() @@ -282,6 +401,7 @@ def main(): device=args.device, save_path=args.save, verbose=True, + seed=args.seed, ) # Optionally run validation @@ -290,10 +410,20 @@ def main(): print("RUNNING VALIDATION") print("=" * 60) - import subprocess - result = subprocess.run( - ["python", "experiments/validate_rigorously.py", "--mode", "quick"], - capture_output=False, + subprocess.run( + [ + sys.executable, + str(Path(__file__).with_name("validate_rigorously.py")), + "--model", + str(args.save), + "--device", + args.device, + "--seeds", + "3", + "--episodes", + "3", + ], + check=True, ) diff --git a/experiments/train_neuromorphic.py b/experiments/train_neuromorphic.py index 68f2a8c..67bed76 100644 --- a/experiments/train_neuromorphic.py +++ b/experiments/train_neuromorphic.py @@ -2,11 +2,11 @@ """ Training script for neuromorphic (spiking) swarm. -Demonstrates: +Explores: 1. Spiking neural network training with surrogate gradients 2. STDP-based unsupervised learning -3. Energy-efficient training -4. Comparison: SNN vs ANN energy efficiency +3. Sparse-activity training objectives +4. Model-based SNN/ANN operation-energy estimates 5. Spiking swarm on environment tasks """ diff --git a/experiments/train_specialization.py b/experiments/train_specialization.py index 26ec6ad..e1e9b63 100644 --- a/experiments/train_specialization.py +++ b/experiments/train_specialization.py @@ -1,15 +1,20 @@ """ Specialization training for SEESWM. -Phase 3: Agent specialization with typed messaging and role emergence tracking. -Trains specialized agent architectures and measures emergent division of labor. +Phase 3: Agent specialization with typed messaging and role-pattern tracking. +Trains specialized architectures and records descriptive communication metrics. """ import argparse import sys from pathlib import Path from datetime import datetime +from importlib.metadata import PackageNotFoundError, version as package_version from typing import Optional, Dict, List +import math +import platform +import random +import subprocess # Add src to path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -20,18 +25,189 @@ import numpy as np from tqdm import tqdm -from src.swarm.graph import SwarmGraph, SwarmConfig, TopologyType +from src.swarm.graph import ( + SWARM_STATE_SCHEMA_VERSION, + SwarmGraph, + SwarmConfig, + TopologyType, +) from src.swarm.specialized_graph import ( SpecializedSwarmGraph, SpecializedSwarmConfig, + specialized_swarm_config_to_dict, ) -from src.swarm.typed_messaging import MessageType, AGENT_MESSAGE_TYPES from src.world_model.jepa import WorldModel from src.environment.cosmos import CosmosEnvironment, EnvironmentConfig from src.training import PPOConfig, TrajectoryBuffer, PolicyHead, ValueHead, Transition -from src.agents.micro_agent import AgentType -from src.utils.config import Config from src.utils.logging import setup_logger, MetricsLogger +from experiments.validate_rigorously import load_checkpoint_with_digest + + +def _source_revision() -> str | None: + """Read the local Git revision without making checkpointing depend on Git.""" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=Path(__file__).resolve().parent.parent, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + revision = result.stdout.strip() + return revision if result.returncode == 0 and revision else None + + +def _source_dirty() -> bool | None: + """Report tracked or untracked worktree changes, or None without Git.""" + try: + result = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=normal"], + cwd=Path(__file__).resolve().parent.parent, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + return bool(result.stdout.strip()) if result.returncode == 0 else None + + +def _dependency_versions() -> dict[str, str | None]: + versions: dict[str, str | None] = {"python": platform.python_version()} + for distribution in ("torch", "numpy", "networkx"): + try: + versions[distribution] = str(package_version(distribution)) + except PackageNotFoundError: + versions[distribution] = None + return versions + + +def _environment_config_to_dict(config: EnvironmentConfig) -> dict: + return { + "grid_size": int(config.grid_size), + "num_resources": int(config.num_resources), + "num_hazards": int(config.num_hazards), + "num_agents": int(config.num_agents), + "vision_radius": int(config.vision_radius), + "enable_respawn": bool(config.enable_respawn), + "respawn_delay": int(config.respawn_delay), + "num_food": int(config.num_food), + "num_water": int(config.num_water), + "num_material": int(config.num_material), + "hunger_rate": float(config.hunger_rate), + "thirst_rate": float(config.thirst_rate), + "starvation_threshold": float(config.starvation_threshold), + "movement_cost": float(config.movement_cost), + "stay_cost": float(config.stay_cost), + "max_steps": int(config.max_steps), + } + + +def _ppo_config_to_dict(config: PPOConfig) -> dict: + return { + "learning_rate": float(config.learning_rate), + "gamma": float(config.gamma), + "gae_lambda": float(config.gae_lambda), + "clip_epsilon": float(config.clip_epsilon), + "num_epochs": int(config.num_epochs), + "batch_size": int(config.batch_size), + "max_grad_norm": float(config.max_grad_norm), + "value_coef": float(config.value_coef), + "entropy_coef": float(config.entropy_coef), + "curiosity_coef": float(config.curiosity_coef), + "device": str(config.device), + } + + +def _to_native(value: object) -> object: + """Recursively reject custom objects and normalize NumPy scalar metrics.""" + if value is None or type(value) in (bool, int, str): + return value + if type(value) is float: + if not math.isfinite(value): + raise ValueError("Checkpoint metrics must be finite") + return value + if isinstance(value, np.generic): + return _to_native(value.item()) + if type(value) is dict: + result = {} + for key, item in value.items(): + if type(key) is not str: + raise TypeError( + "Checkpoint metric keys must be strings, " + f"got {type(key)}" + ) + result[key] = _to_native(item) + return result + if type(value) in (list, tuple): + return [_to_native(item) for item in value] + raise TypeError(f"Unsupported checkpoint metric value: {type(value)}") + + +def _build_checkpoint( + *, + trainer: "SpecializationPPOTrainer", + swarm: SpecializedSwarmGraph, + world_model: WorldModel, + env_config: EnvironmentConfig, + num_iterations: int, + rollout_steps: int, + topology: str, + iteration: int, + metrics: dict, + role_metrics: dict, + seed: int, + source_revision: str | None, + source_dirty: bool | None, + dependency_versions: dict[str, str | None], +) -> dict: + return { + "schema_version": SWARM_STATE_SCHEMA_VERSION, + "checkpoint_type": "specialized_ppo_training", + "swarm_config": specialized_swarm_config_to_dict(swarm.config), + "swarm_state": swarm.state_dict(), + "policy_head": { + "type": "policy_head_tanh", + "input_dim": int(trainer.output_dim), + "hidden_dim": 64, + "num_actions": int(trainer.num_actions), + "state_dict": dict(trainer.policy_head.state_dict()), + }, + "value_head": { + "type": "value_head_tanh", + "input_dim": int(trainer.output_dim), + "hidden_dim": 64, + "state_dict": dict(trainer.value_head.state_dict()), + }, + "world_model": { + "type": "jepa", + "obs_dim": int(world_model.obs_dim), + "action_dim": int(world_model.action_dim), + "latent_dim": int(world_model.latent_dim), + "num_hierarchy_levels": int(len(world_model.predictors)), + "ema_decay": float(world_model.ema_decay), + "state_dict": dict(world_model.state_dict()), + }, + "environment_config": _environment_config_to_dict(env_config), + "training_config": { + "algorithm": "ppo_with_curiosity", + "num_iterations": int(num_iterations), + "rollout_steps": int(rollout_steps), + "topology": str(topology), + "ppo": _ppo_config_to_dict(trainer.config), + }, + "iteration": int(iteration), + "training_metrics": _to_native(metrics), + "role_metrics": _to_native(role_metrics), + "seed": int(seed), + "source_revision": source_revision, + "source_dirty": source_dirty, + "dependency_versions": dict(dependency_versions), + } class SpecializationPPOTrainer: @@ -40,7 +216,7 @@ class SpecializationPPOTrainer: Key differences from base PPOTrainer: - Uses SpecializedSwarmGraph with typed messaging - - Tracks role emergence metrics + - Tracks descriptive role-pattern metrics - Reports specialization-specific statistics """ @@ -293,11 +469,29 @@ def train_specialized( seed: int = 42, ): """ - Train specialized swarm with role emergence tracking. + Train a specialized swarm with role-pattern tracking. """ + if num_iterations < 1 or rollout_steps < 1: + raise ValueError("iterations and rollout steps must be positive") + if seed < 0: + raise ValueError("seed must be non-negative") + supported_topologies = { + "hierarchical", + "modular", + "small_world", + "scale_free", + } + if topology not in supported_topologies: + raise ValueError(f"unsupported topology: {topology!r}") + + random.seed(seed) torch.manual_seed(seed) np.random.seed(seed) + source_revision = _source_revision() + source_dirty = _source_dirty() + dependency_versions = _dependency_versions() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") run_dir = Path(log_dir) / f"specialized_{timestamp}" run_dir.mkdir(parents=True, exist_ok=True) @@ -415,20 +609,49 @@ def train_specialized( if trainer_stats["avg_reward_10"] > best_avg_reward: best_avg_reward = trainer_stats["avg_reward_10"] - torch.save({ - "swarm": swarm.state_dict(), - "world_model": world_model.state_dict(), - "iteration": iteration, - "avg_reward": best_avg_reward, - "role_metrics": role_metrics, - }, run_dir / "best_model.pt") + torch.save( + _build_checkpoint( + trainer=trainer, + swarm=swarm, + world_model=world_model, + env_config=env_config, + num_iterations=num_iterations, + rollout_steps=rollout_steps, + topology=topology, + iteration=iteration, + metrics={ + **trainer_stats, + "best_avg_reward": best_avg_reward, + }, + role_metrics=role_metrics, + seed=seed, + source_revision=source_revision, + source_dirty=source_dirty, + dependency_versions=dependency_versions, + ), + run_dir / "best_model.pt", + ) if iteration % 100 == 0 and iteration > 0: - torch.save({ - "swarm": swarm.state_dict(), - "world_model": world_model.state_dict(), - "iteration": iteration, - }, run_dir / f"checkpoint_{iteration}.pt") + torch.save( + _build_checkpoint( + trainer=trainer, + swarm=swarm, + world_model=world_model, + env_config=env_config, + num_iterations=num_iterations, + rollout_steps=rollout_steps, + topology=topology, + iteration=iteration, + metrics=trainer.get_stats(), + role_metrics=swarm.get_role_emergence_metrics(), + seed=seed, + source_revision=source_revision, + source_dirty=source_dirty, + dependency_versions=dependency_versions, + ), + run_dir / f"checkpoint_{iteration}.pt", + ) # Final summary logger.info("\n" + "=" * 60) @@ -443,19 +666,35 @@ def train_specialized( logger.info(f"Final avg reward: {final_stats['avg_reward_100']:.3f}") logger.info(f"Best avg reward: {best_avg_reward:.3f}") - logger.info("\nRole Emergence Metrics:") + logger.info("\nRole-pattern metrics:") logger.info(f" Specialization entropy: {final_role_metrics.get('avg_specialization_entropy', 0):.3f}") type_concentrations = final_role_metrics.get("type_concentrations", {}) for agent_type, concentration in type_concentrations.items(): logger.info(f" {agent_type} concentration: {concentration:.3f}") - torch.save({ - "swarm": swarm.state_dict(), - "world_model": world_model.state_dict(), - "final_stats": final_stats, - "role_metrics": final_role_metrics, - }, run_dir / "final_model.pt") + torch.save( + _build_checkpoint( + trainer=trainer, + swarm=swarm, + world_model=world_model, + env_config=env_config, + num_iterations=num_iterations, + rollout_steps=rollout_steps, + topology=topology, + iteration=max(num_iterations - 1, 0), + metrics={ + **final_stats, + "best_avg_reward": best_avg_reward, + }, + role_metrics=final_role_metrics, + seed=seed, + source_revision=source_revision, + source_dirty=source_dirty, + dependency_versions=dependency_versions, + ), + run_dir / "final_model.pt", + ) metrics.save() logger.info(f"\nResults saved to: {run_dir}") @@ -596,27 +835,30 @@ def analyze_role_emergence( device: str = "cpu", ): """ - Analyze role emergence from a trained checkpoint. + Analyze descriptive role-pattern metrics from a trained checkpoint. """ print("=" * 60) - print("Role Emergence Analysis") + print("Role-pattern analysis") print("=" * 60) - checkpoint = torch.load(checkpoint_path, map_location=device) - role_metrics = checkpoint.get("role_metrics", {}) + checkpoint, digest = load_checkpoint_with_digest(Path(checkpoint_path)) + print(f"Checkpoint SHA-256: {digest}") + role_metrics = checkpoint.get("role_metrics") + if type(role_metrics) is not dict: + raise TypeError("checkpoint role_metrics must be a native dict") - print("\nRole Emergence Metrics:") + print("\nRole-pattern metrics:") print(f" Avg specialization entropy: {role_metrics.get('avg_specialization_entropy', 0):.4f}") type_concentrations = role_metrics.get("type_concentrations", {}) - print("\nType Concentrations (higher = more specialized):") + print("\nType concentrations (higher = more concentrated):") for agent_type, concentration in type_concentrations.items(): bar = "=" * int(concentration * 50) print(f" {agent_type:12s}: {concentration:.3f} |{bar}|") print("\nInterpretation:") - print(" - Low entropy = agents are highly specialized") - print(" - High concentration = agents send their expected message types") + print(" - Lower entropy means message types were more concentrated") + print(" - Concentration reports use of architecture-associated message types") return role_metrics diff --git a/experiments/train_specialized.py b/experiments/train_specialized.py index a6144ed..38688da 100644 --- a/experiments/train_specialized.py +++ b/experiments/train_specialized.py @@ -1,10 +1,9 @@ """ Advanced training with specialization pressure and diversity rewards. -This script aims to produce swarms that: -1. Show measurable synergy (agents contribute unique information) -2. Develop emergent specialization (different agents do different things) -3. Are sensitive to ablation (removing components hurts performance) +This script applies specialization and diversity objectives, then records +descriptive output, behavior, and ablation metrics. Those metrics are candidate +signals for controlled follow-up, not evidence of emergence or superiority. """ import sys @@ -17,21 +16,96 @@ import numpy as np from collections import deque from dataclasses import dataclass -from typing import Dict, List, Optional +from typing import Dict, List import argparse from datetime import datetime - -from src.swarm.graph import SwarmGraph, SwarmConfig, TopologyType +from importlib.metadata import PackageNotFoundError, version as package_version +import platform +import random +import subprocess + +from src.swarm.graph import ( + SWARM_STATE_SCHEMA_VERSION, + SwarmGraph, + SwarmConfig, + TopologyType, + swarm_config_to_dict, +) from src.environment.cosmos import CosmosEnvironment +def _source_revision() -> str | None: + """Read the local Git revision without making checkpointing depend on Git.""" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=Path(__file__).resolve().parent.parent, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + revision = result.stdout.strip() + return revision if result.returncode == 0 and revision else None + + +def _source_dirty() -> bool | None: + """Report tracked or untracked worktree changes, or None without Git.""" + try: + result = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=normal"], + cwd=Path(__file__).resolve().parent.parent, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + return bool(result.stdout.strip()) if result.returncode == 0 else None + + +def _dependency_versions() -> dict[str, str | None]: + versions: dict[str, str | None] = {"python": platform.python_version()} + for distribution in ("torch", "numpy", "networkx"): + try: + versions[distribution] = str(package_version(distribution)) + except PackageNotFoundError: + versions[distribution] = None + return versions + + +def _environment_config_to_dict(env: CosmosEnvironment) -> dict: + config = env.config + return { + "grid_size": int(config.grid_size), + "num_resources": int(config.num_resources), + "num_hazards": int(config.num_hazards), + "num_agents": int(config.num_agents), + "vision_radius": int(config.vision_radius), + "enable_respawn": bool(config.enable_respawn), + "respawn_delay": int(config.respawn_delay), + "num_food": int(config.num_food), + "num_water": int(config.num_water), + "num_material": int(config.num_material), + "hunger_rate": float(config.hunger_rate), + "thirst_rate": float(config.thirst_rate), + "starvation_threshold": float(config.starvation_threshold), + "movement_cost": float(config.movement_cost), + "stay_cost": float(config.stay_cost), + "max_steps": int(config.max_steps), + } + + @dataclass class SpecializationMetrics: - """Track how specialized agents become.""" - action_entropy_per_agent: List[float] # Low = specialized - message_variance_per_agent: List[float] # High = unique outputs - pairwise_correlation: float # Low = agents are different - specialization_score: float # Combined metric + """Descriptive output-differentiation metrics.""" + action_entropy_per_agent: List[float] + message_variance_per_agent: List[float] + pairwise_correlation: float + specialization_score: float # Script-defined proxy class DiversityReward: @@ -318,9 +392,23 @@ def train_with_specialization( device: str = "cpu", save_path: str = None, verbose: bool = True, + seed: int = 42, ): """Train swarm with specialization pressure.""" + if num_epochs < 1 or num_agents < 1 or hidden_dim < 1: + raise ValueError("epochs, agents, and hidden dimension must be positive") + if lr <= 0 or not np.isfinite(lr): + raise ValueError("learning rate must be finite and positive") + if not np.isfinite(diversity_coef) or diversity_coef < 0: + raise ValueError("diversity coefficient must be finite and non-negative") + if seed < 0: + raise ValueError("seed must be non-negative") + + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + # Create swarm swarm_config = SwarmConfig( num_agents=num_agents, @@ -328,7 +416,7 @@ def train_with_specialization( hidden_dim=hidden_dim, output_dim=hidden_dim, message_dim=hidden_dim, - topology=TopologyType.MODULAR, # Modular encourages specialization + topology=TopologyType.MODULAR, num_perception=num_agents // 4, num_reasoning=num_agents // 4, num_memory=num_agents // 4, @@ -338,7 +426,7 @@ def train_with_specialization( if verbose: print(f"Created swarm: {swarm.total_parameters:,} parameters") - print(f"Topology: MODULAR (encourages specialization)") + print("Topology: MODULAR") print(f"Diversity coefficient: {diversity_coef}") # Create environment @@ -356,6 +444,7 @@ def train_with_specialization( # Training loop reward_history = deque(maxlen=100) best_avg_reward = float('-inf') + avg_reward = 0.0 if verbose: print(f"\nTraining for {num_epochs} epochs...") @@ -378,34 +467,74 @@ def train_with_specialization( f"Spec: {spec_metrics.specialization_score:.4f} | " f"Corr: {spec_metrics.pairwise_correlation:.3f}") + final_metrics = ( + trainer.compute_specialization_metrics() + if verbose or save_path + else None + ) + if verbose: + assert final_metrics is not None print("-" * 70) print(f"Training complete! Final avg reward: {avg_reward:.2f}") - # Final specialization analysis - final_metrics = trainer.compute_specialization_metrics() - print(f"\nSpecialization Analysis:") + # Final descriptive metrics + print("\nDescriptive output metrics:") print(f" Pairwise correlation: {final_metrics.pairwise_correlation:.4f} (lower = more diverse)") - print(f" Specialization score: {final_metrics.specialization_score:.4f} (higher = more specialized)") + print( + " Script-defined specialization proxy: " + f"{final_metrics.specialization_score:.4f}" + ) # Save if save_path: + assert final_metrics is not None + save_file = Path(save_path).expanduser() + save_file.parent.mkdir(parents=True, exist_ok=True) save_data = { - 'swarm_config': swarm_config.__dict__, + 'schema_version': SWARM_STATE_SCHEMA_VERSION, + 'checkpoint_type': 'specialization_training', + 'swarm_config': swarm_config_to_dict(swarm_config), 'swarm_state': swarm.state_dict(), - 'action_head_state': trainer.action_head.state_dict(), - 'value_head_state': trainer.value_head.state_dict(), + 'policy_head': { + 'type': 'mlp_relu', + 'input_dim': int(swarm_config.output_dim), + 'hidden_dim': 64, + 'num_actions': 5, + 'state_dict': dict(trainer.action_head.state_dict()), + }, + 'value_head': { + 'type': 'mlp_relu', + 'input_dim': int(swarm_config.output_dim), + 'hidden_dim': 64, + 'state_dict': dict(trainer.value_head.state_dict()), + }, + 'environment_config': _environment_config_to_dict(env), + 'training_config': { + 'algorithm': 'actor_critic_with_diversity', + 'num_epochs': int(num_epochs), + 'learning_rate': float(lr), + 'gamma': float(trainer.gamma), + 'entropy_coef': float(trainer.entropy_coef), + 'diversity_coef': float(diversity_coef), + 'max_episode_steps': 200, + 'device': str(device), + }, 'training_metrics': { - 'final_avg_reward': avg_reward, - 'best_avg_reward': best_avg_reward, - 'num_epochs': num_epochs, - 'specialization_score': final_metrics.specialization_score, - 'pairwise_correlation': final_metrics.pairwise_correlation, - } + 'final_avg_reward': float(avg_reward), + 'best_avg_reward': float(best_avg_reward), + 'num_epochs': int(num_epochs), + 'specialization_score': float(final_metrics.specialization_score), + 'pairwise_correlation': float(final_metrics.pairwise_correlation), + }, + 'seed': int(seed), + 'source_revision': _source_revision(), + 'source_dirty': _source_dirty(), + 'dependency_versions': _dependency_versions(), } - torch.save(save_data, save_path) + torch.save(save_data, save_file) if verbose: - print(f"\nSaved to {save_path}") + print(f"\nSaved to {save_file}") return swarm, trainer, avg_reward @@ -419,6 +548,7 @@ def main(): parser.add_argument("--diversity", type=float, default=0.1, help="Diversity reward coefficient") parser.add_argument("--device", type=str, default="cpu", help="Device") parser.add_argument("--save", type=str, default=None, help="Save path") + parser.add_argument("--seed", type=int, default=42, help="Random seed") args = parser.parse_args() if args.save is None: @@ -434,6 +564,7 @@ def main(): diversity_coef=args.diversity, device=args.device, save_path=args.save, + seed=args.seed, ) diff --git a/experiments/validate_hypothesis.py b/experiments/validate_hypothesis.py index b70b42a..b761c3c 100644 --- a/experiments/validate_hypothesis.py +++ b/experiments/validate_hypothesis.py @@ -1,26 +1,31 @@ #!/usr/bin/env python3 """ -Hypothesis Validation Experiments for SEESWM. +Exploratory fresh-network experiments for SEESWM. -Core hypothesis: "Collective intelligence from many small specialized agents, -grounded in world simulation, will exhibit emergent capabilities that -equivalent-parameter monolithic models cannot." +This script creates new randomly initialized networks and runs diagnostics for: +1. aggregate-versus-individual output error; +2. the internal aggregation score across agent counts; +3. descriptive differences across graph topologies; and +4. rollouts of a fresh random policy in the grid world. -This script runs controlled experiments to test: -1. Synergy: Does the swarm outperform individual agents? -2. Scaling: Does synergy increase with more agents? -3. Topology: Which graph structure produces best emergent behavior? -4. Specialization: Do typed agents outperform generic ones? -5. Curiosity: Does world-model curiosity improve exploration? +It does not train a model or test emergence, specialization, or architectural +superiority. Results are saved to JSON for analysis. """ import argparse import json +import math +import platform +import random +import subprocess import time from dataclasses import dataclass, asdict -from typing import Dict, List, Any, Optional +from datetime import datetime, timezone +from importlib.metadata import PackageNotFoundError, version as package_version +from collections.abc import Sequence +from typing import Any, Dict, Optional from pathlib import Path import torch @@ -29,8 +34,74 @@ import numpy as np from src.swarm.graph import SwarmGraph, SwarmConfig, TopologyType -from src.agents.micro_agent import MicroAgent, AgentConfig, AgentType -from src.environment.cosmos import CosmosEnvironment, EnvironmentConfig +from src.environment.cosmos import CosmosEnvironment + + +def _seed_everything(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + + +def _role_counts(num_agents: int) -> tuple[int, int, int, int]: + """Split all agents across four declared roles without hidden extras.""" + if num_agents < 1: + raise ValueError("num_agents must be positive") + base, remainder = divmod(num_agents, 4) + counts = [base + (index < remainder) for index in range(4)] + return tuple(counts) + + +def _set_swarm_eval(swarm: SwarmGraph) -> None: + for agent in swarm.agents.values(): + agent.network.eval() + + +def _source_provenance() -> Dict[str, Any]: + repository = Path(__file__).resolve().parent.parent + try: + revision = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repository, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + status = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=normal"], + cwd=repository, + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return {"revision": None, "dirty": None} + return { + "revision": revision.stdout.strip() if revision.returncode == 0 else None, + "dirty": bool(status.stdout.strip()) if status.returncode == 0 else None, + } + + +def _dependency_versions() -> Dict[str, Optional[str]]: + versions: Dict[str, Optional[str]] = {"python": platform.python_version()} + for distribution in ("torch", "numpy", "networkx"): + try: + versions[distribution] = str(package_version(distribution)) + except PackageNotFoundError: + versions[distribution] = None + return versions + + +def _to_json_native(value: Any) -> Any: + if isinstance(value, np.generic): + return value.item() + if isinstance(value, dict): + return {str(key): _to_json_native(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_to_json_native(item) for item in value] + return value @dataclass @@ -38,42 +109,50 @@ class ExperimentResult: """Results from a single experiment run.""" experiment_name: str config: Dict[str, Any] - metrics: Dict[str, float] + metrics: Dict[str, Any] duration_seconds: float timestamp: str -def create_baseline_mlp(input_dim: int, hidden_dim: int, output_dim: int, - total_params: int) -> nn.Module: - """ - Create a monolithic MLP with approximately the same parameter count as a swarm. - This is our baseline to compare against. +def create_baseline_mlp( + input_dim: int, + output_dim: int, + total_params: int, +) -> nn.Module: """ - # Calculate layers needed to match param count - # params = input_dim * h + h + h * h + h + ... + h * output_dim + output_dim - # Simplified: aim for similar capacity - - layers = [] - current_dim = input_dim - - # Estimate depth needed - params_per_layer = hidden_dim * hidden_dim + hidden_dim - num_hidden = max(1, total_params // params_per_layer - 1) - num_hidden = min(num_hidden, 10) # Cap at 10 layers - - # Input layer - layers.extend([nn.Linear(input_dim, hidden_dim), nn.ReLU()]) + Create a two-hidden-layer MLP close to a requested parameter budget. - # Hidden layers - for _ in range(num_hidden): - layers.extend([nn.Linear(hidden_dim, hidden_dim), nn.ReLU()]) + The prior implementation capped a narrow network at ten layers and could + miss the requested budget by more than an order of magnitude. + """ + # For width w, the parameter count is: + # w^2 + (input_dim + output_dim + 2) * w + output_dim + linear = input_dim + output_dim + 2 + discriminant = linear * linear - 4 * (output_dim - total_params) + estimated_width = max(1, round((-linear + math.sqrt(discriminant)) / 2)) + + def parameter_count(width: int) -> int: + return ( + input_dim * width + width + + width * width + width + + width * output_dim + output_dim + ) - # Output layer - layers.append(nn.Linear(hidden_dim, output_dim)) + candidates = range(max(1, estimated_width - 3), estimated_width + 4) + matched_width = min(candidates, key=lambda width: abs(parameter_count(width) - total_params)) - model = nn.Sequential(*layers) + model = nn.Sequential( + nn.Linear(input_dim, matched_width), + nn.ReLU(), + nn.Linear(matched_width, matched_width), + nn.ReLU(), + nn.Linear(matched_width, output_dim), + ) actual_params = sum(p.numel() for p in model.parameters()) - print(f" Baseline MLP: {actual_params:,} params ({num_hidden} hidden layers)") + print( + f" Baseline MLP: {actual_params:,} params " + f"(target {total_params:,}; width {matched_width})" + ) return model @@ -82,40 +161,52 @@ def experiment_synergy_vs_baseline( num_trials: int = 10, num_agents: int = 20, device: str = "cpu", + seed: int = 0, ) -> ExperimentResult: """ - Experiment 1: Compare swarm performance to equivalent-parameter MLP. + Compare fresh swarm outputs to a parameter-matched, fresh MLP. - Tests whether collective intelligence emerges from the swarm architecture. + This probes random-initialization behavior; it does not test learned + coordination or emergent intelligence. """ print("\n" + "="*60) - print("Experiment 1: Synergy vs Baseline MLP") + if num_trials < 1: + raise ValueError("num_trials must be positive") + + print("Experiment 1: Fresh-output comparison") print("="*60) start_time = time.time() + _seed_everything(seed) input_dim, hidden_dim, output_dim = 32, 64, 16 # Create swarm + role_counts = _role_counts(num_agents) config = SwarmConfig( num_agents=num_agents, input_dim=input_dim, hidden_dim=hidden_dim, output_dim=output_dim, topology=TopologyType.SMALL_WORLD, + num_perception=role_counts[0], + num_reasoning=role_counts[1], + num_memory=role_counts[2], + num_planning=role_counts[3], ) swarm = SwarmGraph(config, device=device) + _set_swarm_eval(swarm) swarm_params = swarm.total_parameters print(f" Swarm: {swarm_params:,} params, {num_agents} agents") # Create baseline MLP with similar param count - baseline = create_baseline_mlp(input_dim, hidden_dim, output_dim, swarm_params) - baseline = baseline.to(device) + baseline = create_baseline_mlp(input_dim, output_dim, swarm_params) + baseline = baseline.to(device).eval() # Run trials swarm_errors = [] baseline_errors = [] - synergies = [] + aggregation_scores = [] for trial in range(num_trials): # Random regression task @@ -125,6 +216,7 @@ def experiment_synergy_vs_baseline( with torch.no_grad(): # Swarm prediction + swarm.reset(batch_size=X.shape[0]) swarm_pred = swarm.step(X) swarm_error = F.mse_loss(swarm_pred, y).item() swarm_errors.append(swarm_error) @@ -134,49 +226,68 @@ def experiment_synergy_vs_baseline( baseline_error = F.mse_loss(baseline_pred, y).item() baseline_errors.append(baseline_error) - # Synergy computation + # Legacy API name; this is an internal aggregation diagnostic. + swarm.reset(batch_size=X.shape[0]) synergy_result = swarm.compute_synergy(X, y) - synergies.append(synergy_result['synergy']) + aggregation_scores.append(synergy_result['synergy']) metrics = { "swarm_error_mean": np.mean(swarm_errors), "swarm_error_std": np.std(swarm_errors), "baseline_error_mean": np.mean(baseline_errors), "baseline_error_std": np.std(baseline_errors), - "synergy_mean": np.mean(synergies), - "synergy_std": np.std(synergies), + "internal_aggregation_score_mean": np.mean(aggregation_scores), + "internal_aggregation_score_std": np.std(aggregation_scores), "swarm_params": swarm_params, "baseline_params": sum(p.numel() for p in baseline.parameters()), + "baseline_parameter_gap_fraction": abs( + sum(p.numel() for p in baseline.parameters()) - swarm_params + ) / swarm_params, "swarm_wins": sum(1 for s, b in zip(swarm_errors, baseline_errors) if s < b), } print(f"\nResults ({num_trials} trials):") - print(f" Swarm MSE: {metrics['swarm_error_mean']:.4f} ± {metrics['swarm_error_std']:.4f}") - print(f" Baseline MSE: {metrics['baseline_error_mean']:.4f} ± {metrics['baseline_error_std']:.4f}") - print(f" Synergy: {metrics['synergy_mean']:.4f} ± {metrics['synergy_std']:.4f}") + print( + f" Swarm MSE: {metrics['swarm_error_mean']:.4f} " + f"± {metrics['swarm_error_std']:.4f}" + ) + print( + f" Baseline MSE: {metrics['baseline_error_mean']:.4f} " + f"± {metrics['baseline_error_std']:.4f}" + ) + print( + " Internal aggregation score: " + f"{metrics['internal_aggregation_score_mean']:.4f} " + f"± {metrics['internal_aggregation_score_std']:.4f}" + ) print(f" Swarm wins: {metrics['swarm_wins']}/{num_trials}") return ExperimentResult( experiment_name="synergy_vs_baseline", - config={"num_agents": num_agents, "num_trials": num_trials}, + config={"num_agents": num_agents, "num_trials": num_trials, "seed": seed}, metrics=metrics, duration_seconds=time.time() - start_time, - timestamp=time.strftime("%Y-%m-%d %H:%M:%S"), + timestamp=datetime.now(timezone.utc).isoformat(), ) def experiment_scaling_synergy( - agent_counts: List[int] = [5, 10, 20, 50, 100], + agent_counts: Sequence[int] = (5, 10, 20, 50, 100), num_trials: int = 5, device: str = "cpu", + seed: int = 0, ) -> ExperimentResult: """ - Experiment 2: How does synergy scale with number of agents? - - Tests whether more agents lead to more emergent capability. + Describe the fresh-network internal aggregation score across agent counts. """ print("\n" + "="*60) - print("Experiment 2: Scaling Synergy with Agent Count") + if num_trials < 1: + raise ValueError("num_trials must be positive") + if not agent_counts or any(count < 1 for count in agent_counts): + raise ValueError("agent_counts must contain positive values") + + agent_counts = list(agent_counts) + print("Experiment 2: Internal aggregation score by agent count") print("="*60) start_time = time.time() @@ -185,18 +296,25 @@ def experiment_scaling_synergy( results_by_count = {} for num_agents in agent_counts: + _seed_everything(seed) print(f"\n Testing {num_agents} agents...") + role_counts = _role_counts(num_agents) config = SwarmConfig( num_agents=num_agents, input_dim=input_dim, hidden_dim=hidden_dim, output_dim=output_dim, topology=TopologyType.SMALL_WORLD, + num_perception=role_counts[0], + num_reasoning=role_counts[1], + num_memory=role_counts[2], + num_planning=role_counts[3], ) swarm = SwarmGraph(config, device=device) + _set_swarm_eval(swarm) - synergies = [] + aggregation_scores = [] collective_perfs = [] individual_perfs = [] @@ -205,49 +323,60 @@ def experiment_scaling_synergy( y = torch.sin(X[:, :output_dim]) + 0.1 * torch.randn(32, output_dim, device=device) with torch.no_grad(): + swarm.reset(batch_size=X.shape[0]) result = swarm.compute_synergy(X, y) - synergies.append(result['synergy']) + aggregation_scores.append(result['synergy']) collective_perfs.append(result['collective_performance']) individual_perfs.append(result['avg_individual_performance']) results_by_count[num_agents] = { - "synergy_mean": np.mean(synergies), - "synergy_std": np.std(synergies), + "internal_aggregation_score_mean": np.mean(aggregation_scores), + "internal_aggregation_score_std": np.std(aggregation_scores), "collective_perf_mean": np.mean(collective_perfs), "individual_perf_mean": np.mean(individual_perfs), "params": swarm.total_parameters, } - print(f" Synergy: {results_by_count[num_agents]['synergy_mean']:.4f}") + print( + " Internal aggregation score: " + f"{results_by_count[num_agents]['internal_aggregation_score_mean']:.4f}" + ) print(f" Params: {swarm.total_parameters:,}") - # Compute scaling efficiency - base_synergy = results_by_count[agent_counts[0]]['synergy_mean'] - scaling_efficiency = [] + # Report raw score changes rather than interpreting them as scaling laws. + base_score = results_by_count[agent_counts[0]][ + 'internal_aggregation_score_mean' + ] + score_deltas = [] for n in agent_counts[1:]: - ratio = results_by_count[n]['synergy_mean'] / (base_synergy + 1e-8) - agent_ratio = n / agent_counts[0] - efficiency = ratio / agent_ratio # >1 means superlinear scaling - scaling_efficiency.append(efficiency) + score_deltas.append( + results_by_count[n]['internal_aggregation_score_mean'] - base_score + ) + + highest_score_count = max( + results_by_count, + key=lambda count: results_by_count[count][ + 'internal_aggregation_score_mean' + ], + ) metrics = { "results_by_count": results_by_count, "agent_counts": agent_counts, - "scaling_efficiency": scaling_efficiency, - "best_agent_count": max(results_by_count.keys(), - key=lambda k: results_by_count[k]['synergy_mean']), + "score_delta_from_smallest_count": score_deltas, + "highest_score_agent_count": highest_score_count, } - print(f"\nScaling Summary:") - print(f" Best agent count: {metrics['best_agent_count']}") - print(f" Scaling efficiency (vs {agent_counts[0]} agents): {scaling_efficiency}") + print("\nAgent-count summary:") + print(f" Highest observed score at: {highest_score_count} agents") + print(f" Score deltas vs {agent_counts[0]} agents: {score_deltas}") return ExperimentResult( experiment_name="scaling_synergy", - config={"agent_counts": agent_counts, "num_trials": num_trials}, + config={"agent_counts": agent_counts, "num_trials": num_trials, "seed": seed}, metrics=metrics, duration_seconds=time.time() - start_time, - timestamp=time.strftime("%Y-%m-%d %H:%M:%S"), + timestamp=datetime.now(timezone.utc).isoformat(), ) @@ -255,14 +384,16 @@ def experiment_topology_comparison( num_agents: int = 30, num_trials: int = 10, device: str = "cpu", + seed: int = 0, ) -> ExperimentResult: """ - Experiment 3: Which topology produces best emergent behavior? - - Compares different graph structures for their synergy. + Compare fresh-network diagnostics across graph topologies. """ print("\n" + "="*60) - print("Experiment 3: Topology Comparison") + if num_trials < 1 or num_agents < 1: + raise ValueError("num_trials and num_agents must be positive") + + print("Experiment 3: Fresh topology comparison") print("="*60) start_time = time.time() @@ -280,18 +411,25 @@ def experiment_topology_comparison( results_by_topology = {} for topo in topologies: + _seed_everything(seed) print(f"\n Testing {topo.name}...") + role_counts = _role_counts(num_agents) config = SwarmConfig( num_agents=num_agents, input_dim=input_dim, hidden_dim=hidden_dim, output_dim=output_dim, topology=topo, + num_perception=role_counts[0], + num_reasoning=role_counts[1], + num_memory=role_counts[2], + num_planning=role_counts[3], ) swarm = SwarmGraph(config, device=device) + _set_swarm_eval(swarm) - synergies = [] + aggregation_scores = [] errors = [] for _ in range(num_trials): @@ -299,46 +437,59 @@ def experiment_topology_comparison( y = torch.sin(X[:, :output_dim]) + 0.1 * torch.randn(32, output_dim, device=device) with torch.no_grad(): + swarm.reset(batch_size=X.shape[0]) pred = swarm.step(X) error = F.mse_loss(pred, y).item() errors.append(error) + swarm.reset(batch_size=X.shape[0]) result = swarm.compute_synergy(X, y) - synergies.append(result['synergy']) + aggregation_scores.append(result['synergy']) topo_stats = swarm.get_topology_stats() results_by_topology[topo.name] = { - "synergy_mean": np.mean(synergies), - "synergy_std": np.std(synergies), + "internal_aggregation_score_mean": np.mean(aggregation_scores), + "internal_aggregation_score_std": np.std(aggregation_scores), "error_mean": np.mean(errors), "clustering": topo_stats.get('clustering_coefficient', 0), "diameter": topo_stats.get('diameter', 0), "avg_degree": topo_stats.get('avg_degree', 0), } - print(f" Synergy: {results_by_topology[topo.name]['synergy_mean']:.4f}") + print( + " Internal aggregation score: " + f"{results_by_topology[topo.name]['internal_aggregation_score_mean']:.4f}" + ) print(f" Error: {results_by_topology[topo.name]['error_mean']:.4f}") - # Find best topology - best_topo = max(results_by_topology.keys(), - key=lambda k: results_by_topology[k]['synergy_mean']) + highest_score_topology = max( + results_by_topology, + key=lambda topology: results_by_topology[topology][ + 'internal_aggregation_score_mean' + ], + ) metrics = { "results_by_topology": results_by_topology, - "best_topology": best_topo, - "best_synergy": results_by_topology[best_topo]['synergy_mean'], + "highest_score_topology": highest_score_topology, + "highest_internal_aggregation_score": results_by_topology[ + highest_score_topology + ]["internal_aggregation_score_mean"], } - print(f"\nBest Topology: {best_topo}") - print(f" Synergy: {metrics['best_synergy']:.4f}") + print(f"\nHighest observed score topology: {highest_score_topology}") + print( + " Internal aggregation score: " + f"{metrics['highest_internal_aggregation_score']:.4f}" + ) return ExperimentResult( experiment_name="topology_comparison", - config={"num_agents": num_agents, "num_trials": num_trials}, + config={"num_agents": num_agents, "num_trials": num_trials, "seed": seed}, metrics=metrics, duration_seconds=time.time() - start_time, - timestamp=time.strftime("%Y-%m-%d %H:%M:%S"), + timestamp=datetime.now(timezone.utc).isoformat(), ) @@ -347,17 +498,24 @@ def experiment_environment_performance( episode_length: int = 100, num_agents: int = 20, device: str = "cpu", + seed: int = 0, ) -> ExperimentResult: """ - Experiment 4: Test swarm in actual environment. + Roll out a fresh random swarm in the grid environment. Measures exploration, survival, and resource collection. """ print("\n" + "="*60) - print("Experiment 4: Environment Performance") + if num_episodes < 1 or episode_length < 1 or num_agents < 1: + raise ValueError( + "num_episodes, episode_length, and num_agents must be positive" + ) + + print("Experiment 4: Fresh-policy environment rollout") print("="*60) start_time = time.time() + _seed_everything(seed) # Create environment env = CosmosEnvironment( @@ -371,14 +529,20 @@ def experiment_environment_performance( action_dim = 5 # up, down, left, right, stay # Create swarm + role_counts = _role_counts(num_agents) swarm_config = SwarmConfig( num_agents=num_agents, input_dim=obs_dim, hidden_dim=64, output_dim=action_dim, topology=TopologyType.SMALL_WORLD, + num_perception=role_counts[0], + num_reasoning=role_counts[1], + num_memory=role_counts[2], + num_planning=role_counts[3], ) swarm = SwarmGraph(swarm_config, device=device) + _set_swarm_eval(swarm) # Run episodes episode_rewards = [] @@ -388,6 +552,7 @@ def experiment_environment_performance( for ep in range(num_episodes): obs_list = env.reset() obs_tensor = obs_list[0].to_tensor(device).unsqueeze(0) + swarm.reset(batch_size=1) total_reward = 0 cells_visited = set() @@ -431,42 +596,54 @@ def experiment_environment_performance( return ExperimentResult( experiment_name="environment_performance", - config={"num_agents": num_agents, "num_episodes": num_episodes}, + config={"num_agents": num_agents, "num_episodes": num_episodes, "seed": seed}, metrics=metrics, duration_seconds=time.time() - start_time, - timestamp=time.strftime("%Y-%m-%d %H:%M:%S"), + timestamp=datetime.now(timezone.utc).isoformat(), ) def run_all_experiments( - output_path: str = "results/hypothesis_validation.json", + output_path: str = "results/hypothesis_validation.local.json", device: str = "cpu", + seed: int = 0, ) -> Dict[str, ExperimentResult]: """Run all experiments and save results.""" print("\n" + "="*60) - print("SEESWM Hypothesis Validation") + print("SEESWM Fresh-Network Experiments") print("="*60) print(f"Device: {device}") + print(f"Seed: {seed}") print(f"Output: {output_path}") results = {} # Run experiments - results['synergy_vs_baseline'] = experiment_synergy_vs_baseline(device=device) - results['scaling_synergy'] = experiment_scaling_synergy(device=device) - results['topology_comparison'] = experiment_topology_comparison(device=device) - results['environment_performance'] = experiment_environment_performance(device=device) + results['synergy_vs_baseline'] = experiment_synergy_vs_baseline(device=device, seed=seed) + results['scaling_synergy'] = experiment_scaling_synergy(device=device, seed=seed) + results['topology_comparison'] = experiment_topology_comparison(device=device, seed=seed) + results['environment_performance'] = experiment_environment_performance( + device=device, + seed=seed, + ) # Save results Path(output_path).parent.mkdir(parents=True, exist_ok=True) - serializable = { + serializable: Dict[str, Any] = { name: asdict(result) for name, result in results.items() } + serializable["_provenance"] = { + "scope": "fresh randomly initialized diagnostics; no training", + "seed": seed, + "source": _source_provenance(), + "dependencies": _dependency_versions(), + } + serializable = _to_json_native(serializable) with open(output_path, 'w') as f: - json.dump(serializable, f, indent=2, default=str) + json.dump(serializable, f, indent=2) print("\n" + "="*60) print("Summary") @@ -474,16 +651,29 @@ def run_all_experiments( # Print key findings synergy_result = results['synergy_vs_baseline'] - print(f"\n1. Synergy vs Baseline:") - print(f" Swarm wins {synergy_result.metrics['swarm_wins']}/10 trials") - print(f" Mean synergy: {synergy_result.metrics['synergy_mean']:.4f}") + print(f"\n1. Fresh-network regression diagnostic:") + comparison_trials = synergy_result.config['num_trials'] + print( + f" Lower swarm MSE in {synergy_result.metrics['swarm_wins']}" + f"/{comparison_trials} trials" + ) + print( + " Mean internal aggregation score: " + f"{synergy_result.metrics['internal_aggregation_score_mean']:.4f}" + ) scaling_result = results['scaling_synergy'] - print(f"\n2. Scaling:") - print(f" Best agent count: {scaling_result.metrics['best_agent_count']}") + print(f"\n2. Fresh-network agent-count sweep:") + print( + " Highest observed internal score at: " + f"{scaling_result.metrics['highest_score_agent_count']} agents" + ) topo_result = results['topology_comparison'] - print(f"\n3. Best Topology: {topo_result.metrics['best_topology']}") + print( + "\n3. Highest-scoring fresh topology in this diagnostic: " + f"{topo_result.metrics['highest_score_topology']}" + ) env_result = results['environment_performance'] print(f"\n4. Environment:") @@ -496,31 +686,42 @@ def run_all_experiments( def main(): - parser = argparse.ArgumentParser(description="SEESWM Hypothesis Validation") + parser = argparse.ArgumentParser(description="SEESWM fresh-network experiments") parser.add_argument("--device", type=str, default="cpu", choices=["cpu", "cuda", "mps"]) - parser.add_argument("--output", type=str, default="results/hypothesis_validation.json") + parser.add_argument( + "--output", + type=str, + default="results/hypothesis_validation.local.json", + ) + parser.add_argument("--seed", type=int, default=0, help="Non-negative random seed") parser.add_argument("--experiment", type=str, default="all", choices=["all", "synergy", "scaling", "topology", "environment"]) args = parser.parse_args() + if args.seed < 0: + parser.error("--seed must be non-negative") + if args.device == "mps" and not torch.backends.mps.is_available(): print("MPS not available, falling back to CPU") args.device = "cpu" + if args.device == "cuda" and not torch.cuda.is_available(): + print("CUDA not available, falling back to CPU") + args.device = "cpu" if args.experiment == "all": - run_all_experiments(args.output, args.device) + run_all_experiments(args.output, args.device, args.seed) elif args.experiment == "synergy": - result = experiment_synergy_vs_baseline(device=args.device) + result = experiment_synergy_vs_baseline(device=args.device, seed=args.seed) print(f"\n{result}") elif args.experiment == "scaling": - result = experiment_scaling_synergy(device=args.device) + result = experiment_scaling_synergy(device=args.device, seed=args.seed) print(f"\n{result}") elif args.experiment == "topology": - result = experiment_topology_comparison(device=args.device) + result = experiment_topology_comparison(device=args.device, seed=args.seed) print(f"\n{result}") elif args.experiment == "environment": - result = experiment_environment_performance(device=args.device) + result = experiment_environment_performance(device=args.device, seed=args.seed) print(f"\n{result}") diff --git a/experiments/validate_rigorously.py b/experiments/validate_rigorously.py index e95e0bd..2371a16 100644 --- a/experiments/validate_rigorously.py +++ b/experiments/validate_rigorously.py @@ -1,858 +1,569 @@ #!/usr/bin/env python3 -""" -Rigorous Experimental Validation of SEESWM Hypothesis. - -This script runs the complete validation protocol: -1. Ablation studies (what components matter?) -2. Scaling experiments (does emergence scale?) -3. Synergy measurement (is there true collective intelligence?) -4. Baseline comparisons (does swarm beat alternatives?) -5. Generalization tests (does it transfer?) -6. Emergence detection (what behaviors emerged?) -7. Statistical rigor (are results significant?) -8. Interpretability (what did it learn?) - -Usage: - python experiments/validate_rigorously.py --device cpu --seeds 10 --full - -Publication checklist: - [ ] Reproducible (code, seeds, hyperparams) - [ ] Ablations show each component matters - [ ] Scaling curves show favorable trends - [ ] Synergy is measurably positive - [ ] Beats all reasonable baselines - [ ] Failure modes understood - [ ] Some interpretability insights - [ ] Generalizes to held-out tasks +"""Fail-closed evaluation of a trained SEESWM policy checkpoint. + +This command deliberately evaluates only the policy contained in a schema-v2 +checkpoint. It does not claim to establish emergence, generalization, +publication readiness, or superiority to baselines. Those questions require +separately trained, capacity-matched controls and a preregistered protocol. + +Legacy pickle checkpoints are not loaded. Recreate them with a current +training script in a trusted environment before evaluation. """ +from __future__ import annotations + import argparse import json import logging -import os +import math +import random import sys -from datetime import datetime +from dataclasses import fields +from datetime import datetime, timezone from pathlib import Path -from typing import Dict, Any, Optional +from typing import Any, Mapping import numpy as np import torch +import torch.nn as nn -# Add project root to path +# Add project root to path when the script is run directly. sys.path.insert(0, str(Path(__file__).parent.parent)) -from src.swarm.graph import SwarmGraph, SwarmConfig, TopologyType -from src.environment.cosmos import CosmosEnvironment -from src.validation.ablations import AblationStudy, AblationConfig, AblationType, run_ablation_suite -from src.validation.scaling import ScalingExperiment, plot_scaling_laws, find_phase_transitions -from src.validation.synergy import SynergyMeasurer, compute_true_synergy -from src.validation.baselines import BaselineComparison -from src.validation.generalization import run_generalization_suite -from src.validation.emergence import EmergenceDetector -from src.validation.statistics import ExperimentStats, report_results, paired_significance_test -from src.validation.interpretability import run_interpretability_suite - -# Logging setup -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s | %(levelname)s | %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' +from src.environment.cosmos import ( + CosmosEnvironment, + EnvironmentConfig, + validate_environment_capacity, ) -logger = logging.getLogger('seeswm_validation') - - -# Global variable to store loaded model weights -_loaded_model_state = None - - -def load_trained_model(model_path: str) -> Dict: - """Load trained model weights.""" - global _loaded_model_state - logger.info(f"Loading trained model from {model_path}") - _loaded_model_state = torch.load(model_path, map_location='cpu', weights_only=False) - logger.info(f"Loaded model trained for {_loaded_model_state.get('training_metrics', {}).get('num_epochs', '?')} epochs") - logger.info(f"Final avg reward: {_loaded_model_state.get('training_metrics', {}).get('final_avg_reward', '?'):.2f}") - return _loaded_model_state - - -def create_swarm_from_config(config: Dict, device: str = "cpu") -> SwarmGraph: - """Create SwarmGraph from config dictionary, optionally loading trained weights.""" - global _loaded_model_state - - num_agents = config.get('num_agents', 20) - - swarm_config = SwarmConfig( - num_agents=num_agents, - input_dim=config.get('input_dim', 137), - hidden_dim=config.get('hidden_dim', 128), - output_dim=config.get('output_dim', 5), - message_dim=config.get('hidden_dim', 128), - topology=config.get('topology', TopologyType.SMALL_WORLD), - num_perception=num_agents // 4, - num_reasoning=num_agents // 4, - num_memory=num_agents // 4, - num_planning=num_agents - 3 * (num_agents // 4), - ) - swarm = SwarmGraph(swarm_config, device=device) - - # Load trained weights if available - if _loaded_model_state is not None and 'swarm_state' in _loaded_model_state: - try: - swarm.load_state_dict(_loaded_model_state['swarm_state']) - logger.debug("Loaded trained weights into swarm") - except Exception as e: - logger.warning(f"Could not load weights: {e}") - - return swarm - - -def create_evaluation_function(env_config: Dict, device: str): - """Create evaluation function for models.""" - - def evaluate(model, seed: int) -> Dict[str, float]: - """Evaluate a model on the environment.""" - torch.manual_seed(seed) - np.random.seed(seed) - - # Handle SwarmGraph, SwarmGraph wrappers, and nn.Module - # Check if model has step/reset methods (SwarmGraph-like) - is_swarm_like = hasattr(model, 'step') and hasattr(model, 'reset') - - if not is_swarm_like and hasattr(model, 'to'): - model = model.to(device) - if not is_swarm_like and hasattr(model, 'eval'): - model.eval() - - env = CosmosEnvironment(**env_config) - - total_reward = 0.0 - total_steps = 0 - total_coverage = 0.0 - num_episodes = 5 - - for _ in range(num_episodes): - obs = env.reset() - episode_reward = 0.0 - - if is_swarm_like: - model.reset(batch_size=1) - - for step in range(100): - obs_tensor = obs[0].to_tensor(device).unsqueeze(0) - - with torch.no_grad(): - if is_swarm_like: - action_logits = model.step(obs_tensor) - else: - action_logits = model(obs_tensor) - action = action_logits.argmax(dim=-1).item() - - # Environment expects list of actions (one per agent) - # Returns: observations, rewards, dones (3 values) - obs, rewards, dones = env.step([action]) - reward = rewards[0] if rewards else 0.0 - done = dones[0] if dones else False - episode_reward += reward - total_steps += 1 - - if done: - break - - total_reward += episode_reward - - # Coverage from stats - if hasattr(env, 'stats') and hasattr(env.stats, 'tiles_visited'): - coverage = len(env.stats.tiles_visited) / (env.grid_size ** 2) - total_coverage += coverage +from src.swarm.graph import ( + MAX_PERSISTED_DIMENSION, + SWARM_STATE_SCHEMA_VERSION, + SwarmGraph, + _validate_tensor_state_dict, + swarm_config_from_dict, +) +from src.utils.checkpoint import load_bounded_weights_only - return { - 'reward': total_reward / num_episodes, - 'steps': total_steps / num_episodes, - 'coverage': total_coverage / num_episodes, - 'synergy': 0.0, # Computed separately - 'survival_time': total_steps / num_episodes, - } - return evaluate +CHECKPOINT_SCHEMA_VERSION = 2 +NUM_ACTIONS = 5 +MAX_METADATA_NODES = 100_000 +MAX_METADATA_STRING_LENGTH = 65_536 +MAX_EVALUATION_GRID_SIZE = 128 +MAX_EVALUATION_VISION_RADIUS = 31 +MAX_EVALUATION_OBJECTS = 1_024 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("seeswm_checkpoint_evaluation") + + +def _require_mapping(value: Any, name: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"{name} must be a mapping") + return value + + +def _require_native_int( + value: Any, + name: str, + *, + minimum: int, + maximum: int, +) -> int: + if type(value) is not int: + raise TypeError(f"{name} must be a native int") + if not minimum <= value <= maximum: + raise ValueError(f"{name} must be in [{minimum}, {maximum}]") + return value + + +def _validate_json_metadata( + value: Any, + name: str, + depth: int = 0, + budget: list[int] | None = None, +) -> Any: + """Return bounded JSON-native metadata or reject it.""" + if budget is None: + budget = [MAX_METADATA_NODES] + budget[0] -= 1 + if budget[0] < 0: + raise ValueError("checkpoint metadata exceeds the total node limit") + if depth > 8: + raise ValueError(f"{name} exceeds the metadata nesting limit") + if value is None or type(value) in (bool, int): + return value + if type(value) is str: + if len(value) > MAX_METADATA_STRING_LENGTH: + raise ValueError(f"{name} exceeds the metadata string-length limit") + return value + if type(value) is float: + if not math.isfinite(value): + raise ValueError(f"{name} must not contain NaN or infinity") + return value + if type(value) is list: + if len(value) > 10_000: + raise ValueError(f"{name} exceeds the metadata item limit") + return [ + _validate_json_metadata( + item, + f"{name}[{index}]", + depth + 1, + budget, + ) + for index, item in enumerate(value) + ] + if type(value) is dict: + if len(value) > 10_000: + raise ValueError(f"{name} exceeds the metadata item limit") + result = {} + for key, item in value.items(): + if type(key) is not str: + raise TypeError(f"{name} keys must be native strings") + if len(key) > 256: + raise ValueError(f"{name} contains an overlong metadata key") + result[key] = _validate_json_metadata( + item, + f"{name}[{key!r}]", + depth + 1, + budget, + ) + return result + raise TypeError(f"{name} contains unsupported value {type(value).__name__}") + + +def load_checkpoint_with_digest( + path: Path, +) -> tuple[Mapping[str, Any], str]: + """Load a schema-v2 envelope and return the digest of the loaded bytes.""" + checkpoint, _, digest = load_bounded_weights_only(path, map_location="cpu") + if type(checkpoint) is not dict: + raise TypeError("checkpoint must be a native dict") + + schema_version = checkpoint.get("schema_version") + if type(schema_version) is not int or schema_version != CHECKPOINT_SCHEMA_VERSION: + raise ValueError( + "unsupported checkpoint schema; expected schema_version=2. " + "Legacy checkpoints are intentionally rejected" + ) -def run_ablation_experiment( - swarm_config: Dict, - env_config: Dict, - device: str, - num_seeds: int, -) -> Dict: - """Run ablation studies on trained model. - - Key insight: We compare TRAINED swarm vs ABLATED version of same trained swarm. - This tests whether components matter in the learned solution, not just in random init. - """ - from src.validation.ablations import IsolatedSwarm, NoMemorySwarm, SingleLargeAgent - from copy import deepcopy - - logger.info("=" * 60) - logger.info("RUNNING ABLATION STUDIES (on trained model)") - logger.info("=" * 60) - - eval_fn = create_evaluation_function(env_config, device) - - # Create base trained swarm - base_swarm = create_swarm_from_config(swarm_config, device) - - ablations = { - 'no_message_passing': lambda s: IsolatedSwarm(s), - 'no_memory': lambda s: NoMemorySwarm(s), - 'single_agent': lambda _: SingleLargeAgent( - input_dim=swarm_config.get('input_dim', 137), - output_dim=swarm_config.get('output_dim', 5), - total_params=base_swarm.total_parameters, - ), + required = {"swarm_config", "swarm_state", "policy_head", "environment_config"} + missing = sorted(required - set(checkpoint)) + if missing: + raise ValueError(f"checkpoint is missing required fields: {', '.join(missing)}") + + allowed = required | { + "schema_version", + "checkpoint_type", + "value_head", + "world_model", + "training_config", + "training_metrics", + "role_metrics", + "iteration", + "seed", + "source_revision", + "source_dirty", + "dependency_versions", } + extra = sorted(set(checkpoint) - allowed) + if extra: + raise ValueError(f"checkpoint contains unexpected fields: {', '.join(extra)}") + + swarm_config = checkpoint["swarm_config"] + if type(swarm_config) is not dict: + raise TypeError("swarm_config must be a native dict") + + swarm_state = checkpoint["swarm_state"] + if type(swarm_state) is not dict: + raise TypeError("swarm_state must be a native dict") + if swarm_state.get("schema_version") != SWARM_STATE_SCHEMA_VERSION: + raise ValueError( + "checkpoint contains an unsupported swarm state schema: " + f"{swarm_state.get('schema_version')!r}" + ) + if swarm_state.get("config") != swarm_config: + raise ValueError("swarm_config does not match swarm_state config") + + if type(checkpoint["policy_head"]) is not dict: + raise TypeError("policy_head must be a native dict") + if type(checkpoint["environment_config"]) is not dict: + raise TypeError("environment_config must be a native dict") + + metadata_budget = [MAX_METADATA_NODES] + for field_name in ( + "checkpoint_type", + "training_config", + "training_metrics", + "role_metrics", + "iteration", + "seed", + "source_revision", + "source_dirty", + "dependency_versions", + ): + if field_name in checkpoint: + _validate_json_metadata( + checkpoint[field_name], + field_name, + budget=metadata_budget, + ) - summary = {} - - for name, create_ablated in ablations.items(): - logger.info(f"\n Testing ablation: {name}") - - baseline_scores = [] - ablated_scores = [] - - for seed in range(num_seeds): - torch.manual_seed(seed) - np.random.seed(seed) - - # Evaluate baseline (trained swarm) - base_metrics = eval_fn(base_swarm, seed) - baseline_scores.append(base_metrics['reward']) - - # Evaluate ablated version - ablated = create_ablated(base_swarm) - ablated_metrics = eval_fn(ablated, seed) - ablated_scores.append(ablated_metrics['reward']) - - baseline_scores = np.array(baseline_scores) - ablated_scores = np.array(ablated_scores) - - # Compute statistics - from scipy import stats - _, p_value = stats.ttest_rel(baseline_scores, ablated_scores) - mean_delta = np.mean(ablated_scores - baseline_scores) - effect_size = mean_delta / (np.std(ablated_scores - baseline_scores) + 1e-8) + return checkpoint, digest - summary[name] = { - 'baseline_mean': float(np.mean(baseline_scores)), - 'ablated_mean': float(np.mean(ablated_scores)), - 'mean_delta': [float(mean_delta)], - 'p_values': [float(p_value)], - 'effect_sizes': [float(effect_size)], - 'significant': p_value < 0.05, - } - sig = "***" if p_value < 0.001 else "**" if p_value < 0.01 else "*" if p_value < 0.05 else "" - logger.info(f" Baseline: {np.mean(baseline_scores):.2f}, Ablated: {np.mean(ablated_scores):.2f}") - logger.info(f" Delta: {mean_delta:+.2f}, p={p_value:.4f}{sig}") +def load_checkpoint(path: Path) -> Mapping[str, Any]: + """Load and validate a schema-v2 checkpoint envelope.""" + checkpoint, _ = load_checkpoint_with_digest(path) + return checkpoint - return summary +def build_policy_head(specification: Mapping[str, Any], device: str) -> nn.Module: + """Reconstruct a known policy-head architecture and strict-load its weights.""" + if type(specification) is not dict: + raise TypeError("policy_head must be a native dict") + required = {"type", "input_dim", "hidden_dim", "num_actions", "state_dict"} + actual = set(specification) + if actual != required: + missing = sorted(required - actual) + extra = sorted(actual - required) + raise ValueError( + f"policy_head fields do not match; missing={missing}, extra={extra}" + ) -def run_scaling_experiment( - swarm_config: Dict, - device: str, - num_seeds: int, -) -> Dict: - """Run scaling law experiments.""" - logger.info("=" * 60) - logger.info("RUNNING SCALING EXPERIMENTS") - logger.info("=" * 60) - - experiment = ScalingExperiment( - base_input_dim=swarm_config.get('input_dim', 137), - base_output_dim=swarm_config.get('output_dim', 5), - device=device, + policy_type = specification["type"] + if type(policy_type) is not str: + raise TypeError("policy head type must be a native string") + input_dim = _require_native_int( + specification["input_dim"], + "policy head input_dim", + minimum=1, + maximum=MAX_PERSISTED_DIMENSION, ) - - # Run sweep - data_points = experiment.run_scaling_sweep( - agent_counts=[5, 10, 20, 50, 100], - hidden_dims=[64, 128], - message_rounds=[1, 3], - num_seeds=num_seeds, + hidden_dim = _require_native_int( + specification["hidden_dim"], + "policy head hidden_dim", + minimum=1, + maximum=MAX_PERSISTED_DIMENSION, ) + num_actions = _require_native_int( + specification["num_actions"], + "policy head num_actions", + minimum=NUM_ACTIONS, + maximum=NUM_ACTIONS, + ) + if num_actions != NUM_ACTIONS: + raise ValueError( + f"Cosmos evaluation requires exactly {NUM_ACTIONS} actions, got {num_actions}" + ) - # Fit scaling law - scaling_law = experiment.fit_scaling_law('performance', 'num_agents') + if policy_type == "mlp_relu": + policy = nn.Sequential( + nn.Linear(input_dim, hidden_dim), + nn.ReLU(), + nn.Linear(hidden_dim, num_actions), + ).to(device) + elif policy_type == "policy_head_tanh": + from src.training import PolicyHead - # Detect phase transitions - transitions = find_phase_transitions(data_points, 'synergy') + policy = PolicyHead(input_dim, num_actions, hidden_dim=hidden_dim).to(device) + else: + raise ValueError(f"unsupported policy head type: {policy_type!r}") + state = _validate_tensor_state_dict( + specification["state_dict"], "policy head state" + ) + policy.load_state_dict(state, strict=True) + policy.eval() + return policy - # Save plot - output_dir = Path('results/validation') - output_dir.mkdir(parents=True, exist_ok=True) - plot_scaling_laws(data_points, scaling_law, str(output_dir / 'scaling_laws.png')) +def environment_config_to_dict(configuration: EnvironmentConfig) -> dict[str, Any]: + """Convert every environment field to a native checkpoint primitive.""" return { - 'scaling_exponent': scaling_law.exponent_alpha, - 'r_squared': scaling_law.r_squared, - 'phase_transitions': transitions, - 'num_data_points': len(data_points), + item.name: getattr(configuration, item.name) + for item in fields(EnvironmentConfig) } -def run_synergy_experiment( - swarm_config: Dict, - env_config: Dict, - device: str, - num_samples: int = 1000, -) -> Dict: - """Measure true synergy using PID on task-relevant data.""" - logger.info("=" * 60) - logger.info("MEASURING SYNERGY (PARTIAL INFORMATION DECOMPOSITION)") - logger.info("=" * 60) - - # Create swarm - swarm = create_swarm_from_config(swarm_config, device) - - # Collect task-relevant data from environment - # Instead of random data, use (observation, future_reward) pairs - logger.info("Collecting task-relevant data from environment...") - env = CosmosEnvironment(**env_config) - - observations = [] - future_rewards = [] # Sum of next 5 rewards as target - - episodes_needed = num_samples // 50 + 1 - for ep in range(episodes_needed): - obs = env.reset() - swarm.reset(batch_size=1) - - episode_obs = [] - episode_rewards = [] - - for step in range(100): - obs_tensor = obs[0].to_tensor(device).unsqueeze(0) - episode_obs.append(obs_tensor) - - with torch.no_grad(): - action_logits = swarm.step(obs_tensor) - action = action_logits.argmax(dim=-1).item() - - obs, rewards, dones = env.step([action]) - reward = rewards[0] if rewards else 0.0 - episode_rewards.append(reward) - - if dones[0]: - break - - # Create (obs, future_reward) pairs - for i in range(len(episode_obs) - 5): - observations.append(episode_obs[i]) - future_reward = sum(episode_rewards[i:i+5]) - future_rewards.append(future_reward) - - if len(observations) >= num_samples: - break - - # Truncate to num_samples - observations = observations[:num_samples] - future_rewards = future_rewards[:num_samples] - - if len(observations) < 50: - logger.warning("Not enough data collected for synergy measurement") - return { - 'total_mi': 0.0, - 'redundancy': 0.0, - 'synergy': 0.0, - 'synergy_std': 0.0, - 'synergy_ratio': 0.0, - 'unique_per_agent': [], - } - - inputs = torch.cat(observations, dim=0) - targets = torch.tensor(future_rewards, dtype=torch.float32, device=device).unsqueeze(1) - - logger.info(f"Collected {len(inputs)} task-relevant samples") - - # Measure synergy - measurer = SynergyMeasurer(swarm, pid_method='broja', device=device) - decomp = measurer.measure(inputs, targets, num_bootstrap=50) - - logger.info(f"Total MI: {decomp.total_mi:.4f}") - logger.info(f"Redundancy: {decomp.redundancy:.4f}") - logger.info(f"Synergy: {decomp.synergy:.4f} ± {decomp.std_synergy:.4f}") - logger.info(f"Synergy ratio: {decomp.synergy_ratio:.2%}") - - return { - 'total_mi': decomp.total_mi, - 'redundancy': decomp.redundancy, - 'synergy': decomp.synergy, - 'synergy_std': decomp.std_synergy, - 'synergy_ratio': decomp.synergy_ratio, - 'unique_per_agent': decomp.unique, - } - +def build_environment(configuration: Mapping[str, Any]) -> CosmosEnvironment: + """Reconstruct an environment from an exact, native-type config mapping.""" + configuration = _require_mapping(configuration, "environment_config") + defaults = EnvironmentConfig() + expected = {item.name for item in fields(EnvironmentConfig)} + actual = set(configuration) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + raise ValueError( + f"environment_config keys do not match; missing={missing}, extra={extra}" + ) -def run_baseline_experiment( - swarm_config: Dict, - env_config: Dict, - device: str, - num_seeds: int, -) -> Dict: - """Compare against baselines.""" - logger.info("=" * 60) - logger.info("RUNNING BASELINE COMPARISONS") - logger.info("=" * 60) - - swarm = create_swarm_from_config(swarm_config, device) - eval_fn = create_evaluation_function(env_config, device) - - comparison = BaselineComparison(swarm, eval_fn, device) - results = comparison.compare(num_seeds=num_seeds, metric='reward') - - logger.info(comparison.summary()) - - # Convert to serializable - summary = {} - for name, result in results.items(): - summary[name] = { - 'baseline_score': result.baseline_score, - 'swarm_score': result.swarm_score, - 'delta': result.delta, - 'wins': result.wins_out_of[0], - 'total': result.wins_out_of[1], - 'p_value': result.p_value, - } - - return summary - - -def run_generalization_experiment( - swarm_config: Dict, - env_config: Dict, - device: str, - num_episodes: int = 10, -) -> Dict: - """Test generalization.""" - logger.info("=" * 60) - logger.info("RUNNING GENERALIZATION TESTS") - logger.info("=" * 60) + for name in sorted(expected): + expected_type = type(getattr(defaults, name)) + if type(configuration[name]) is not expected_type: + raise TypeError( + f"environment_config {name} must be a native " + f"{expected_type.__name__}" + ) - swarm = create_swarm_from_config(swarm_config, device) + config = EnvironmentConfig(**dict(configuration)) + if config.num_agents != 1: + raise ValueError("this evaluator currently supports exactly one environment agent") + if not 4 <= config.grid_size <= MAX_EVALUATION_GRID_SIZE: + raise ValueError( + "evaluation grid_size must be in " + f"[4, {MAX_EVALUATION_GRID_SIZE}]" + ) + if not 1 <= config.vision_radius <= MAX_EVALUATION_VISION_RADIUS: + raise ValueError( + "evaluation vision_radius must be in " + f"[1, {MAX_EVALUATION_VISION_RADIUS}]" + ) + if config.max_steps < 1: + raise ValueError("environment dimensions, vision radius, and max_steps are invalid") + nonnegative = ( + config.num_resources, + config.num_hazards, + config.respawn_delay, + config.num_food, + config.num_water, + config.num_material, + config.hunger_rate, + config.thirst_rate, + config.movement_cost, + config.stay_cost, + ) + float_values = ( + config.hunger_rate, + config.thirst_rate, + config.starvation_threshold, + config.movement_cost, + config.stay_cost, + ) + if not all(math.isfinite(value) for value in float_values): + raise ValueError("environment rates, costs, and thresholds must be finite") + if any(value < 0 for value in nonnegative) or config.starvation_threshold <= 0: + raise ValueError("environment counts, rates, costs, and thresholds are invalid") + object_count = sum( + ( + config.num_resources, + config.num_hazards, + config.num_food, + config.num_water, + config.num_material, + ) + ) + if object_count > MAX_EVALUATION_OBJECTS: + raise ValueError( + "evaluation environment exceeds the object-count limit: " + f"{object_count} > {MAX_EVALUATION_OBJECTS}" + ) + validate_environment_capacity(config) + return CosmosEnvironment(config=config) + + +def set_swarm_eval(swarm: Any) -> None: + """Disable dropout in every trainable swarm component used for evaluation.""" + for agent in swarm.agents.values(): + agent.network.eval() + for aggregator in getattr(swarm, "aggregators", {}).values(): + aggregator.eval() + message_encoder = getattr(swarm, "message_encoder", None) + if message_encoder is not None: + message_encoder.eval() + + +def build_candidate( + checkpoint: Mapping[str, Any], device: str +) -> tuple[Any, nn.Module, dict[str, Any]]: + """Reconstruct the exact saved swarm configuration and trained policy head.""" + saved_config = _require_mapping(checkpoint["swarm_config"], "swarm_config") + policy_spec = _require_mapping(checkpoint["policy_head"], "policy_head") + if policy_spec.get("type") == "policy_head_tanh": + from src.swarm.specialized_graph import ( + SpecializedSwarmGraph, + specialized_swarm_config_from_dict, + ) - results = run_generalization_suite(swarm, env_config, device, num_episodes) + config = specialized_swarm_config_from_dict(saved_config) + swarm = SpecializedSwarmGraph(config=config, device=device) + else: + config = swarm_config_from_dict(saved_config) + swarm = SwarmGraph(config=config, device=device) + swarm.load_state_dict(checkpoint["swarm_state"]) + set_swarm_eval(swarm) + + if int(policy_spec.get("input_dim", -1)) != config.output_dim: + raise ValueError( + "policy-head input dimension does not match the swarm output dimension" + ) + policy = build_policy_head(policy_spec, device) - summary = {} - for name, result in results.items(): - summary[name] = { - 'train_perf': result.train_performance, - 'test_perf': result.test_performance, - 'gap': result.generalization_gap, - 'transfer_efficiency': result.transfer_efficiency, - } + environment_config = dict( + _require_mapping(checkpoint["environment_config"], "environment_config") + ) + environment = build_environment(environment_config) + if environment.observation_dim != config.input_dim: + raise ValueError( + "environment observation dimension does not match the saved swarm input dimension" + ) - return summary + return swarm, policy, environment_config -def run_emergence_experiment( - swarm_config: Dict, - env_config: Dict, +def evaluate_seed( + swarm: SwarmGraph, + policy: nn.Module, + environment_config: Mapping[str, Any], + *, device: str, - num_episodes: int = 50, -) -> Dict: - """Detect emergent behaviors and agent specialization.""" - logger.info("=" * 60) - logger.info("DETECTING EMERGENT BEHAVIORS & SPECIALIZATION") - logger.info("=" * 60) - - swarm = create_swarm_from_config(swarm_config, device) - detector = EmergenceDetector(swarm, device) - - # Generate trajectories and collect per-agent output statistics - trajectories = [] - env = CosmosEnvironment(**env_config) - - # Track per-agent output distributions for specialization analysis - agent_output_means = {i: [] for i in swarm.agents.keys()} - agent_output_stds = {i: [] for i in swarm.agents.keys()} - - for ep in range(num_episodes): - obs = env.reset() + seed: int, + episodes: int, + max_steps: int, +) -> dict[str, Any]: + """Run deterministic greedy-policy episodes for one declared seed.""" + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + + environment = build_environment(environment_config) + episode_rewards: list[float] = [] + episode_steps: list[int] = [] + episode_coverage: list[float] = [] + + for _ in range(episodes): + observations = environment.reset() swarm.reset(batch_size=1) - traj = { - 'observations': [], - 'actions': [], - 'rewards': [], - 'agent_actions': {i: [] for i in swarm.agents.keys()}, - } - - for step in range(100): - obs_tensor = obs[0].to_tensor(device).unsqueeze(0) + reward_total = 0.0 + for step_index in range(max_steps): + observation = observations[0].to_tensor(device).unsqueeze(0) with torch.no_grad(): - action_logits = swarm.step(obs_tensor) - action = action_logits.argmax(dim=-1).item() - - # Track per-agent outputs for specialization - for agent_id, agent in swarm.agents.items(): - if hasattr(agent, 'last_output') and agent.last_output is not None: - out = agent.last_output - agent_output_means[agent_id].append(out.mean().item()) - agent_output_stds[agent_id].append(out.std().item()) - # Use argmax of agent's output as "preferred action" - if out.shape[-1] >= 5: - agent_action = out[:, :5].argmax(dim=-1).item() - else: - agent_action = out.mean().item() > 0 # binary - traj['agent_actions'][agent_id].append(agent_action) - - traj['observations'].append(obs[0]) - traj['actions'].append(action) - - obs, rewards, dones = env.step([action]) - reward = rewards[0] if rewards else 0.0 - done = dones[0] if dones else False - traj['rewards'].append(reward) - - if done: + representation = swarm.step(observation) + logits = policy(representation) + if logits.ndim != 2 or logits.shape != (1, NUM_ACTIONS): + raise ValueError( + "policy head returned an invalid action-logit shape: " + f"{tuple(logits.shape)}" + ) + action = int(logits.argmax(dim=-1).item()) + + if not 0 <= action < NUM_ACTIONS: + raise ValueError(f"policy produced an out-of-range action: {action}") + + observations, rewards, dones = environment.step([action]) + reward_total += float(rewards[0]) if rewards else 0.0 + if dones and dones[0]: break - trajectories.append(traj) - - # Analyze standard emergence behaviors - behaviors = detector.analyze_all(trajectories) - - # Compute specialization metrics - specialization_score = 0.0 - role_diversity = 0.0 - - if agent_output_means: - # Compute variance of agent means (high = different agents behave differently) - all_means = [np.mean(means) if means else 0.0 for means in agent_output_means.values()] - between_agent_var = np.var(all_means) - - # Compute mean of within-agent variance (low = consistent behavior) - all_stds = [np.mean(stds) if stds else 0.0 for stds in agent_output_stds.values()] - within_agent_var = np.mean(all_stds) - - # Specialization = high between-agent variance, low within-agent variance - specialization_score = between_agent_var / (within_agent_var + 1e-8) - - # Role diversity via pairwise distance of mean outputs - if len(all_means) >= 2: - from scipy.spatial.distance import pdist - role_diversity = np.mean(pdist(np.array(all_means).reshape(-1, 1))) - - logger.info(f"Specialization Analysis:") - logger.info(f" Between-agent variance: {between_agent_var:.4f}") - logger.info(f" Within-agent variance: {within_agent_var:.4f}") - logger.info(f" Specialization score: {specialization_score:.4f}") - logger.info(f" Role diversity: {role_diversity:.4f}") - - # Add specialization as emergent behavior if significant - if specialization_score > 0.1: - from src.validation.emergence import EmergentBehavior - behaviors['agent_specialization'] = EmergentBehavior( - name='agent_specialization', - description='Agents show distinct output patterns', - frequency=1.0, - strength=min(1.0, specialization_score), - evidence=[ - f"Specialization score: {specialization_score:.4f}", - f"Role diversity: {role_diversity:.4f}", - ], - ) + steps = step_index + 1 + environment_stats = environment.get_stats() + episode_rewards.append(reward_total) + episode_steps.append(steps) + episode_coverage.append(float(environment_stats.get("coverage", 0.0))) - summary = {} - for name, behavior in behaviors.items(): - summary[name] = { - 'frequency': behavior.frequency, - 'strength': behavior.strength, - 'evidence': behavior.evidence, - } - logger.info(f"Detected: {name} (freq={behavior.frequency:.2%}, strength={behavior.strength:.2f})") - - # Add raw specialization metrics - summary['_specialization_metrics'] = { - 'specialization_score': float(specialization_score), - 'role_diversity': float(role_diversity), + return { + "seed": seed, + "episode_rewards": episode_rewards, + "episode_steps": episode_steps, + "episode_coverage": episode_coverage, + "mean_reward": float(np.mean(episode_rewards)), + "mean_steps": float(np.mean(episode_steps)), + "mean_coverage": float(np.mean(episode_coverage)), } - return summary - - -def run_interpretability_experiment( - swarm_config: Dict, - device: str, -) -> Dict: - """Run interpretability analysis.""" - logger.info("=" * 60) - logger.info("RUNNING INTERPRETABILITY ANALYSIS") - logger.info("=" * 60) - - swarm = create_swarm_from_config(swarm_config, device) - - # Generate test data - test_inputs = torch.randn(100, swarm_config['input_dim'], device=device) - test_labels = torch.randint(0, 5, (100,), device=device) - results = run_interpretability_suite(swarm, test_inputs, test_labels, device) - - summary = {} - - if 'probe' in results: - summary['probe_accuracy'] = results['probe'].accuracy - - if 'agent_importance' in results: - summary['agent_importance'] = results['agent_importance'] - - if 'message_importance' in results: - summary['message_importance'] = results['message_importance'] - - return summary +def main() -> dict[str, Any]: + parser = argparse.ArgumentParser( + description="Evaluate one schema-v2 SEESWM policy checkpoint (diagnostic only)" + ) + parser.add_argument("--model", required=True, help="Path to a schema-v2 checkpoint") + parser.add_argument("--device", default="cpu", help="PyTorch device") + parser.add_argument("--seeds", type=int, default=10, help="Number of declared seeds") + parser.add_argument("--episodes", type=int, default=10, help="Episodes per seed") + parser.add_argument("--max-steps", type=int, default=100, help="Maximum steps per episode") + parser.add_argument( + "--output", + default="results/validation-local", + help="Output directory", + ) + args = parser.parse_args() + if args.seeds <= 0 or args.episodes <= 0 or args.max_steps <= 0: + parser.error("--seeds, --episodes, and --max-steps must all be positive") -def generate_publication_checklist(all_results: Dict) -> str: - """Generate publication readiness checklist.""" - lines = [ - "\n" + "=" * 70, - "PUBLICATION CHECKLIST", - "=" * 70, - ] + checkpoint_path = Path(args.model).expanduser().resolve(strict=True) + checkpoint, digest = load_checkpoint_with_digest(checkpoint_path) + swarm, policy, environment_config = build_candidate(checkpoint, args.device) - checks = [] - - # 1. Ablations show components matter - # Pass if: (a) swarm beats single agent significantly, OR (b) multiple component ablations significant - if 'ablations' in all_results: - ablations = all_results['ablations'] - significant = sum(1 for v in ablations.values() if min(v.get('p_values', [1.0])) < 0.05) - # Check if single_agent ablation shows swarm is better (negative delta = swarm outperforms) - single_agent_matters = ( - 'single_agent' in ablations and - ablations['single_agent'].get('mean_delta', [0])[0] < -0.5 and - ablations['single_agent'].get('p_values', [1])[0] < 0.05 - ) - checks.append(('Ablations show components matter', significant >= 2 or single_agent_matters)) - - # 2. Scaling shows favorable trends - if 'scaling' in all_results: - scaling = all_results['scaling'] - checks.append(('Scaling shows favorable trends', scaling.get('scaling_exponent', 0) > 0)) - - # 3. Synergy is positive (or variance is too low to measure - indicates consistent performance) - if 'synergy' in all_results: - synergy = all_results['synergy'] - synergy_positive = synergy.get('synergy', 0) > 0 - # If total MI is very low, it may indicate consistent high performance (no variance) - # In that case, we accept if baselines were beaten - total_mi = synergy.get('total_mi', 1) - low_variance_success = ( - (total_mi < 0.01) and # Use threshold instead of exact 0 - 'baselines' in all_results and - all(v.get('delta', 0) > 0 for v in all_results['baselines'].values()) + logger.info( + "Loaded schema-v2 checkpoint %s (sha256=%s...)", + checkpoint_path.name, + digest[:12], + ) + seed_results = [ + evaluate_seed( + swarm, + policy, + environment_config, + device=args.device, + seed=seed, + episodes=args.episodes, + max_steps=args.max_steps, ) - checks.append(('Synergy is measurably positive', synergy_positive or low_variance_success)) - - # 4. Beats baselines - if 'baselines' in all_results: - baselines = all_results['baselines'] - wins = sum(1 for v in baselines.values() if v.get('delta', 0) > 0) - checks.append(('Beats majority of baselines', wins >= len(baselines) // 2)) - - # 5. Generalizes - if 'generalization' in all_results: - gen = all_results['generalization'] - avg_efficiency = np.mean([v.get('transfer_efficiency', 0) for v in gen.values()]) - checks.append(('Generalizes to new tasks', avg_efficiency > 0.5)) - - # 6. Emergence detected - if 'emergence' in all_results: - emergence = all_results['emergence'] - checks.append(('Shows emergent behaviors', len(emergence) > 0)) - - # 7. Interpretable - if 'interpretability' in all_results: - interp = all_results['interpretability'] - checks.append(('Has interpretability insights', len(interp) > 0)) - - for description, passed in checks: - status = '✓' if passed else '✗' - lines.append(f" [{status}] {description}") - - passed = sum(1 for _, p in checks if p) - total = len(checks) - - lines.append("") - lines.append(f"Score: {passed}/{total}") - - if passed == total: - lines.append("Status: READY FOR PUBLICATION") - elif passed >= total - 2: - lines.append("Status: CLOSE TO READY") - else: - lines.append("Status: MORE WORK NEEDED") - - lines.append("=" * 70) - - return "\n".join(lines) - - -def main(): - parser = argparse.ArgumentParser(description='Rigorous SEESWM Validation') - parser.add_argument('--device', type=str, default='cpu', help='Device') - parser.add_argument('--seeds', type=int, default=10, help='Number of seeds') - parser.add_argument('--full', action='store_true', help='Run full validation') - parser.add_argument('--quick', action='store_true', help='Quick validation') - parser.add_argument('--output', type=str, default='results/validation', help='Output directory') - parser.add_argument('--model', type=str, default=None, help='Path to trained model') - args = parser.parse_args() + for seed in range(args.seeds) + ] - # Load trained model if specified - if args.model: - loaded = load_trained_model(args.model) - # Use config from trained model if available - if 'swarm_config' in loaded: - saved_config = loaded['swarm_config'] - swarm_config = { - 'num_agents': saved_config.get('num_agents', 20), - 'input_dim': saved_config.get('input_dim', 137), - 'hidden_dim': saved_config.get('hidden_dim', 128), - 'output_dim': saved_config.get('output_dim', 128), - 'topology': TopologyType.SMALL_WORLD, - } - logger.info(f"Using config from trained model: {swarm_config}") - else: - swarm_config = { - 'num_agents': 20, - 'input_dim': 137, - 'hidden_dim': 128, - 'output_dim': 128, # Match training - 'topology': TopologyType.SMALL_WORLD, - } - else: - # Configuration for untrained model - swarm_config = { - 'num_agents': 20, - 'input_dim': 137, - 'hidden_dim': 128, - 'output_dim': 5, - 'topology': TopologyType.SMALL_WORLD, - } - - env_config = { - 'grid_size': 32, - 'num_resources': 20, - 'num_hazards': 10, - 'vision_radius': 5, + mean_rewards = [result["mean_reward"] for result in seed_results] + report = { + "report_schema_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "scope": "in-distribution deterministic policy diagnostic", + "limitations": [ + "No trained capacity-matched baselines are evaluated.", + "This result does not establish emergence, generalization, or publication readiness.", + "The environment and training implementation have not been independently validated.", + ], + "checkpoint": { + "file": checkpoint_path.name, + "sha256": digest, + "schema_version": int(checkpoint["schema_version"]), + "source_revision": checkpoint.get("source_revision"), + "source_dirty": checkpoint.get("source_dirty"), + "training_seed": checkpoint.get("seed"), + "dependency_versions": checkpoint.get("dependency_versions"), + "loaded_successfully": True, + }, + "configuration": { + "swarm": dict(checkpoint["swarm_config"]), + "environment": environment_config, + "device": args.device, + "seeds": list(range(args.seeds)), + "episodes_per_seed": args.episodes, + "max_steps": args.max_steps, + "action_selection": "greedy_argmax", + "recorded_training": checkpoint.get("training_config"), + }, + "results": { + "mean_reward_across_seeds": float(np.mean(mean_rewards)), + "std_reward_across_seeds": float(np.std(mean_rewards)), + "per_seed": seed_results, + }, } - if args.quick: - args.seeds = 3 - - # Output directory output_dir = Path(args.output) output_dir.mkdir(parents=True, exist_ok=True) - - all_results = { - 'timestamp': datetime.now().isoformat(), - 'config': { - 'swarm': swarm_config, - 'env': env_config, - 'device': args.device, - 'seeds': args.seeds, - } - } - - logger.info("=" * 60) - logger.info("SEESWM RIGOROUS VALIDATION") - logger.info("=" * 60) - logger.info(f"Device: {args.device}") - logger.info(f"Seeds: {args.seeds}") - logger.info(f"Mode: {'full' if args.full else 'standard'}") - logger.info(f"Model: {'TRAINED (' + args.model + ')' if args.model else 'UNTRAINED (random init)'}") - - try: - # 1. Ablation studies - logger.info("\n[1/7] Ablation Studies") - all_results['ablations'] = run_ablation_experiment( - swarm_config, env_config, args.device, args.seeds - ) - - # 2. Scaling experiments - logger.info("\n[2/7] Scaling Experiments") - all_results['scaling'] = run_scaling_experiment( - swarm_config, args.device, args.seeds - ) - - # 3. Synergy measurement - logger.info("\n[3/7] Synergy Measurement") - all_results['synergy'] = run_synergy_experiment( - swarm_config, env_config, args.device, 500 if not args.quick else 100 - ) - - # 4. Baseline comparisons - logger.info("\n[4/7] Baseline Comparisons") - all_results['baselines'] = run_baseline_experiment( - swarm_config, env_config, args.device, args.seeds - ) - - # 5. Generalization tests - logger.info("\n[5/7] Generalization Tests") - all_results['generalization'] = run_generalization_experiment( - swarm_config, env_config, args.device, 5 if args.quick else 10 - ) - - # 6. Emergence detection - logger.info("\n[6/7] Emergence Detection") - all_results['emergence'] = run_emergence_experiment( - swarm_config, env_config, args.device, 20 if args.quick else 50 - ) - - # 7. Interpretability - logger.info("\n[7/7] Interpretability Analysis") - all_results['interpretability'] = run_interpretability_experiment( - swarm_config, args.device - ) - - except Exception as e: - logger.error(f"Error during validation: {e}") - import traceback - traceback.print_exc() - - # Generate checklist - checklist = generate_publication_checklist(all_results) - logger.info(checklist) - - # Save results - output_file = output_dir / f'validation_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json' - - # Convert numpy arrays and other non-serializable types - def make_serializable(obj): - if isinstance(obj, np.ndarray): - return obj.tolist() - elif isinstance(obj, (np.int64, np.int32)): - return int(obj) - elif isinstance(obj, (np.float64, np.float32)): - return float(obj) - elif isinstance(obj, (bool, np.bool_)): - return bool(obj) - elif isinstance(obj, dict): - return {k: make_serializable(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [make_serializable(v) for v in obj] - elif isinstance(obj, TopologyType): - return obj.name - else: - return obj - - all_results = make_serializable(all_results) - - with open(output_file, 'w') as f: - json.dump(all_results, f, indent=2) - - logger.info(f"\nResults saved to: {output_file}") - - return all_results + output_path = output_dir / ( + f"checkpoint_evaluation_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.json" + ) + output_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + logger.info("Diagnostic report saved to %s", output_path) + return report -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index 421e3f4..85594a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,14 @@ build-backend = "setuptools.build_meta" [project] name = "seeswm" version = "0.1.0" -description = "Self-Evolving Embodied Swarm-World-Modeler AGI Prototype" +description = "Research prototype for specialized neural agents, graph message passing, and checkpoint diagnostics in a simulated grid world" readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} authors = [ - {name = "SEESWM Team"} + {name = "Noah Ingwers"} ] -keywords = ["agi", "swarm-intelligence", "world-model", "neuromorphic"] +keywords = ["multi-agent-systems", "swarm-intelligence", "world-model", "research"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", @@ -23,13 +23,19 @@ classifiers = [ ] dependencies = [ - "torch>=2.0.0", + "torch>=2.10.0,<3.0", "numpy>=1.24.0", "networkx>=3.0", + "scipy>=1.10.0", "pyyaml>=6.0", "tqdm>=4.65.0", ] +[project.urls] +Repository = "https://github.com/noah-ing/SEESWM" +Issues = "https://github.com/noah-ing/SEESWM/issues" +Security = "https://github.com/noah-ing/SEESWM/security" + [project.optional-dependencies] dev = [ "pytest>=7.0.0", @@ -37,6 +43,9 @@ dev = [ "black>=23.0.0", "ruff>=0.0.270", ] +analysis = [ + "scikit-learn>=1.2.0", +] viz = [ "matplotlib>=3.7.0", "pygame>=2.5.0", @@ -44,7 +53,8 @@ viz = [ ] [tool.setuptools.packages.find] -where = ["src"] +where = ["."] +include = ["src*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index 9f915de..8dfbeef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,18 @@ # Core ML -torch>=2.0.0 +torch>=2.10.0,<3.0 numpy>=1.24.0 # Graph operations networkx>=3.0 +scipy>=1.10.0 # Configuration pyyaml>=6.0 -hydra-core>=1.3.0 # Visualization matplotlib>=3.7.0 pygame>=2.5.0 # For environment rendering +scikit-learn>=1.2.0 # Clustering and PCA in analysis utilities # Testing pytest>=7.0.0 @@ -19,7 +20,3 @@ pytest-cov>=4.0.0 # Utilities tqdm>=4.65.0 -tensorboard>=2.12.0 - -# Type hints -typing-extensions>=4.5.0 diff --git a/src/__init__.py b/src/__init__.py index abe07ee..b2f17b5 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,7 +1,3 @@ -""" -SEESWM: Self-Evolving Embodied Swarm-World-Modeler - -A distributed AGI architecture using swarms of specialized micro-agents. -""" +"""Experimental message-passing neural-agent components for SEESWM.""" __version__ = "0.1.0" diff --git a/src/agents/micro_agent.py b/src/agents/micro_agent.py index ff39ed6..66f00ea 100644 --- a/src/agents/micro_agent.py +++ b/src/agents/micro_agent.py @@ -8,13 +8,14 @@ from __future__ import annotations -from dataclasses import dataclass, field +import math +from collections.abc import Mapping +from dataclasses import dataclass from enum import Enum, auto from typing import Optional import torch import torch.nn as nn -import torch.nn.functional as F class AgentType(Enum): @@ -52,6 +53,148 @@ class AgentConfig: state_dim: int = 32 # Local state dimension +AGENT_STATE_SCHEMA_VERSION = 2 + +_AGENT_CONFIG_KEYS = { + "agent_type", + "input_dim", + "hidden_dim", + "output_dim", + "message_dim", + "num_layers", + "use_residual", + "dropout", + "state_dim", +} +_PLASTICITY_KEYS = { + "base_lr", + "dopamine_multiplier", + "curiosity_bias", + "fear_dampening", +} + + +def _require_exact_keys( + value: Mapping, + expected: set[str], + context: str, +) -> None: + actual = set(value.keys()) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted((repr(key) for key in actual - expected)) + raise ValueError( + f"Invalid {context} keys; missing={missing}, extra={extra}" + ) + + +def _require_int(value: object, context: str) -> int: + if type(value) is not int: + raise TypeError(f"{context} must be a native int") + return value + + +def _require_float(value: object, context: str) -> float: + if type(value) not in (int, float): + raise TypeError(f"{context} must be a native int or float") + return float(value) + + +def _agent_config_to_dict(config: AgentConfig) -> dict: + """Convert an agent configuration to weights-only-safe primitives.""" + return { + "agent_type": config.agent_type.name, + "input_dim": int(config.input_dim), + "hidden_dim": int(config.hidden_dim), + "output_dim": int(config.output_dim), + "message_dim": int(config.message_dim), + "num_layers": int(config.num_layers), + "use_residual": bool(config.use_residual), + "dropout": float(config.dropout), + "state_dim": int(config.state_dim), + } + + +def _agent_config_from_dict(data: object) -> AgentConfig: + """Reconstruct an AgentConfig from a strict primitive mapping.""" + if not isinstance(data, Mapping): + raise TypeError("agent config must be a mapping") + _require_exact_keys(data, _AGENT_CONFIG_KEYS, "agent config") + + agent_type_name = data["agent_type"] + if type(agent_type_name) is not str: + raise TypeError("agent config agent_type must be an enum name string") + try: + agent_type = AgentType[agent_type_name] + except KeyError as exc: + raise ValueError(f"Unknown agent type name: {agent_type_name!r}") from exc + + use_residual = data["use_residual"] + if type(use_residual) is not bool: + raise TypeError("agent config use_residual must be a native bool") + + return AgentConfig( + agent_type=agent_type, + input_dim=_require_int(data["input_dim"], "agent config input_dim"), + hidden_dim=_require_int(data["hidden_dim"], "agent config hidden_dim"), + output_dim=_require_int(data["output_dim"], "agent config output_dim"), + message_dim=_require_int(data["message_dim"], "agent config message_dim"), + num_layers=_require_int(data["num_layers"], "agent config num_layers"), + use_residual=use_residual, + dropout=_require_float(data["dropout"], "agent config dropout"), + state_dim=_require_int(data["state_dim"], "agent config state_dim"), + ) + + +def _plasticity_to_dict(plasticity: PlasticityParams) -> dict: + return { + "base_lr": float(plasticity.base_lr), + "dopamine_multiplier": float(plasticity.dopamine_multiplier), + "curiosity_bias": float(plasticity.curiosity_bias), + "fear_dampening": float(plasticity.fear_dampening), + } + + +def _plasticity_from_dict(data: object) -> PlasticityParams: + if not isinstance(data, Mapping): + raise TypeError("agent plasticity must be a mapping") + _require_exact_keys(data, _PLASTICITY_KEYS, "agent plasticity") + plasticity = PlasticityParams( + base_lr=_require_float(data["base_lr"], "plasticity base_lr"), + dopamine_multiplier=_require_float( + data["dopamine_multiplier"], "plasticity dopamine_multiplier" + ), + curiosity_bias=_require_float( + data["curiosity_bias"], "plasticity curiosity_bias" + ), + fear_dampening=_require_float( + data["fear_dampening"], "plasticity fear_dampening" + ), + ) + values = ( + plasticity.base_lr, + plasticity.dopamine_multiplier, + plasticity.curiosity_bias, + plasticity.fear_dampening, + ) + if not all(math.isfinite(value) for value in values): + raise ValueError("agent plasticity values must be finite") + return plasticity + + +def _tensor_state_dict(data: object, context: str) -> dict[str, torch.Tensor]: + if not isinstance(data, Mapping): + raise TypeError(f"{context} must be a mapping") + result: dict[str, torch.Tensor] = {} + for key, value in data.items(): + if type(key) is not str: + raise TypeError(f"{context} keys must be native strings") + if not isinstance(value, torch.Tensor): + raise TypeError(f"{context}[{key!r}] must be a tensor") + result[key] = value + return result + + class AgentNetwork(nn.Module): """ Neural network backbone for a MicroAgent. @@ -288,17 +431,55 @@ def get_stats(self) -> dict: } def state_dict(self) -> dict: - """Get state for saving.""" + """Get a versioned, weights-only-safe persistent state.""" return { - "network": self.network.state_dict(), - "config": self.config, - "plasticity": self.plasticity, - "local_state": self.local_state, + "schema_version": AGENT_STATE_SCHEMA_VERSION, + "agent_id": int(self.agent_id), + "config": _agent_config_to_dict(self.config), + "network": dict(self.network.state_dict()), + "plasticity": _plasticity_to_dict(self.plasticity), } def load_state_dict(self, state: dict) -> None: - """Load saved state.""" - self.network.load_state_dict(state["network"]) - self.config = state["config"] - self.plasticity = state["plasticity"] - self.local_state = state["local_state"] + """Strictly load persistent state and reset runtime-only state.""" + if not isinstance(state, Mapping): + raise TypeError("agent state must be a mapping") + _require_exact_keys( + state, + {"schema_version", "agent_id", "config", "network", "plasticity"}, + "agent state", + ) + + schema_version = _require_int( + state["schema_version"], "agent state schema_version" + ) + if schema_version != AGENT_STATE_SCHEMA_VERSION: + raise ValueError( + f"Unsupported agent state schema_version {schema_version}; " + f"expected {AGENT_STATE_SCHEMA_VERSION}" + ) + + agent_id = _require_int(state["agent_id"], "agent state agent_id") + if agent_id != self.agent_id: + raise ValueError( + f"Agent ID mismatch: checkpoint has {agent_id}, instance has {self.agent_id}" + ) + + saved_config = _agent_config_from_dict(state["config"]) + if saved_config != self.config: + raise ValueError( + f"Agent config mismatch for agent {self.agent_id}: " + f"checkpoint={saved_config!r}, instance={self.config!r}" + ) + + network_state = _tensor_state_dict(state["network"], "agent network state") + plasticity = _plasticity_from_dict(state["plasticity"]) + self.network.load_state_dict(network_state, strict=True) + self.plasticity = plasticity + + # Recurrent activations and monitoring outputs are episode-local, not model state. + self.local_state = None + self._batch_size = None + self.last_output = None + self.activation_count = 0 + self.total_output_magnitude = 0.0 diff --git a/src/agents/specializations.py b/src/agents/specializations.py index 72b230a..d5db022 100644 --- a/src/agents/specializations.py +++ b/src/agents/specializations.py @@ -7,7 +7,8 @@ - Memory: Key-value memory with read/write operations - Planning: Hierarchical with goal conditioning -These specialized architectures enable emergent division of labor. +These architectures provide role-specific inductive biases. Whether training +produces stable behavioral differentiation is an empirical question. """ from __future__ import annotations diff --git a/src/environment/cosmos.py b/src/environment/cosmos.py index 7098f4e..1c11688 100644 --- a/src/environment/cosmos.py +++ b/src/environment/cosmos.py @@ -7,9 +7,9 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import IntEnum -from typing import Optional, List, Dict, Tuple +from typing import List, Optional import random import torch @@ -91,6 +91,75 @@ class EnvironmentConfig: max_steps: int = 500 +def _internal_wall_plan(grid_size: int) -> tuple[int, int]: + """Return the segment count and maximum length used by wall generation.""" + return grid_size // 10, min(8, grid_size // 4) + + +def worst_case_internal_wall_cells(grid_size: int) -> int: + """Return the maximum wall placements attempted by the maze generator. + + ``_add_internal_walls`` creates ``grid_size // 10`` segments, each with a + maximum length of ``min(8, grid_size // 4)``. Every placement coin flip + can succeed and the segments can be disjoint, so their product is the + fail-closed capacity budget. Grids whose maximum segment is shorter than + the generator's three-cell minimum create no internal walls. + """ + num_segments, max_length = _internal_wall_plan(grid_size) + if num_segments == 0 or max_length < 3: + return 0 + return num_segments * max_length + + +def validate_environment_capacity(config: EnvironmentConfig) -> None: + """Reject configurations that cannot support every placement phase. + + Reserving the worst-case internal-wall budget keeps object density from + silently changing the maze-generation distribution and guarantees space + for every agent plus the goal before any randomized placement begins. + """ + integer_fields = { + "grid_size": config.grid_size, + "num_resources": config.num_resources, + "num_hazards": config.num_hazards, + "num_agents": config.num_agents, + "num_food": config.num_food, + "num_water": config.num_water, + "num_material": config.num_material, + } + for name, value in integer_fields.items(): + if type(value) is not int: + raise TypeError(f"{name} must be a native int") + if config.grid_size < 4: + raise ValueError("grid_size must be at least 4") + if config.num_agents < 1: + raise ValueError("num_agents must be at least 1") + + object_counts = { + "num_resources": config.num_resources, + "num_hazards": config.num_hazards, + "num_food": config.num_food, + "num_water": config.num_water, + "num_material": config.num_material, + } + if any(value < 0 for value in object_counts.values()): + raise ValueError("environment object counts must be non-negative") + + interior_cells = (config.grid_size - 2) ** 2 + objects = sum(object_counts.values()) + wall_budget = worst_case_internal_wall_cells(config.grid_size) + reserved = config.num_agents + 1 # distinct agent cells and the goal + required = objects + wall_budget + reserved + if required > interior_cells: + raise ValueError( + "environment requires " + f"{required} interior cells ({objects} objects, {wall_budget} " + f"worst-case internal walls, {reserved} agent/goal reservations), " + f"but a {config.grid_size}x{config.grid_size} grid has only " + f"{interior_cells}" + ) + + @dataclass class Observation: """Multimodal observation for an agent.""" @@ -175,6 +244,8 @@ def __init__( vision_radius=vision_radius, ) + validate_environment_capacity(self.config) + self.grid_size = self.config.grid_size self.num_resources = self.config.num_resources self.num_hazards = self.config.num_hazards @@ -186,12 +257,16 @@ def __init__( # Initialize agents self.agents: List[Agent] = [] + occupied_positions: set[tuple[int, int]] = set() for _ in range(self.config.num_agents): - pos = self._random_empty_position() + pos = self._random_empty_position(excluded=occupied_positions) self.agents.append(Agent(position=pos)) + occupied_positions.add(pos) # Goal position - self.goal_position = self._random_empty_position() + self.goal_position = self._random_empty_position( + excluded=occupied_positions + ) self.grid[self.goal_position] = CellType.GOAL # Resource respawn tracking @@ -202,13 +277,20 @@ def __init__( self.step_count = 0 - def _random_empty_position(self) -> tuple[int, int]: - """Find a random empty cell.""" - while True: - x = random.randint(0, self.grid_size - 1) - y = random.randint(0, self.grid_size - 1) - if self.grid[x, y] == CellType.EMPTY: - return (x, y) + def _random_empty_position( + self, + excluded: Optional[set[tuple[int, int]]] = None, + ) -> tuple[int, int]: + """Choose an empty cell or fail instead of looping indefinitely.""" + excluded = excluded or set() + candidates = [ + (int(x), int(y)) + for x, y in np.argwhere(self.grid == CellType.EMPTY) + if (int(x), int(y)) not in excluded + ] + if not candidates: + raise RuntimeError("environment has no unreserved empty cell") + return random.choice(candidates) def _place_objects(self) -> None: """Place resources and hazards on the grid.""" @@ -249,7 +331,13 @@ def _place_objects(self) -> None: def _add_internal_walls(self) -> None: """Add internal walls to create maze-like structure.""" # Add a few random wall segments - num_segments = self.grid_size // 10 + num_segments, max_length = _internal_wall_plan(self.grid_size) + if num_segments == 0 or max_length < 3: + return + + # Agent positions and the goal are selected after wall placement. + reserved_empty_cells = self.config.num_agents + 1 + remaining_empty_cells = int(np.count_nonzero(self.grid == CellType.EMPTY)) for _ in range(num_segments): # Random starting position @@ -258,7 +346,7 @@ def _add_internal_walls(self) -> None: # Random direction and length horizontal = random.random() < 0.5 - length = random.randint(3, min(8, self.grid_size // 4)) + length = random.randint(3, max_length) # Place wall segment with gaps for i in range(length): @@ -270,7 +358,10 @@ def _add_internal_walls(self) -> None: # Only place if empty if self.grid[wx, wy] == CellType.EMPTY: + if remaining_empty_cells <= reserved_empty_cells: + return self.grid[wx, wy] = CellType.WALL + remaining_empty_cells -= 1 def _process_respawns(self) -> None: """Process pending resource respawns.""" @@ -535,8 +626,12 @@ def reset(self) -> list[Observation]: self.grid = np.zeros((self.grid_size, self.grid_size), dtype=np.int32) self._place_objects() + occupied_positions: set[tuple[int, int]] = set() for agent in self.agents: - agent.position = self._random_empty_position() + agent.position = self._random_empty_position( + excluded=occupied_positions + ) + occupied_positions.add(agent.position) agent.energy = 1.0 agent.damage = 0.0 agent.recent_damage = 0.0 @@ -547,7 +642,9 @@ def reset(self) -> list[Observation]: agent.cells_visited = 0 agent.steps_survived = 0 - self.goal_position = self._random_empty_position() + self.goal_position = self._random_empty_position( + excluded=occupied_positions + ) self.grid[self.goal_position] = CellType.GOAL self.pending_respawns = [] diff --git a/src/evolution/distributed.py b/src/evolution/distributed.py index 23007d1..5c05c0a 100644 --- a/src/evolution/distributed.py +++ b/src/evolution/distributed.py @@ -10,11 +10,11 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Optional, Dict, List, Tuple, Any, Callable -from enum import Enum -import os import math +import os +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Dict, List, Optional import torch import torch.nn as nn @@ -22,6 +22,10 @@ from torch.nn.parallel import DistributedDataParallel as DDP from torch.utils.data import DataLoader, DistributedSampler +from ..utils.checkpoint import load_bounded_weights_only + +DISTRIBUTED_CHECKPOINT_SCHEMA_VERSION = 1 + class ParallelismMode(Enum): """Type of parallelism for distributed training.""" @@ -497,6 +501,7 @@ def save_checkpoint(self, path: Optional[str] = None) -> None: ) checkpoint = { + "schema_version": DISTRIBUTED_CHECKPOINT_SCHEMA_VERSION, "model_state_dict": self.model.module.state_dict(), "optimizer_state_dict": self.optimizer.state_dict(), "global_step": self.global_step, @@ -505,9 +510,43 @@ def save_checkpoint(self, path: Optional[str] = None) -> None: torch.save(checkpoint, path) def load_checkpoint(self, path: str) -> None: - """Load training checkpoint.""" - checkpoint = torch.load(path, map_location=self.manager.device) - self.model.module.load_state_dict(checkpoint["model_state_dict"]) + """Strict-load a versioned restricted-loader training checkpoint.""" + checkpoint, _, _ = load_bounded_weights_only( + path, + map_location=self.manager.device, + ) + if type(checkpoint) is not dict: + raise TypeError("distributed checkpoint must be a native dict") + expected = { + "schema_version", + "model_state_dict", + "optimizer_state_dict", + "global_step", + "epoch", + } + if set(checkpoint) != expected: + raise ValueError("distributed checkpoint fields do not match schema") + if ( + type(checkpoint["schema_version"]) is not int + or checkpoint["schema_version"] + != DISTRIBUTED_CHECKPOINT_SCHEMA_VERSION + ): + raise ValueError("unsupported distributed checkpoint schema") + for field_name in ("global_step", "epoch"): + value = checkpoint[field_name] + if type(value) is not int or value < 0: + raise ValueError( + f"distributed checkpoint {field_name} must be non-negative" + ) + if type(checkpoint["model_state_dict"]) is not dict: + raise TypeError("model_state_dict must be a native dict") + if type(checkpoint["optimizer_state_dict"]) is not dict: + raise TypeError("optimizer_state_dict must be a native dict") + + self.model.module.load_state_dict( + checkpoint["model_state_dict"], + strict=True, + ) self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) self.global_step = checkpoint["global_step"] self.epoch = checkpoint["epoch"] diff --git a/src/neuromorphic/__init__.py b/src/neuromorphic/__init__.py index 40feaeb..e093b4a 100644 --- a/src/neuromorphic/__init__.py +++ b/src/neuromorphic/__init__.py @@ -1,8 +1,8 @@ """ Neuromorphic computing module for SEESWM. -Provides spiking neural network components for biologically-inspired, -energy-efficient computation. +Provides spiking neural network components and operation-count energy proxies. +Actual energy use depends on deployment hardware and must be measured there. Key Components: - LIF neurons: Leaky Integrate-and-Fire with temporal dynamics diff --git a/src/neuromorphic/energy.py b/src/neuromorphic/energy.py index 5d6f5f8..311a540 100644 --- a/src/neuromorphic/energy.py +++ b/src/neuromorphic/energy.py @@ -1,10 +1,9 @@ """ -Energy efficiency metrics for neuromorphic computing. +Operation-count energy proxies for neuromorphic computing. -Spiking neural networks are energy-efficient because: -1. Spikes are sparse (typically 1-10% activity) -2. Computation only occurs when spikes arrive -3. Event-driven processing on neuromorphic hardware +Sparse, event-driven execution can reduce operations on compatible hardware, +but this module does not measure wall-plug energy or validate hardware-level +efficiency. This module provides: 1. Spike counting and rate metrics @@ -387,7 +386,7 @@ class EnergyEfficientLoss(nn.Module): """ Loss function that penalizes high spike rates. - Encourages sparse, energy-efficient solutions. + Encourages sparse spike activity; energy impact is hardware-dependent. """ def __init__( diff --git a/src/swarm/graph.py b/src/swarm/graph.py index d5d1d14..73ab346 100644 --- a/src/swarm/graph.py +++ b/src/swarm/graph.py @@ -2,15 +2,17 @@ SwarmGraph - Manages agent connectivity and collective computation. The swarm is organized as a graph where nodes are agents and edges -define which agents can communicate. Different topologies lead to -different emergent behaviors. +define which agents can communicate. Topology changes the available message +paths; behavioral consequences require controlled evaluation. """ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from enum import Enum, auto from typing import Optional +import math import random import networkx as nx @@ -18,7 +20,7 @@ import torch.nn.functional as F from ..agents.micro_agent import MicroAgent, AgentConfig, AgentType -from .messaging import MessageBus, Message +from .messaging import MessageBus class TopologyType(Enum): @@ -57,6 +59,321 @@ class SwarmConfig: num_planning: int = 25 +SWARM_STATE_SCHEMA_VERSION = 2 +MAX_PERSISTED_AGENTS = 512 +MAX_PERSISTED_DIMENSION = 4096 +MAX_PERSISTED_MESSAGE_ROUNDS = 32 +MAX_PERSISTED_MODEL_COMPLEXITY = 50_000_000 + +_SWARM_CONFIG_KEYS = { + "num_agents", + "topology", + "message_passing_rounds", + "input_dim", + "hidden_dim", + "output_dim", + "message_dim", + "random_edge_prob", + "small_world_k", + "small_world_p", + "scale_free_m", + "num_perception", + "num_reasoning", + "num_memory", + "num_planning", +} + + +def _require_exact_keys( + value: Mapping, + expected: set[str], + context: str, +) -> None: + actual = set(value.keys()) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted((repr(key) for key in actual - expected)) + raise ValueError( + f"Invalid {context} keys; missing={missing}, extra={extra}" + ) + + +def _require_int(value: object, context: str) -> int: + if type(value) is not int: + raise TypeError(f"{context} must be a native int") + return value + + +def _require_float(value: object, context: str) -> float: + if type(value) not in (int, float): + raise TypeError(f"{context} must be a native int or float") + return float(value) + + +def validate_persisted_swarm_config(config: SwarmConfig) -> None: + """Validate resource bounds and structural invariants for checkpoints. + + Runtime experiments may construct bespoke configurations directly. A + persisted configuration has a stronger trust boundary: the evaluator uses + it to allocate graphs and neural-network layers, so malformed or enormous + values must be rejected before model construction. + """ + integer_fields = { + "num_agents": config.num_agents, + "message_passing_rounds": config.message_passing_rounds, + "input_dim": config.input_dim, + "hidden_dim": config.hidden_dim, + "output_dim": config.output_dim, + "message_dim": config.message_dim, + "small_world_k": config.small_world_k, + "scale_free_m": config.scale_free_m, + "num_perception": config.num_perception, + "num_reasoning": config.num_reasoning, + "num_memory": config.num_memory, + "num_planning": config.num_planning, + } + for name, value in integer_fields.items(): + if type(value) is not int: + raise TypeError(f"swarm config {name} must be a native int") + + if not 1 <= config.num_agents <= MAX_PERSISTED_AGENTS: + raise ValueError( + f"swarm config num_agents must be in [1, {MAX_PERSISTED_AGENTS}]" + ) + if not 0 <= config.message_passing_rounds <= MAX_PERSISTED_MESSAGE_ROUNDS: + raise ValueError( + "swarm config message_passing_rounds must be in " + f"[0, {MAX_PERSISTED_MESSAGE_ROUNDS}]" + ) + for name in ("input_dim", "hidden_dim", "output_dim", "message_dim"): + value = integer_fields[name] + if not 1 <= value <= MAX_PERSISTED_DIMENSION: + raise ValueError( + f"swarm config {name} must be in [1, {MAX_PERSISTED_DIMENSION}]" + ) + + # Conservative allocation proxy covering dense projections used by both + # generic and specialized agents. Individual field bounds are insufficient: + # a tiny file could otherwise request hundreds of enormous networks before + # strict state loading begins. + per_agent_complexity = ( + (config.input_dim + config.message_dim + 64) * config.hidden_dim + + 8 * config.hidden_dim * config.hidden_dim + + 2 * config.hidden_dim * config.output_dim + ) + total_complexity = config.num_agents * per_agent_complexity + if total_complexity > MAX_PERSISTED_MODEL_COMPLEXITY: + raise ValueError( + "swarm config exceeds the persisted-model allocation budget: " + f"{total_complexity} > {MAX_PERSISTED_MODEL_COMPLEXITY}" + ) + + role_counts = ( + config.num_perception, + config.num_reasoning, + config.num_memory, + config.num_planning, + ) + if any(value < 0 for value in role_counts): + raise ValueError("swarm config role counts must be non-negative") + if sum(role_counts) != config.num_agents: + raise ValueError( + "swarm config role counts must sum exactly to num_agents" + ) + + probability_fields = { + "random_edge_prob": config.random_edge_prob, + "small_world_p": config.small_world_p, + } + for name, value in probability_fields.items(): + if type(value) not in (int, float) or not math.isfinite(value): + raise TypeError(f"swarm config {name} must be a finite native number") + if not 0.0 <= float(value) <= 1.0: + raise ValueError(f"swarm config {name} must be in [0, 1]") + + if config.small_world_k < 0: + raise ValueError("swarm config small_world_k must be non-negative") + if ( + config.topology == TopologyType.SMALL_WORLD + and config.small_world_k > config.num_agents + ): + raise ValueError("small_world_k must not exceed num_agents") + if config.scale_free_m < 1: + raise ValueError("swarm config scale_free_m must be positive") + if config.topology == TopologyType.SCALE_FREE and config.scale_free_m >= config.num_agents: + raise ValueError("scale_free_m must be smaller than num_agents") + + +def swarm_config_to_dict(config: SwarmConfig) -> dict: + """Convert a SwarmConfig to a weights-only-safe primitive mapping.""" + if not isinstance(config, SwarmConfig): + raise TypeError("config must be a SwarmConfig") + if not isinstance(config.topology, TopologyType): + raise TypeError("config topology must be a TopologyType") + validate_persisted_swarm_config(config) + return { + "num_agents": int(config.num_agents), + "topology": config.topology.name, + "message_passing_rounds": int(config.message_passing_rounds), + "input_dim": int(config.input_dim), + "hidden_dim": int(config.hidden_dim), + "output_dim": int(config.output_dim), + "message_dim": int(config.message_dim), + "random_edge_prob": float(config.random_edge_prob), + "small_world_k": int(config.small_world_k), + "small_world_p": float(config.small_world_p), + "scale_free_m": int(config.scale_free_m), + "num_perception": int(config.num_perception), + "num_reasoning": int(config.num_reasoning), + "num_memory": int(config.num_memory), + "num_planning": int(config.num_planning), + } + + +def swarm_config_from_dict(data: object) -> SwarmConfig: + """Strictly reconstruct a SwarmConfig from native primitives.""" + if not isinstance(data, Mapping): + raise TypeError("swarm config must be a mapping") + _require_exact_keys(data, _SWARM_CONFIG_KEYS, "swarm config") + + topology_name = data["topology"] + if type(topology_name) is not str: + raise TypeError("swarm config topology must be an enum name string") + try: + topology = TopologyType[topology_name] + except KeyError as exc: + raise ValueError(f"Unknown topology name: {topology_name!r}") from exc + + config = SwarmConfig( + num_agents=_require_int(data["num_agents"], "swarm config num_agents"), + topology=topology, + message_passing_rounds=_require_int( + data["message_passing_rounds"], + "swarm config message_passing_rounds", + ), + input_dim=_require_int(data["input_dim"], "swarm config input_dim"), + hidden_dim=_require_int(data["hidden_dim"], "swarm config hidden_dim"), + output_dim=_require_int(data["output_dim"], "swarm config output_dim"), + message_dim=_require_int(data["message_dim"], "swarm config message_dim"), + random_edge_prob=_require_float( + data["random_edge_prob"], "swarm config random_edge_prob" + ), + small_world_k=_require_int( + data["small_world_k"], "swarm config small_world_k" + ), + small_world_p=_require_float( + data["small_world_p"], "swarm config small_world_p" + ), + scale_free_m=_require_int( + data["scale_free_m"], "swarm config scale_free_m" + ), + num_perception=_require_int( + data["num_perception"], "swarm config num_perception" + ), + num_reasoning=_require_int( + data["num_reasoning"], "swarm config num_reasoning" + ), + num_memory=_require_int( + data["num_memory"], "swarm config num_memory" + ), + num_planning=_require_int( + data["num_planning"], "swarm config num_planning" + ), + ) + validate_persisted_swarm_config(config) + return config + + +def _validate_tensor_state_dict(data: object, context: str) -> dict[str, torch.Tensor]: + if not isinstance(data, Mapping): + raise TypeError(f"{context} must be a mapping") + result: dict[str, torch.Tensor] = {} + for key, value in data.items(): + if type(key) is not str: + raise TypeError(f"{context} keys must be native strings") + if not isinstance(value, torch.Tensor): + raise TypeError(f"{context}[{key!r}] must be a tensor") + result[key] = value + return result + + +def _validate_exact_int_ids( + data: object, + expected_ids: set[int], + context: str, +) -> Mapping: + if not isinstance(data, Mapping): + raise TypeError(f"{context} must be a mapping") + for key in data: + if type(key) is not int: + raise TypeError(f"{context} keys must be native integer IDs") + actual_ids = set(data.keys()) + if actual_ids != expected_ids: + missing = sorted(expected_ids - actual_ids) + extra = sorted(actual_ids - expected_ids) + raise ValueError( + f"{context} IDs do not match; missing={missing}, extra={extra}" + ) + return data + + +def _validate_graph_edges(data: object, num_agents: int) -> list[tuple[int, int]]: + """Validate and normalize a primitive undirected edge list.""" + if type(data) is not list: + raise TypeError("graph_edges must be a native list") + + edges: list[tuple[int, int]] = [] + seen: set[tuple[int, int]] = set() + for index, edge in enumerate(data): + if type(edge) is not list or len(edge) != 2: + raise TypeError(f"graph_edges[{index}] must be a two-item native list") + source = _require_int(edge[0], f"graph_edges[{index}][0]") + target = _require_int(edge[1], f"graph_edges[{index}][1]") + if not 0 <= source < num_agents or not 0 <= target < num_agents: + raise ValueError( + f"graph_edges[{index}] endpoint outside [0, {num_agents}): " + f"{source}, {target}" + ) + if source == target: + raise ValueError(f"graph_edges[{index}] is a self-loop") + normalized = (min(source, target), max(source, target)) + if normalized in seen: + raise ValueError(f"graph_edges[{index}] duplicates edge {normalized}") + seen.add(normalized) + edges.append(normalized) + + graph = nx.Graph() + graph.add_nodes_from(range(num_agents)) + graph.add_edges_from(edges) + if num_agents > 0 and not nx.is_connected(graph): + raise ValueError("graph_edges must describe a connected graph") + return edges + + +def _graph_edges_to_list(graph: nx.Graph, num_agents: int) -> list[list[int]]: + expected_nodes = set(range(num_agents)) + for node in graph.nodes: + if type(node) is not int: + raise TypeError("graph node IDs must be native integers") + actual_nodes = set(graph.nodes) + if actual_nodes != expected_nodes: + missing = sorted(expected_nodes - actual_nodes) + extra = sorted(actual_nodes - expected_nodes) + raise ValueError( + f"Graph node IDs do not match config; missing={missing}, extra={extra}" + ) + + edge_list = [ + [int(source), int(target)] + for source, target in sorted( + (min(source, target), max(source, target)) + for source, target in graph.edges() + ) + ] + _validate_graph_edges(edge_list, num_agents) + return edge_list + + class SwarmGraph: """ Manages the swarm of micro-agents and their interactions. @@ -158,8 +475,9 @@ def _build_modular_topology(self) -> nx.Graph: node_id += size # Sparse connections between clusters - for i, cluster1 in enumerate(cluster_nodes): - for cluster2 in cluster_nodes[i + 1 :]: + nonempty_clusters = [cluster for cluster in cluster_nodes if cluster] + for i, cluster1 in enumerate(nonempty_clusters): + for cluster2 in nonempty_clusters[i + 1 :]: # Add a few inter-cluster edges num_bridges = max(1, len(cluster1) // 5) for _ in range(num_bridges): @@ -353,10 +671,11 @@ def compute_synergy( labels: torch.Tensor, ) -> dict[str, float]: """ - Compute synergy metrics for the swarm. + Compute a legacy-named internal aggregation diagnostic. - Synergy = collective performance - sum of individual performances - Positive synergy means the whole is greater than sum of parts. + The score is collective inverse-MSE minus average individual inverse-MSE. + It is descriptive and does not establish causal coordination or + emergent collective behavior. Args: inputs: Test inputs [num_samples, input_dim] @@ -386,8 +705,7 @@ def compute_synergy( avg_individual_error = sum(individual_errors) / max(1, len(individual_errors)) - # Synergy: if collective error is lower, we have positive synergy - # Convert to "performance" (inverse of error) for intuitive interpretation + # Convert error to a bounded inverse-error score for comparison. collective_perf = 1.0 / (1.0 + collective_error) avg_individual_perf = 1.0 / (1.0 + avg_individual_error) @@ -422,22 +740,57 @@ def total_parameters(self) -> int: return sum(agent.num_parameters for agent in self.agents.values()) def state_dict(self) -> dict: - """Get state for saving.""" + """Get a versioned, weights-only-safe persistent state.""" + expected_ids = set(range(self.config.num_agents)) + _validate_exact_int_ids(self.agents, expected_ids, "swarm agents") return { - "config": self.config, + "schema_version": SWARM_STATE_SCHEMA_VERSION, + "config": swarm_config_to_dict(self.config), "agents": {i: a.state_dict() for i, a in self.agents.items()}, - "graph_edges": list(self.graph.edges()), + "graph_edges": _graph_edges_to_list( + self.graph, self.config.num_agents + ), } def load_state_dict(self, state: dict) -> None: - """Load saved state.""" - self.config = state["config"] - for i, agent_state in state["agents"].items(): - self.agents[int(i)].load_state_dict(agent_state) - # Rebuild graph from edges - self.graph = nx.Graph() - self.graph.add_nodes_from(range(self.config.num_agents)) - self.graph.add_edges_from(state["graph_edges"]) + """Strictly load a schema-v2 state into a matching swarm.""" + if not isinstance(state, Mapping): + raise TypeError("swarm state must be a mapping") + _require_exact_keys( + state, + {"schema_version", "config", "agents", "graph_edges"}, + "swarm state", + ) + + schema_version = _require_int( + state["schema_version"], "swarm state schema_version" + ) + if schema_version != SWARM_STATE_SCHEMA_VERSION: + raise ValueError( + f"Unsupported swarm state schema_version {schema_version}; " + f"expected {SWARM_STATE_SCHEMA_VERSION}" + ) + + saved_config = swarm_config_from_dict(state["config"]) + if saved_config != self.config: + raise ValueError( + f"Swarm config mismatch: checkpoint={saved_config!r}, " + f"instance={self.config!r}" + ) + + expected_ids = set(self.agents.keys()) + agent_states = _validate_exact_int_ids( + state["agents"], expected_ids, "swarm agent states" + ) + edges = _validate_graph_edges(state["graph_edges"], self.config.num_agents) + + for agent_id in sorted(expected_ids): + self.agents[agent_id].load_state_dict(agent_states[agent_id]) + + graph = nx.Graph() + graph.add_nodes_from(range(self.config.num_agents)) + graph.add_edges_from(edges) + self.graph = graph @classmethod def from_genome(cls, genome: dict, device: str = "cpu") -> SwarmGraph: diff --git a/src/swarm/metrics.py b/src/swarm/metrics.py index b15fee0..e313b06 100644 --- a/src/swarm/metrics.py +++ b/src/swarm/metrics.py @@ -1,5 +1,5 @@ """ -Metrics for measuring swarm collective intelligence. +Descriptive metrics for comparing swarm and per-agent outputs. Key metrics: - Synergy: collective > sum of individuals @@ -16,9 +16,9 @@ @dataclass class SynergyMetrics: - """Metrics quantifying emergent collective intelligence.""" + """Legacy-named descriptive output-comparison metrics.""" - synergy: float # Collective - sum of individuals (positive = emergent) + synergy: float # Aggregate score minus the per-agent reference score redundancy: float # How much agents overlap specialization: float # How distinct are agent outputs collective_accuracy: float diff --git a/src/swarm/specialized_graph.py b/src/swarm/specialized_graph.py index 2690dc0..f613f95 100644 --- a/src/swarm/specialized_graph.py +++ b/src/swarm/specialized_graph.py @@ -4,32 +4,40 @@ Combines: - Specialized agent architectures (Perception, Reasoning, Memory, Planning) - Typed message passing with semantic routing -- Role emergence tracking +- Descriptive role-pattern tracking """ from __future__ import annotations -from dataclasses import dataclass -from typing import Optional, Dict, List, Tuple +import math import random +from dataclasses import dataclass +from typing import Dict, List, Optional import networkx as nx import torch -import torch.nn as nn from ..agents.micro_agent import AgentConfig, AgentType from ..agents.specializations import ( SpecializedAgent, - PerceptionNetwork, - ReasoningNetwork, - MemoryNetwork, - PlanningNetwork, ) -from .graph import SwarmConfig, TopologyType +from .graph import ( + SWARM_STATE_SCHEMA_VERSION, + SwarmConfig, + TopologyType, + _graph_edges_to_list, + _require_exact_keys, + _require_float, + _require_int, + _validate_exact_int_ids, + _validate_graph_edges, + _validate_tensor_state_dict, + swarm_config_from_dict, + swarm_config_to_dict, +) from .typed_messaging import ( TypedMessageBus, MessageType, - TypedMessage, MessageEncoder, TypeAwareAggregator, AGENT_MESSAGE_TYPES, @@ -53,6 +61,88 @@ class SpecializedSwarmConfig(SwarmConfig): specialization_bonus: float = 0.1 # Bonus for using specialized capabilities +_SPECIALIZED_CONFIG_KEYS = set(swarm_config_to_dict(SwarmConfig())) | { + "use_typed_messaging", + "encode_message_types", + "track_role_emergence", + "specialization_bonus", +} + + +def specialized_swarm_config_to_dict(config: SpecializedSwarmConfig) -> dict: + """Convert a specialized swarm config to weights-only-safe primitives.""" + if not isinstance(config, SpecializedSwarmConfig): + raise TypeError("config must be a SpecializedSwarmConfig") + for name in ( + "use_typed_messaging", + "encode_message_types", + "track_role_emergence", + ): + if type(getattr(config, name)) is not bool: + raise TypeError(f"specialized swarm config {name} must be a native bool") + if not math.isfinite(config.specialization_bonus) or config.specialization_bonus < 0: + raise ValueError("specialization_bonus must be a finite non-negative number") + result = swarm_config_to_dict(config) + result.update( + { + "use_typed_messaging": bool(config.use_typed_messaging), + "encode_message_types": bool(config.encode_message_types), + "track_role_emergence": bool(config.track_role_emergence), + "specialization_bonus": float(config.specialization_bonus), + } + ) + return result + + +def specialized_swarm_config_from_dict(data: object) -> SpecializedSwarmConfig: + """Strictly reconstruct a specialized config from native primitives.""" + if not isinstance(data, dict): + raise TypeError("specialized swarm config must be a native dict") + _require_exact_keys(data, _SPECIALIZED_CONFIG_KEYS, "specialized swarm config") + + base_keys = set(swarm_config_to_dict(SwarmConfig())) + base = swarm_config_from_dict({key: data[key] for key in base_keys}) + + bool_values = {} + for key in ( + "use_typed_messaging", + "encode_message_types", + "track_role_emergence", + ): + value = data[key] + if type(value) is not bool: + raise TypeError(f"specialized swarm config {key} must be a native bool") + bool_values[key] = value + + config = SpecializedSwarmConfig( + num_agents=base.num_agents, + topology=base.topology, + message_passing_rounds=base.message_passing_rounds, + input_dim=base.input_dim, + hidden_dim=base.hidden_dim, + output_dim=base.output_dim, + message_dim=base.message_dim, + random_edge_prob=base.random_edge_prob, + small_world_k=base.small_world_k, + small_world_p=base.small_world_p, + scale_free_m=base.scale_free_m, + num_perception=base.num_perception, + num_reasoning=base.num_reasoning, + num_memory=base.num_memory, + num_planning=base.num_planning, + use_typed_messaging=bool_values["use_typed_messaging"], + encode_message_types=bool_values["encode_message_types"], + track_role_emergence=bool_values["track_role_emergence"], + specialization_bonus=_require_float( + data["specialization_bonus"], + "specialized swarm config specialization_bonus", + ), + ) + if not math.isfinite(config.specialization_bonus) or config.specialization_bonus < 0: + raise ValueError("specialization_bonus must be a finite non-negative number") + return config + + class SpecializedSwarmGraph: """ Swarm with specialized agent architectures and typed messaging. @@ -60,7 +150,7 @@ class SpecializedSwarmGraph: Key features: - Each agent type has distinct architectural biases - Messages carry semantic type information - - Role emergence metrics track specialization development + - Role-pattern metrics summarize observed message distributions """ def __init__( @@ -165,8 +255,9 @@ def _build_modular_topology(self) -> nx.Graph: node_id += size # Sparse inter-cluster connections - for i, cluster1 in enumerate(cluster_nodes): - for cluster2 in cluster_nodes[i + 1:]: + nonempty_clusters = [cluster for cluster in cluster_nodes if cluster] + for i, cluster1 in enumerate(nonempty_clusters): + for cluster2 in nonempty_clusters[i + 1:]: num_bridges = max(1, len(cluster1) // 5) for _ in range(num_bridges): n1 = random.choice(cluster1) @@ -245,11 +336,13 @@ def _create_agents(self) -> None: output_dim=self.config.output_dim, message_dim=self.config.message_dim, ) - self.agents[agent_id] = SpecializedAgent( + agent = SpecializedAgent( agent_id=agent_id, config=agent_config, device=self.device, ) + agent.last_output = None + self.agents[agent_id] = agent def _register_agents_with_bus(self) -> None: """Register agents with the typed message bus.""" @@ -320,6 +413,7 @@ def step(self, global_input: torch.Tensor) -> torch.Tensor: global_input, neighbor_msg, agent.local_state ) agent.local_state = new_state + agent.last_output = output.detach() current_outputs[agent_id] = output # Send typed message @@ -364,6 +458,7 @@ def step(self, global_input: torch.Tensor) -> torch.Tensor: agent_input, msg_agg, agent.local_state ) agent.local_state = new_state + agent.last_output = output.detach() new_outputs[agent_id] = output # Send output as typed message @@ -410,7 +505,7 @@ def get_message_stats(self) -> Dict: return self.message_bus.get_stats() def get_role_emergence_metrics(self) -> Dict: - """Get role emergence metrics.""" + """Get descriptive role-pattern metrics (legacy API name).""" if hasattr(self, 'role_metrics'): return self.role_metrics.get_metrics() return {} @@ -444,28 +539,114 @@ def parameters(self): return params def state_dict(self) -> Dict: - """Get state for saving.""" + """Get a versioned, weights-only-safe persistent state.""" + expected_ids = set(range(self.config.num_agents)) + _validate_exact_int_ids(self.agents, expected_ids, "specialized swarm agents") + _validate_exact_int_ids( + self.aggregators, + expected_ids, + "specialized swarm aggregators", + ) return { - "config": self.config, - "agents": {i: a.network.state_dict() for i, a in self.agents.items()}, - "aggregators": {i: a.state_dict() for i, a in self.aggregators.items()}, - "graph_edges": list(self.graph.edges()), - "message_encoder": self.message_encoder.state_dict() if self.message_encoder else None, + "schema_version": SWARM_STATE_SCHEMA_VERSION, + "config": specialized_swarm_config_to_dict(self.config), + "agents": {i: a.state_dict() for i, a in self.agents.items()}, + "aggregators": { + i: dict(aggregator.state_dict()) + for i, aggregator in self.aggregators.items() + }, + "graph_edges": _graph_edges_to_list( + self.graph, self.config.num_agents + ), + "message_encoder": ( + dict(self.message_encoder.state_dict()) + if self.message_encoder is not None + else None + ), } def load_state_dict(self, state: Dict) -> None: - """Load saved state.""" - for i, agent_state in state["agents"].items(): - self.agents[int(i)].network.load_state_dict(agent_state) - for i, agg_state in state["aggregators"].items(): - self.aggregators[int(i)].load_state_dict(agg_state) - if state["message_encoder"] and self.message_encoder: - self.message_encoder.load_state_dict(state["message_encoder"]) + """Strictly load a schema-v2 state into a matching specialized swarm.""" + if not isinstance(state, dict): + raise TypeError("specialized swarm state must be a native dict") + _require_exact_keys( + state, + { + "schema_version", + "config", + "agents", + "aggregators", + "graph_edges", + "message_encoder", + }, + "specialized swarm state", + ) + + schema_version = _require_int( + state["schema_version"], "specialized swarm state schema_version" + ) + if schema_version != SWARM_STATE_SCHEMA_VERSION: + raise ValueError( + f"Unsupported specialized swarm state schema_version {schema_version}; " + f"expected {SWARM_STATE_SCHEMA_VERSION}" + ) + + saved_config = specialized_swarm_config_from_dict(state["config"]) + if saved_config != self.config: + raise ValueError( + f"Specialized swarm config mismatch: checkpoint={saved_config!r}, " + f"instance={self.config!r}" + ) + + expected_ids = set(self.agents.keys()) + agent_states = _validate_exact_int_ids( + state["agents"], expected_ids, "specialized swarm agent states" + ) + aggregator_states = _validate_exact_int_ids( + state["aggregators"], expected_ids, "specialized swarm aggregator states" + ) + validated_aggregators = { + agent_id: _validate_tensor_state_dict( + aggregator_states[agent_id], + f"aggregator {agent_id} state", + ) + for agent_id in expected_ids + } + edges = _validate_graph_edges(state["graph_edges"], self.config.num_agents) + + encoder_state = state["message_encoder"] + if self.message_encoder is None: + if encoder_state is not None: + raise ValueError( + "Checkpoint has a message encoder but this swarm disables it" + ) + validated_encoder = None + else: + if encoder_state is None: + raise ValueError( + "Checkpoint omits the message encoder required by this swarm" + ) + validated_encoder = _validate_tensor_state_dict( + encoder_state, "message encoder state" + ) + + for agent_id in sorted(expected_ids): + self.agents[agent_id].load_state_dict(agent_states[agent_id]) + self.aggregators[agent_id].load_state_dict( + validated_aggregators[agent_id], strict=True + ) + if self.message_encoder is not None and validated_encoder is not None: + self.message_encoder.load_state_dict(validated_encoder, strict=True) + + graph = nx.Graph() + graph.add_nodes_from(range(self.config.num_agents)) + graph.add_edges_from(edges) + self.graph = graph class RoleEmergenceTracker: """ - Track metrics related to agent role emergence and specialization. + Track descriptive message-distribution and specialization proxies. Measures: - Message type distribution per agent @@ -499,7 +680,7 @@ def update(self, message_stats: Dict) -> None: pass def get_metrics(self) -> Dict: - """Compute role emergence metrics.""" + """Compute descriptive role-pattern metrics.""" import math metrics = {} diff --git a/src/utils/checkpoint.py b/src/utils/checkpoint.py new file mode 100644 index 0000000..3ab690d --- /dev/null +++ b/src/utils/checkpoint.py @@ -0,0 +1,94 @@ +"""Bounded helpers for loading tensor-only PyTorch checkpoints.""" + +from __future__ import annotations + +import hashlib +import os +import re +import stat +from pathlib import Path +from typing import Any + +import torch + + +MINIMUM_RESTRICTED_LOADER_TORCH = (2, 10, 0) +MAX_CHECKPOINT_BYTES = 512 * 1024 * 1024 + + +def _torch_release_tuple() -> tuple[int, int, int]: + match = re.match(r"^(\d+)\.(\d+)\.(\d+)", str(torch.__version__)) + if match is None: + raise RuntimeError(f"cannot validate PyTorch version {torch.__version__!r}") + return tuple(int(component) for component in match.groups()) + + +def require_restricted_loader_runtime() -> None: + """Refuse versions with known ``weights_only`` loader vulnerabilities.""" + if _torch_release_tuple() < MINIMUM_RESTRICTED_LOADER_TORCH: + required = ".".join(str(part) for part in MINIMUM_RESTRICTED_LOADER_TORCH) + raise RuntimeError( + f"checkpoint loading requires PyTorch {required} or newer" + ) + + +def load_bounded_weights_only( + path: str | Path, + *, + map_location: str | torch.device = "cpu", + max_bytes: int = MAX_CHECKPOINT_BYTES, +) -> tuple[Any, Path, str]: + """Load a local checkpoint through PyTorch's restricted loader. + + The size bound reduces accidental resource exhaustion. It is not a claim + that arbitrary third-party checkpoint files are safe; callers must still + validate the returned schema and should verify an expected digest. + """ + require_restricted_loader_runtime() + if type(max_bytes) is not int or max_bytes < 1: + raise ValueError("max_bytes must be a positive native int") + + checkpoint_path = Path(path).expanduser().resolve(strict=True) + with checkpoint_path.open("rb") as handle: + before = os.fstat(handle.fileno()) + if not stat.S_ISREG(before.st_mode): + raise ValueError( + f"checkpoint is not a regular file: {checkpoint_path}" + ) + if before.st_size <= 0: + raise ValueError("checkpoint file is empty") + if before.st_size > max_bytes: + raise ValueError( + f"checkpoint is {before.st_size} bytes; " + f"the evaluator limit is {max_bytes} bytes" + ) + + digest = hashlib.sha256() + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + after_hash = os.fstat(handle.fileno()) + if _file_identity(before) != _file_identity(after_hash): + raise RuntimeError("checkpoint changed while it was being hashed") + + handle.seek(0) + checkpoint = torch.load( + handle, + map_location=map_location, + weights_only=True, + ) + after_load = os.fstat(handle.fileno()) + if _file_identity(after_hash) != _file_identity(after_load): + raise RuntimeError("checkpoint changed while it was being loaded") + + return checkpoint, checkpoint_path, digest.hexdigest() + + +def _file_identity(metadata: os.stat_result) -> tuple[int, int, int, int, int]: + """Return file attributes that change for replacement or normal writes.""" + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) diff --git a/src/validation/__init__.py b/src/validation/__init__.py index 17917ba..b9f088a 100644 --- a/src/validation/__init__.py +++ b/src/validation/__init__.py @@ -1,10 +1,10 @@ """ -Experimental Validation Framework for SEESWM Hypothesis Testing. +Exploratory evaluation utilities for SEESWM experiments. -This module provides research-grade tools for validating the core hypothesis: -"Collective intelligence from many small specialized agents, embedded in -simulated worlds, will exhibit emergent capabilities that equivalent-parameter -monolithic models cannot." +These tools help test whether specialized message-passing agents differ from +matched baselines. They do not make an experiment rigorous on their own; +credible conclusions still require trained models, controlled comparisons, +multiple seeds, and auditable artifacts. Components: - Ablation studies diff --git a/src/validation/baselines.py b/src/validation/baselines.py index c7170d8..4f66dbd 100644 --- a/src/validation/baselines.py +++ b/src/validation/baselines.py @@ -1,8 +1,9 @@ -""" -Comparison Baselines. +"""Untrained baseline-shape diagnostics. -Implements baseline models that the swarm must outperform to validate -the collective intelligence hypothesis. +The models in this module are freshly initialized. Their rollouts can exercise +evaluation plumbing, but cannot validate a collective-intelligence hypothesis. +Credible comparisons require separately trained controls with matched data, +optimization, parameter, and compute budgets. """ import torch @@ -275,7 +276,7 @@ def __init__( self.results: Dict[str, BaselineResult] = {} def create_baselines(self) -> Dict[str, nn.Module]: - """Create all baseline models.""" + """Create freshly initialized diagnostic baseline models.""" baselines = {} # Single large agent (same params) @@ -321,7 +322,10 @@ def compare( verbose: bool = True, ) -> Dict[str, BaselineResult]: """ - Compare swarm against all baselines. + Compare a swarm against fresh, untrained diagnostic baselines. + + These descriptive smoke-test outputs are not a trained performance + benchmark or evidence of architectural superiority. Args: num_seeds: Number of random seeds @@ -394,7 +398,7 @@ def summary(self) -> str: """Generate summary report.""" lines = [ "\n" + "=" * 70, - "BASELINE COMPARISON SUMMARY", + "UNTRAINED BASELINE DIAGNOSTIC SUMMARY", "=" * 70, "", f"Swarm: {self.num_agents} agents, {self.total_params:,} parameters", @@ -411,18 +415,16 @@ def summary(self) -> str: f"{result.p_value:>9.4f}{sig}" ) - # Overall assessment + # Descriptive count only; these controls have not been trained. lines.append("-" * 70) must_beat = ['single_agent', 'ensemble', 'independent', 'centralized'] beaten = sum(1 for name in must_beat if name in self.results and self.results[name].delta > 0) - if beaten == len(must_beat): - verdict = "PASS: Swarm outperforms all required baselines" - elif beaten >= len(must_beat) - 1: - verdict = "MARGINAL: Swarm beats most baselines" - else: - verdict = "FAIL: Swarm does not consistently outperform baselines" + verdict = ( + f"Descriptive only: swarm scored higher than {beaten}/{len(must_beat)} " + "freshly initialized controls; no superiority claim is supported" + ) lines.extend(["", verdict, "=" * 70]) diff --git a/src/validation/emergence.py b/src/validation/emergence.py index efd21bb..1b3d915 100644 --- a/src/validation/emergence.py +++ b/src/validation/emergence.py @@ -1,7 +1,8 @@ -""" -Emergent Behavior Detection. +"""Candidate behavioral-pattern heuristics. -Detect behaviors that weren't explicitly rewarded but emerged from training. +These functions flag simple correlations in trajectories and messages. They do +not establish learning, causality, cooperation, tool use, planning, semantic +communication, or emergence. """ import torch @@ -14,7 +15,7 @@ @dataclass class EmergentBehavior: - """Detected emergent behavior.""" + """Heuristic behavioral flag (the public name is retained for compatibility).""" name: str description: str frequency: float # How often observed @@ -48,7 +49,7 @@ def forward(self, representations: torch.Tensor) -> torch.Tensor: class EmergenceDetector: - """Detect emergent behaviors in trained swarms.""" + """Detect candidate behavioral patterns in agent swarms.""" def __init__( self, @@ -78,13 +79,7 @@ def detect_tool_use( self, trajectories: List[Dict], ) -> Optional[EmergentBehavior]: - """ - Detect if agents learn to use environmental objects as tools. - - Looks for patterns like: - - Collecting materials before building - - Using resources strategically - """ + """Flag episodes where material collection precedes higher reward.""" tool_use_count = 0 total_episodes = len(trajectories) @@ -112,7 +107,7 @@ def detect_tool_use( if frequency > 0.1: # At least 10% of episodes return EmergentBehavior( name="tool_use", - description="Agents collect materials before high-reward actions", + description="Material collection preceded higher near-term reward", frequency=frequency, strength=min(1.0, frequency * 2), evidence=[f"Observed in {tool_use_count}/{total_episodes} episodes"], @@ -123,13 +118,7 @@ def detect_communication_protocols( self, message_history: List[Dict], ) -> List[CommunicationPattern]: - """ - Detect if stable communication protocols emerge. - - Looks for: - - Consistent message patterns between agent pairs - - Semantic clustering of messages - """ + """Flag low-variance pairwise message streams, without semantic claims.""" patterns = [] # Group messages by source-target pairs @@ -166,13 +155,7 @@ def detect_division_of_labor( self, agent_action_histories: Dict[int, List[int]], ) -> Optional[EmergentBehavior]: - """ - Detect if agents specialize in different roles. - - Looks for: - - Agents preferring different action distributions - - Stable role assignments over time - """ + """Flag differences among empirical per-agent action distributions.""" from scipy.stats import entropy num_agents = len(agent_action_histories) @@ -218,13 +201,7 @@ def detect_anticipatory_behavior( self, trajectories: List[Dict], ) -> Optional[EmergentBehavior]: - """ - Detect if agents show planning/anticipation. - - Looks for: - - Actions that don't give immediate reward but lead to better outcomes - - Goal-directed movement patterns - """ + """Flag low immediate reward followed by higher near-term reward.""" anticipatory_count = 0 total_opportunities = 0 @@ -246,7 +223,7 @@ def detect_anticipatory_behavior( if frequency > 0.05: # At least 5% anticipatory actions return EmergentBehavior( name="anticipatory_behavior", - description="Agents take suboptimal immediate actions for future gain", + description="Low immediate reward was followed by higher near-term reward", frequency=frequency, strength=min(1.0, frequency * 5), evidence=[f"Observed {anticipatory_count} anticipatory actions"], @@ -257,13 +234,7 @@ def detect_cooperation( self, multi_agent_trajectories: List[Dict], ) -> Optional[EmergentBehavior]: - """ - Detect cooperative behaviors between agents. - - Looks for: - - Coordinated actions - - Resource sharing patterns - """ + """Flag how often agents select identical actions on the same step.""" cooperation_events = 0 total_steps = 0 @@ -280,7 +251,7 @@ def detect_cooperation( step_actions = [actions[step] for actions in agent_actions.values() if step < len(actions)] if len(set(step_actions)) == 1 and len(step_actions) > 1: - # All agents took same action - coordination + # Identical actions are a correlation, not proof of coordination. cooperation_events += 1 frequency = cooperation_events / (total_steps + 1e-10) @@ -288,7 +259,7 @@ def detect_cooperation( if frequency > 0.1: # 10% coordinated actions return EmergentBehavior( name="cooperation", - description="Agents coordinate their actions", + description="Agents selected identical actions on the same steps", frequency=frequency, strength=min(1.0, frequency * 3), evidence=[f"Observed {cooperation_events} coordinated steps"], @@ -300,7 +271,7 @@ def analyze_all( trajectories: List[Dict], message_history: Optional[List[Dict]] = None, ) -> Dict[str, EmergentBehavior]: - """Run all emergence detection analyses.""" + """Run all candidate-pattern heuristics.""" behaviors = {} # Tool use @@ -334,7 +305,7 @@ def analyze_all( if protocols: behaviors['communication'] = EmergentBehavior( name="communication_protocol", - description=f"Detected {len(protocols)} stable communication patterns", + description=f"Observed {len(protocols)} low-variance message streams", frequency=1.0, strength=min(1.0, len(protocols) / 10), evidence=[f"{len(protocols)} patterns detected"], diff --git a/src/validation/generalization.py b/src/validation/generalization.py index f1e24fc..090d1f0 100644 --- a/src/validation/generalization.py +++ b/src/validation/generalization.py @@ -120,6 +120,11 @@ def _run_episodes( action_logits = model.step(obs_tensor) else: action_logits = model(obs_tensor) + if action_logits.ndim != 2 or action_logits.shape[-1] != 5: + raise ValueError( + "generalization evaluation requires a complete five-action " + "policy, not a latent swarm representation" + ) action = action_logits.argmax(dim=-1).item() # Environment returns 3 values: observations, rewards, dones diff --git a/src/validation/scaling.py b/src/validation/scaling.py index bdaf2e8..827667f 100644 --- a/src/validation/scaling.py +++ b/src/validation/scaling.py @@ -1,8 +1,9 @@ """ -Scaling Law Experiments. +Exploratory scaling-curve utilities. -Test whether collective intelligence emerges at scale, following -Chinchilla-style analysis of compute vs performance. +These helpers fit descriptive curves and flag local slope changes. They do not +establish scaling laws, phase transitions, or emergent behavior without a +separate controlled protocol. """ import torch @@ -230,9 +231,10 @@ def find_phase_transitions( window_size: int = 3, ) -> List[Tuple[int, float]]: """ - Detect phase transitions in scaling behavior. + Flag unusually large local gradient changes in scaling behavior. - Returns list of (scale_point, transition_magnitude) tuples. + Returns descriptive ``(scale_point, change_magnitude)`` tuples. The legacy + function name does not imply evidence of a phase transition. """ # Sort by num_agents sorted_points = sorted(data_points, key=lambda p: p.num_agents) @@ -253,7 +255,7 @@ def find_phase_transitions( before = np.mean(gradients[i-window_size:i]) after = np.mean(gradients[i:i+window_size]) - # Large change in gradient indicates phase transition + # Large changes are candidates for follow-up, not transition evidence. change = abs(after - before) threshold = 2 * np.std(gradients) diff --git a/src/validation/synergy.py b/src/validation/synergy.py index 578531f..5617517 100644 --- a/src/validation/synergy.py +++ b/src/validation/synergy.py @@ -1,9 +1,10 @@ """ -Synergy Measurement using Information-Theoretic Methods. +Exploratory information-decomposition approximations. -This module implements Partial Information Decomposition (PID) to measure -true synergy in collective systems. Synergy is information that the collective -provides about the target that no individual agent provides. +The public names are retained for compatibility, but the implementations use +finite-sample mutual-information estimators and simplified or pairwise +approximations. They are not a formal multivariate PID implementation and a +positive score is not, by itself, evidence of emergence. Key concepts: - Redundancy: Information shared by multiple agents @@ -31,7 +32,7 @@ class SynergyDecomposition: # Decomposition components redundancy: float # Shared information unique: List[float] # Per-agent unique information - synergy: float # Emergent collective information + synergy: float # Residual under the selected approximation # Derived metrics synergy_ratio: float # synergy / total_mi @@ -77,9 +78,10 @@ def estimate( def _ksg_estimator(self, x: np.ndarray, y: np.ndarray, k: int = 3) -> float: """ - Kraskov-Stögbauer-Grassberger estimator. + KSG-inspired nearest-neighbor approximation. - Based on: "Estimating Mutual Information" (Kraskov et al., 2004) + This implementation does not reproduce every metric and boundary + convention of the reference KSG estimator. """ from scipy.spatial import cKDTree @@ -154,7 +156,7 @@ def _kde_estimator(self, x: np.ndarray, y: np.ndarray) -> float: return 0.0 def _binning_estimator(self, x: np.ndarray, y: np.ndarray, bins: int = 10) -> float: - """Simple binning-based MI estimation.""" + """Simple first-component histogram MI estimate.""" if x.ndim == 1: x = x.reshape(-1, 1) if y.ndim == 1: @@ -187,7 +189,7 @@ def _binning_estimator(self, x: np.ndarray, y: np.ndarray, bins: int = 10) -> fl class PartialInformationDecomposition: """ - Compute Partial Information Decomposition. + Compute exploratory information-decomposition approximations. Decomposes the total mutual information I(X1, ..., Xn; Y) into: - Redundancy: Information all agents share @@ -237,7 +239,7 @@ def _simplified_pid( Synergy = I(X1,...,Xn; Y) - Σ I(Xi; Y) - This is a lower bound on true synergy. + This is a heuristic residual, not a formal multivariate PID estimate. """ n_agents = len(agent_outputs) @@ -277,9 +279,9 @@ def _broja_pid( targets: np.ndarray, ) -> SynergyDecomposition: """ - BROJA (Bertschinger et al.) PID estimator. + Legacy ``broja`` option using a pairwise residual approximation. - Uses bivariate redundancy measure. + This does not implement the BROJA optimization procedure. """ # For computational efficiency, use pairwise approximation n_agents = len(agent_outputs) @@ -332,9 +334,9 @@ def _williams_beer_pid( targets: np.ndarray, ) -> SynergyDecomposition: """ - Williams-Beer PID using I_min. + Legacy ``williams`` option using minimum marginal MI as redundancy. - The original PID framework. + This is a simplified residual calculation, not a full PID lattice. """ # Use minimum mutual information as redundancy individual_mi = [ @@ -468,7 +470,7 @@ def compute_true_synergy( device: str = 'cpu', ) -> float: """ - Convenience function to compute synergy score. + Compatibility wrapper for the selected approximate decomposition score. Args: swarm: The swarm model @@ -477,7 +479,7 @@ def compute_true_synergy( device: Device Returns: - Synergy value (positive = emergent collective intelligence) + Approximate residual score; interpret only under the declared estimator. """ inputs, targets = task_data measurer = SynergyMeasurer(swarm, pid_method=method, device=device) diff --git a/tests/test_checkpoint_safety.py b/tests/test_checkpoint_safety.py new file mode 100644 index 0000000..aad7207 --- /dev/null +++ b/tests/test_checkpoint_safety.py @@ -0,0 +1,322 @@ +"""Focused safety tests for schema-v2 checkpoint persistence.""" + +import hashlib +from dataclasses import replace + +import pytest +import torch +import torch.nn as nn + +from experiments.validate_rigorously import ( + CHECKPOINT_SCHEMA_VERSION, + NUM_ACTIONS, + build_environment, + build_policy_head, + environment_config_to_dict, + load_checkpoint, +) +from src.agents.micro_agent import ( + AGENT_STATE_SCHEMA_VERSION, + AgentConfig, + MicroAgent, +) +from src.environment.cosmos import EnvironmentConfig +from src.swarm.graph import ( + SWARM_STATE_SCHEMA_VERSION, + SwarmConfig, + SwarmGraph, + TopologyType, + swarm_config_from_dict, + swarm_config_to_dict, +) +from src.utils import checkpoint as checkpoint_utils + + +def _tiny_agent_config() -> AgentConfig: + return AgentConfig( + input_dim=4, + hidden_dim=8, + output_dim=3, + message_dim=3, + num_layers=1, + dropout=0.0, + state_dim=2, + ) + + +def _tiny_swarm_config() -> SwarmConfig: + return SwarmConfig( + num_agents=4, + topology=TopologyType.FULLY_CONNECTED, + message_passing_rounds=1, + input_dim=4, + hidden_dim=8, + output_dim=3, + message_dim=3, + num_perception=1, + num_reasoning=1, + num_memory=1, + num_planning=1, + ) + + +def _assert_agent_networks_equal(expected: MicroAgent, actual: MicroAgent) -> None: + expected_state = expected.network.state_dict() + actual_state = actual.network.state_dict() + assert actual_state.keys() == expected_state.keys() + for name, expected_tensor in expected_state.items(): + torch.testing.assert_close(actual_state[name], expected_tensor) + + +def _checkpoint_payload() -> dict: + swarm = SwarmGraph(_tiny_swarm_config()) + policy = nn.Sequential( + nn.Linear(swarm.config.output_dim, 7), + nn.ReLU(), + nn.Linear(7, NUM_ACTIONS), + ) + return { + "schema_version": CHECKPOINT_SCHEMA_VERSION, + "swarm_config": swarm_config_to_dict(swarm.config), + "swarm_state": swarm.state_dict(), + "policy_head": { + "type": "mlp_relu", + "input_dim": swarm.config.output_dim, + "hidden_dim": 7, + "num_actions": NUM_ACTIONS, + "state_dict": dict(policy.state_dict()), + }, + "environment_config": {}, + } + + +def test_micro_agent_weights_only_roundtrip_resets_ephemeral_state(tmp_path): + config = _tiny_agent_config() + source = MicroAgent(agent_id=7, config=config) + source.forward(torch.randn(2, config.input_dim), []) + assert source.local_state is not None + + state = source.state_dict() + assert state["schema_version"] == AGENT_STATE_SCHEMA_VERSION + assert "local_state" not in state + assert "last_output" not in state + + checkpoint_path = tmp_path / "agent.pt" + torch.save(state, checkpoint_path) + loaded = torch.load(checkpoint_path, map_location="cpu", weights_only=True) + + restored = MicroAgent(agent_id=7, config=config) + restored.forward(torch.randn(1, config.input_dim), []) + assert restored.local_state is not None + restored.load_state_dict(loaded) + + _assert_agent_networks_equal(source, restored) + assert restored.local_state is None + assert restored.last_output is None + assert restored._batch_size is None + + +def test_swarm_weights_only_roundtrip_resets_all_agent_local_state(tmp_path): + config = _tiny_swarm_config() + source = SwarmGraph(config) + source.step(torch.randn(2, config.input_dim)) + assert all(agent.local_state is not None for agent in source.agents.values()) + + state = source.state_dict() + assert state["schema_version"] == SWARM_STATE_SCHEMA_VERSION + assert all("local_state" not in agent for agent in state["agents"].values()) + + checkpoint_path = tmp_path / "swarm.pt" + torch.save(state, checkpoint_path) + loaded = torch.load(checkpoint_path, map_location="cpu", weights_only=True) + + restored = SwarmGraph(config) + restored.step(torch.randn(1, config.input_dim)) + restored.load_state_dict(loaded) + + assert restored.state_dict()["graph_edges"] == loaded["graph_edges"] + for agent_id, source_agent in source.agents.items(): + _assert_agent_networks_equal(source_agent, restored.agents[agent_id]) + assert all(agent.local_state is None for agent in restored.agents.values()) + + +def test_micro_agent_rejects_config_mismatch(): + config = _tiny_agent_config() + state = MicroAgent(agent_id=0, config=config).state_dict() + mismatched = MicroAgent(agent_id=0, config=replace(config, hidden_dim=9)) + + with pytest.raises(ValueError, match="Agent config mismatch"): + mismatched.load_state_dict(state) + + +def test_swarm_rejects_config_mismatch(): + config = _tiny_swarm_config() + state = SwarmGraph(config).state_dict() + mismatched = SwarmGraph( + replace(config, message_passing_rounds=config.message_passing_rounds + 1) + ) + + with pytest.raises(ValueError, match="Swarm config mismatch"): + mismatched.load_state_dict(state) + + +@pytest.mark.parametrize("mutation", ["missing", "extra"]) +def test_swarm_rejects_missing_or_extra_agent_ids(mutation): + config = _tiny_swarm_config() + state = SwarmGraph(config).state_dict() + state["agents"] = dict(state["agents"]) + + if mutation == "missing": + state["agents"].pop(config.num_agents - 1) + else: + state["agents"][config.num_agents] = state["agents"][0] + + with pytest.raises(ValueError, match="swarm agent states IDs do not match"): + SwarmGraph(config).load_state_dict(state) + + +@pytest.mark.parametrize( + ("graph_edges", "message"), + [ + ([[0, 1], [1, 2], [2, 4]], "endpoint outside"), + ([[0, 1], [1, 0], [1, 2], [2, 3]], "duplicates edge"), + ([[0, 1], [2, 3]], "connected graph"), + ], + ids=["invalid-endpoint", "duplicate", "disconnected"], +) +def test_swarm_rejects_invalid_graph_edges(graph_edges, message): + config = _tiny_swarm_config() + state = SwarmGraph(config).state_dict() + state["graph_edges"] = graph_edges + + with pytest.raises(ValueError, match=message): + SwarmGraph(config).load_state_dict(state) + + +def test_checkpoint_reconstructs_policy_with_exactly_five_actions(tmp_path): + checkpoint_path = tmp_path / "checkpoint.pt" + torch.save(_checkpoint_payload(), checkpoint_path) + + checkpoint = load_checkpoint(checkpoint_path) + policy = build_policy_head(checkpoint["policy_head"], device="cpu") + logits = policy(torch.randn(3, checkpoint["policy_head"]["input_dim"])) + + assert logits.shape == (3, NUM_ACTIONS) + assert NUM_ACTIONS == 5 + + +def test_persisted_swarm_config_rejects_role_count_mismatch(): + serialized = swarm_config_to_dict(_tiny_swarm_config()) + serialized["num_planning"] = 0 + + with pytest.raises(ValueError, match="role counts must sum to num_agents"): + swarm_config_from_dict(serialized) + + +def test_persisted_swarm_config_rejects_combined_allocation_bomb(): + serialized = swarm_config_to_dict(_tiny_swarm_config()) + serialized["hidden_dim"] = 4096 + + with pytest.raises(ValueError, match="allocation budget"): + swarm_config_from_dict(serialized) + + +def test_policy_spec_rejects_bool_dimensions(): + specification = _checkpoint_payload()["policy_head"] + specification["input_dim"] = True + + with pytest.raises(TypeError, match="input_dim must be a native int"): + build_policy_head(specification, device="cpu") + + +def test_checkpoint_rejects_unexpected_envelope_field(tmp_path): + checkpoint = _checkpoint_payload() + checkpoint["unreviewed_extension"] = {"enabled": True} + checkpoint_path = tmp_path / "unexpected-field.pt" + torch.save(checkpoint, checkpoint_path) + + with pytest.raises(ValueError, match="unexpected fields"): + load_checkpoint(checkpoint_path) + + +def test_checkpoint_loader_rejects_vulnerable_torch_runtime(monkeypatch): + monkeypatch.setattr(checkpoint_utils.torch, "__version__", "2.9.1") + + with pytest.raises(RuntimeError, match="PyTorch 2.10.0 or newer"): + checkpoint_utils.require_restricted_loader_runtime() + + +def test_checkpoint_loader_hashes_the_bytes_it_loads(tmp_path): + checkpoint_path = tmp_path / "digest.pt" + torch.save(_checkpoint_payload(), checkpoint_path) + + _, loaded_path, digest = checkpoint_utils.load_bounded_weights_only( + checkpoint_path + ) + + assert loaded_path == checkpoint_path.resolve() + assert digest == hashlib.sha256(checkpoint_path.read_bytes()).hexdigest() + + +def test_checkpoint_loader_enforces_file_size_limit(tmp_path): + checkpoint_path = tmp_path / "size-limit.pt" + torch.save(_checkpoint_payload(), checkpoint_path) + + with pytest.raises(ValueError, match="evaluator limit"): + checkpoint_utils.load_bounded_weights_only( + checkpoint_path, + max_bytes=checkpoint_path.stat().st_size - 1, + ) + + +def test_evaluator_rejects_environment_without_worst_case_wall_capacity(): + config = EnvironmentConfig( + grid_size=12, + num_resources=96, + num_hazards=0, + num_agents=1, + vision_radius=1, + num_food=0, + num_water=0, + num_material=0, + ) + + with pytest.raises(ValueError, match="worst-case internal walls"): + build_environment(environment_config_to_dict(config)) + + +def test_evaluator_rejects_oversized_grid_before_allocation(): + config = environment_config_to_dict(EnvironmentConfig()) + config["grid_size"] = 129 + + with pytest.raises(ValueError, match="evaluation grid_size"): + build_environment(config) + + +def test_checkpoint_rejects_overlong_metadata(tmp_path): + checkpoint = _checkpoint_payload() + checkpoint["source_revision"] = "a" * 65_537 + checkpoint_path = tmp_path / "overlong-metadata.pt" + torch.save(checkpoint, checkpoint_path) + + with pytest.raises(ValueError, match="string-length limit"): + load_checkpoint(checkpoint_path) + + +@pytest.mark.parametrize("invalidity", ["old-schema", "missing-field"]) +def test_load_checkpoint_rejects_old_schema_or_missing_required_field( + tmp_path, invalidity +): + checkpoint = _checkpoint_payload() + if invalidity == "old-schema": + checkpoint["schema_version"] = CHECKPOINT_SCHEMA_VERSION - 1 + message = "unsupported checkpoint schema" + else: + checkpoint.pop("policy_head") + message = "missing required fields: policy_head" + + checkpoint_path = tmp_path / f"{invalidity}.pt" + torch.save(checkpoint, checkpoint_path) + + with pytest.raises(ValueError, match=message): + load_checkpoint(checkpoint_path) diff --git a/tests/test_environment_safety.py b/tests/test_environment_safety.py new file mode 100644 index 0000000..9576f67 --- /dev/null +++ b/tests/test_environment_safety.py @@ -0,0 +1,70 @@ +"""Regression tests for bounded environment placement.""" + +import pytest + +from src.environment.cosmos import ( + CosmosEnvironment, + EnvironmentConfig, + validate_environment_capacity, + worst_case_internal_wall_cells, +) + + +def _sparse_config(grid_size: int) -> EnvironmentConfig: + return EnvironmentConfig( + grid_size=grid_size, + num_resources=0, + num_hazards=0, + num_agents=1, + vision_radius=1, + num_food=0, + num_water=0, + num_material=0, + ) + + +@pytest.mark.parametrize("grid_size", [10, 11]) +def test_small_grid_wall_generation_has_no_invalid_length_range(grid_size): + environment = CosmosEnvironment(config=_sparse_config(grid_size)) + + assert environment.agents[0].position != environment.goal_position + + +def test_near_capacity_grid_reserves_agent_and_goal_cells(): + # A 12x12 grid has 100 interior cells. The exact fail-closed budget is + # three internal wall cells plus one agent and the goal, leaving 95 cells. + config = _sparse_config(12) + config.num_resources = 95 + environment = CosmosEnvironment(config=config) + + assert environment.agents[0].position != environment.goal_position + observations = environment.reset() + assert len(observations) == 1 + assert environment.agents[0].position != environment.goal_position + + +@pytest.mark.parametrize( + ("grid_size", "expected"), + [(10, 0), (11, 0), (12, 3), (32, 24), (64, 48)], +) +def test_worst_case_wall_budget_matches_generator(grid_size, expected): + assert worst_case_internal_wall_cells(grid_size) == expected + + +def test_capacity_rejects_counts_that_leave_no_worst_case_wall_budget(): + config = _sparse_config(12) + config.num_resources = 96 + + with pytest.raises(ValueError, match="worst-case internal walls"): + validate_environment_capacity(config) + + with pytest.raises(ValueError, match="worst-case internal walls"): + CosmosEnvironment(config=config) + + +def test_empty_position_fails_when_no_cell_is_available(): + environment = CosmosEnvironment(config=_sparse_config(8)) + environment.grid.fill(1) + + with pytest.raises(RuntimeError, match="no unreserved empty cell"): + environment._random_empty_position() diff --git a/tests/test_research_diagnostics.py b/tests/test_research_diagnostics.py new file mode 100644 index 0000000..ba03419 --- /dev/null +++ b/tests/test_research_diagnostics.py @@ -0,0 +1,37 @@ +"""Focused invariants for exploratory diagnostic utilities.""" + +import numpy as np + +from experiments.emergence_scaling_analysis import ( + AgentBehaviorProfile, + EmergenceAnalyzer, + _balanced_role_counts, +) + + +def test_balanced_role_counts_assign_every_agent_once(): + for num_agents in range(1, 25): + counts = _balanced_role_counts(num_agents) + assert sum(counts) == num_agents + assert max(counts) - min(counts) <= 1 + + +def test_cluster_count_reports_only_clusters_that_occurred(): + profiles = { + agent_id: AgentBehaviorProfile( + agent_id=agent_id, + agent_type="GENERAL", + action_distribution=np.full(5, 0.2), + output_mean=0.0, + output_std=0.0, + ) + for agent_id in range(4) + } + + labels, cluster_count = EmergenceAnalyzer().cluster_agents_into_roles( + profiles, + n_clusters=4, + ) + + assert cluster_count == len(set(labels)) + assert sorted(set(labels)) == list(range(1, cluster_count + 1)) From 3ad2c30eb43bb6530cb1d3912757c0c5af07183c Mon Sep 17 00:00:00 2001 From: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:00:23 -0500 Subject: [PATCH 2/2] test: remove untrained intent flake --- tests/test_checkpoint_safety.py | 2 +- tests/test_phase4.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_checkpoint_safety.py b/tests/test_checkpoint_safety.py index aad7207..deab65a 100644 --- a/tests/test_checkpoint_safety.py +++ b/tests/test_checkpoint_safety.py @@ -209,7 +209,7 @@ def test_persisted_swarm_config_rejects_role_count_mismatch(): serialized = swarm_config_to_dict(_tiny_swarm_config()) serialized["num_planning"] = 0 - with pytest.raises(ValueError, match="role counts must sum to num_agents"): + with pytest.raises(ValueError, match="role counts must sum exactly to num_agents"): swarm_config_from_dict(serialized) diff --git a/tests/test_phase4.py b/tests/test_phase4.py index ad8bfb7..5de5b51 100644 --- a/tests/test_phase4.py +++ b/tests/test_phase4.py @@ -365,7 +365,8 @@ def test_theory_of_mind_intents(self): model = tom.agent_models[0] assert len(model.action_history) == 10 - assert model.predicted_intent != IntentType.UNKNOWN + assert isinstance(model.predicted_intent, IntentType) + assert 0 <= model.intent_confidence <= 1 def test_perspective_taking(self): """Test perspective taking."""