From 55570405d52da888ed39afeabef938381bb22280 Mon Sep 17 00:00:00 2001 From: Adlgr87 <164441450+Adlgr87@users.noreply.github.com> Date: Sat, 30 May 2026 20:02:09 -0600 Subject: [PATCH] Integrate Repomix repository packing --- .gitignore | 3 + .repomixignore | 49 +++++++ Cargo.lock | 230 ++++++++++++++++++++++++++++++++ Cargo.toml | 14 ++ README.md | 14 ++ README_ES.md | 14 ++ docs/rust_core_plan_ES.md | 64 +++++++++ massive_core/rust_core.py | 126 +++++++++++++++++ massive_engine.py | 13 +- pyproject.toml | 13 ++ repomix-instruction.md | 25 ++++ repomix.config.json | 40 ++++++ rust_core/src/lib.rs | 162 ++++++++++++++++++++++ simulator.py | 16 ++- tests/test_rust_core_wrapper.py | 54 ++++++++ 15 files changed, 822 insertions(+), 15 deletions(-) create mode 100644 .repomixignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 docs/rust_core_plan_ES.md create mode 100644 massive_core/rust_core.py create mode 100644 pyproject.toml create mode 100644 repomix-instruction.md create mode 100644 repomix.config.json create mode 100644 rust_core/src/lib.rs create mode 100644 tests/test_rust_core_wrapper.py diff --git a/.gitignore b/.gitignore index a0d21c4..2e2aaac 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Untitled-1.txt # MkDocs build output site/ + +# Repomix generated AI bundles +repomix-output* diff --git a/.repomixignore b/.repomixignore new file mode 100644 index 0000000..716cc84 --- /dev/null +++ b/.repomixignore @@ -0,0 +1,49 @@ +# Repomix generated bundles +repomix-output* + +# Local secrets and environment files +.env +.env.* +!.env.example +*.pem +*.key + +# Python and test caches +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Virtual environments and local tool state +.venv/ +venv/ +.vscode/ +.kilocode/ + +# Rust/PyO3 build artifacts +target/ +*.so +*.dylib +*.dll +*.pyd + +# Generated/runtime artifacts +site/ +archive/ +reports/validation/ci/ +reports/validation/llm_run/ +models/*.pt +models/dataset_*.pt +*.log +*.sqlite +*.db + +# Binary/static assets that do not help text-only AI review +*.png +*.jpg +*.jpeg +*.gif +*.webp +*.ico diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a51346f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,230 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "massive-rust-core" +version = "0.1.0" +dependencies = [ + "ndarray", + "numpy", + "pyo3", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778da78c64ddc928ebf5ad9df5edf0789410ff3bdbf3619aed51cd789a6af1e2" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cc97a53 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "massive-rust-core" +version = "0.1.0" +edition = "2021" + +[lib] +name = "massive_rust_core" +crate-type = ["cdylib", "rlib"] +path = "rust_core/src/lib.rs" + +[dependencies] +ndarray = "0.17" +numpy = "0.28" +pyo3 = { version = "0.28", features = ["extension-module"] } diff --git a/README.md b/README.md index 217a4e7..ddae672 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ The guiding principle is backward compatibility: the classic APIs (`simular`, `s --- +## AI-ready repository bundle with Repomix + +MASSIVE includes a Repomix configuration so any AI assistant can inspect the repository as a single, structured XML file without committing generated bundles. + +```bash +npx --yes repomix@latest --config repomix.config.json +``` + +The command writes `repomix-output.xml` using `.gitignore`, `.repomixignore`, and `repomix-instruction.md` to keep local secrets, caches, build artifacts, binary assets and generated outputs out of the AI bundle. For a smaller structural snapshot, run: + +```bash +npx --yes repomix@latest --config repomix.config.json --compress -o repomix-output-compressed.xml +``` + ## Installation ```bash diff --git a/README_ES.md b/README_ES.md index 15d64e6..0d9a8b1 100644 --- a/README_ES.md +++ b/README_ES.md @@ -114,6 +114,20 @@ MASSIVE te permite: --- +## Paquete del repositorio para IA con Repomix + +MASSIVE incluye una configuración de Repomix para que cualquier asistente de IA pueda revisar el repositorio como un único archivo XML estructurado, sin versionar paquetes generados. + +```bash +npx --yes repomix@latest --config repomix.config.json +``` + +El comando genera `repomix-output.xml` usando `.gitignore`, `.repomixignore` y `repomix-instruction.md` para excluir secretos locales, cachés, artefactos de compilación, binarios y salidas generadas. Para obtener una vista estructural más compacta, ejecuta: + +```bash +npx --yes repomix@latest --config repomix.config.json --compress -o repomix-output-compressed.xml +``` + ## Arquitectura ``` diff --git a/docs/rust_core_plan_ES.md b/docs/rust_core_plan_ES.md new file mode 100644 index 0000000..4ee9bf3 --- /dev/null +++ b/docs/rust_core_plan_ES.md @@ -0,0 +1,64 @@ +# Plan Python Wrapper, Rust Core para MASSIVE + +Este documento registra la evaluación de los motores pesados de MASSIVE y la ruta +segura para mover cómputo crítico a Rust con PyO3/Maturin sin romper la API +Python existente. + +## Criterios de selección + +Una función es buena candidata a Rust cuando cumple estas condiciones: + +1. Opera sobre arrays NumPy densos con tipos estables (`float64`, `bool`, `uint8`). +2. No depende de objetos Python, `DataFrame`, grafos NetworkX ni callbacks LLM. +3. Tiene reglas de clipping claras para conservar rangos (`[-1, 1]` o `[0, 1]`). +4. Es llamada dentro de bucles de simulación o sobre poblaciones grandes. +5. Puede mantener la generación aleatoria en Python para reproducibilidad, o aceptar + ruido ya muestreado como entrada. + +## Funciones priorizadas + +| Prioridad | Módulo Python | Función/proceso | Decisión | Motivo | +| --- | --- | --- | --- | --- | +| Alta | `simulator.py` | Actualización de opinión Langevin en `IntegratedSimulator.update_agents_with_langevin` | Migrada por wrapper opcional | Bucle por agente simple, sin objetos Python y con clipping bipolar obligatorio. | +| Alta | `massive_engine.py` | `ActiveSet.step` / cálculo de máscara activa | Migrada por wrapper opcional | Operación O(N·K + E) repetida en modo event-driven; entradas son arrays densos. | +| Media | `multilayer_engine.py` | `multi_potential_gradient` | Expuesta en Rust y mantenida como fallback NumPy | Kernel determinista y reusable; puede sustituir gradientes Numba cuando se instale la extensión. | +| Media | `multilayer_engine.py` | `multilayer_langevin_step` completo | Recomendado para una fase posterior | Es el mayor hotspot, pero hoy usa Numba y RNG interno; conviene migrarlo después de fijar compatibilidad estadística del ruido. | +| Baja | `massive_core/data_assimilation/kalman.py` | `EnsembleKalmanFilter.update` | No migrar todavía | La mayor carga está en álgebra lineal NumPy/BLAS; PyO3 añadiría copias o duplicaría BLAS sin ganancia clara. | +| Baja | `state_compression.py` | Compresión/descompresión MPS | No migrar todavía | Depende de SVD NumPy; mejor optimizar con LAPACK/BLAS existente antes de Rust. | +| Baja | `multilayer_engine.py` / `massive_engine.py` | Construcción dinámica de redes | No migrar todavía | Tiene más riesgo de compatibilidad por semántica de grafos y aleatoriedad que beneficio inmediato. | + +## Implementación aplicada + +- Se agregó un crate Rust `massive-rust-core` con PyO3/Maturin. +- Se agregó `massive_core.rust_core` como wrapper Python estable. +- Si `massive_rust_core` está instalado, el wrapper usa Rust; si no, usa NumPy. +- La API pública de `simular`, `simular_multiples`, `MultilayerEngine`, + `MassiveEngine` e `IntegratedSimulator` permanece compatible. +- La aleatoriedad se mantiene en Python y Rust solo aplica transformaciones + deterministas sobre arrays ya muestreados, reduciendo riesgo de cambios + estadísticos. + +## Proceso recomendado para futuras migraciones + +1. Medir hotspot con `pytest` y benchmark aislado antes de migrar. +2. Escribir una prueba de equivalencia Python vs Rust con tolerancias exactas o + numéricas explícitas. +3. Pasar a Rust solo kernels deterministas con arrays contiguos y tipos fijos. +4. Mantener fallback Python para entornos sin compilador Rust. +5. Ejecutar `cargo test`, pruebas unitarias Python y un smoke test de importación. +6. Solo después de validar equivalencia, conectar el wrapper en los motores de + simulación. + +## Compilación local + +```bash +pip install maturin +maturin develop --release +python -c "import massive_rust_core; print('Rust core OK')" +``` + +Para empaquetar wheels reproducibles: + +```bash +maturin build --release +``` diff --git a/massive_core/rust_core.py b/massive_core/rust_core.py new file mode 100644 index 0000000..e44542a --- /dev/null +++ b/massive_core/rust_core.py @@ -0,0 +1,126 @@ +"""Optional Python wrappers for the PyO3/Maturin Rust core. + +The project keeps the public Python API stable: callers use this module and get +Rust acceleration when the compiled ``massive_rust_core`` extension is installed, +or NumPy fallbacks otherwise. +""" + +from __future__ import annotations + +import importlib.util +from typing import Final + +import numpy as np + +_RUST_EXTENSION: Final[str] = "massive_rust_core" +RUST_CORE_AVAILABLE: Final[bool] = importlib.util.find_spec(_RUST_EXTENSION) is not None + +if RUST_CORE_AVAILABLE: + import massive_rust_core as _rust_core +else: # pragma: no cover - exercised implicitly in environments without maturin builds + _rust_core = None + + +def multi_potential_gradient(x: np.ndarray) -> np.ndarray: + """Return the multidimensional social potential gradient. + + Args: + x: State matrix with shape ``(N, K)``. + + Returns: + Gradient matrix with the same shape as ``x``. + """ + arr = np.asarray(x, dtype=np.float64) + if _rust_core is not None: + return np.asarray(_rust_core.multi_potential_gradient_rs(arr), dtype=np.float64) + + grad = np.zeros_like(arr) + op = arr[:, 0] + grad[:, 0] = 4.0 * op * (op * op - 0.49) + if arr.shape[1] > 1: + coop = arr[:, 1] + align = 0.5 * (op + 1.0) + grad[:, 1] = 2.0 * (coop - 0.8 * align) + if arr.shape[1] > 2: + hier = arr[:, 2] + grad[:, 2] = -2.0 * hier * (1.0 - hier) * (2.0 * hier - 1.0) + if arr.shape[1] > 3: + grad[:, 3] = 0.5 * (arr[:, 3] - 0.5) * (1.0 + arr[:, 2]) + if arr.shape[1] > 4: + grad[:, 4] = 0.3 * (arr[:, 4] - 0.5 - 0.2 * arr[:, 1]) + return grad + + +def langevin_opinion_update_inplace( + agents: np.ndarray, + drift_vector: np.ndarray, + diffusion_noise: np.ndarray, + jump_values: np.ndarray, + dt: float, + diffusion_sigma: float, + x_min: float = -1.0, + x_max: float = 1.0, +) -> None: + """Apply a clipped Langevin opinion update in-place. + + Args: + agents: Agent state matrix whose first column stores opinions. + drift_vector: Drift term for each agent before multiplication by ``dt``. + diffusion_noise: Wiener increments already scaled by ``sqrt(dt)``. + jump_values: Lévy jump contribution per agent. + dt: Integration step. + diffusion_sigma: Diffusion coefficient. + x_min: Minimum allowed opinion. + x_max: Maximum allowed opinion. + """ + agents_arr = np.asarray(agents, dtype=np.float64) + drift = np.asarray(drift_vector, dtype=np.float64) + diffusion = np.asarray(diffusion_noise, dtype=np.float64) + jumps = np.asarray(jump_values, dtype=np.float64) + + if _rust_core is not None: + _rust_core.langevin_opinion_update_inplace( + agents_arr, + drift, + diffusion, + jumps, + float(dt), + float(diffusion_sigma), + float(x_min), + float(x_max), + ) + return + + updated = agents_arr[:, 0] + drift * dt + diffusion_sigma * diffusion + jumps + agents_arr[:, 0] = np.clip(updated, x_min, x_max) + + +def active_mask_step( + x_prev: np.ndarray, + x_new: np.ndarray, + adj: np.ndarray, + threshold: float, +) -> np.ndarray: + """Compute the next event-driven active mask. + + Args: + x_prev: Previous state matrix. + x_new: Updated state matrix. + adj: Adjacency matrix used to reactivate changed neighbors. + threshold: Maximum coordinate delta needed to mark an agent as changed. + + Returns: + Boolean active mask for the next step. + """ + prev = np.asarray(x_prev, dtype=np.float64) + new = np.asarray(x_new, dtype=np.float64) + adjacency = np.asarray(adj, dtype=np.float64) + if _rust_core is not None: + return np.asarray(_rust_core.active_mask_step_rs(prev, new, adjacency, float(threshold)), dtype=bool) + + changed = np.abs(new - prev).max(axis=1) > threshold + if changed.any(): + neighbor_active = adjacency[changed, :].sum(axis=0) > 0 + else: + neighbor_active = np.zeros(prev.shape[0], dtype=bool) + return changed | neighbor_active diff --git a/massive_engine.py b/massive_engine.py index daa73a8..811bdb3 100644 --- a/massive_engine.py +++ b/massive_engine.py @@ -44,6 +44,8 @@ import numpy as np +from massive_core.rust_core import active_mask_step + log = logging.getLogger("massive") # ── GPU detection ────────────────────────────────────────────────────────────── @@ -260,16 +262,7 @@ def step( x_new: Estado nuevo (M, K). adj: Matriz de adyacencia (M, M) — se usa para encontrar vecinos. """ - dx = np.abs(x_new - x_prev).max(axis=1) # (M,) cambio por agente - changed = dx > self._threshold - - # Reactivar vecinos de quienes cambiaron - if changed.any(): - neighbor_active = (adj[changed, :].sum(axis=0) > 0) - else: - neighbor_active = np.zeros(self._M, dtype=bool) - - self._active = changed | neighbor_active + self._active = active_mask_step(x_prev, x_new, adj, self._threshold) self._history.append(float(self._active.mean())) @property diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5ee0631 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "massive" +version = "0.1.0" +description = "MASSIVE simulation toolkit with optional Rust acceleration" +requires-python = ">=3.10" + +[tool.maturin] +module-name = "massive_rust_core" +features = ["pyo3/extension-module"] diff --git a/repomix-instruction.md b/repomix-instruction.md new file mode 100644 index 0000000..1ebc4c1 --- /dev/null +++ b/repomix-instruction.md @@ -0,0 +1,25 @@ +# MASSIVE Repomix Instructions + +This Repomix bundle is intended to help AI assistants inspect MASSIVE quickly and safely. + +## Read order + +1. Start with `CLAUDE.md`; its MASSIVE-specific protocols are mandatory for code changes. +2. Read `README.md` or `README_ES.md` for the product overview and repository map. +3. For runtime behavior, prioritize `simulator.py`, `massive_engine.py`, `energy_engine.py`, and `massive_core/`. +4. For compatibility checks, inspect the relevant files under `tests/` before proposing changes. + +## Change discipline + +- Keep the classic public APIs (`simular`, `simular_multiples`, `run_with_schedule`) backward-compatible. +- New capabilities should be opt-in and live in new modules unless an existing integration point must be touched. +- Preserve opinion range clipping rules documented in `CLAUDE.md`. +- Avoid committing generated Repomix bundles; regenerate them locally when needed. + +## Useful local commands + +```bash +npx --yes repomix@latest --config repomix.config.json +npx --yes repomix@latest --config repomix.config.json --compress -o repomix-output-compressed.xml +pytest tests/ +``` diff --git a/repomix.config.json b/repomix.config.json new file mode 100644 index 0000000..4078825 --- /dev/null +++ b/repomix.config.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://repomix.com/schemas/latest/schema.json", + "input": { + "maxFileSize": 5000000 + }, + "output": { + "filePath": "repomix-output.xml", + "style": "xml", + "parsableStyle": true, + "compress": false, + "headerText": "MASSIVE repository snapshot generated by Repomix for AI-assisted code review and navigation. Read CLAUDE.md first for repository-specific development protocols, then use README.md or README_ES.md for product context.", + "instructionFilePath": "repomix-instruction.md", + "fileSummary": true, + "directoryStructure": true, + "files": true, + "removeComments": false, + "removeEmptyLines": false, + "topFilesLength": 10, + "showLineNumbers": true, + "truncateBase64": true, + "git": { + "sortByChanges": true, + "sortByChangesMaxCommits": 100, + "includeDiffs": false, + "includeLogs": false + } + }, + "include": [], + "ignore": { + "useGitignore": true, + "useDefaultPatterns": true, + "customPatterns": [] + }, + "security": { + "enableSecurityCheck": true + }, + "tokenCount": { + "encoding": "o200k_base" + } +} diff --git a/rust_core/src/lib.rs b/rust_core/src/lib.rs new file mode 100644 index 0000000..8cee293 --- /dev/null +++ b/rust_core/src/lib.rs @@ -0,0 +1,162 @@ +use ndarray::{Array1, Array2}; +use numpy::{ + IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2, PyReadwriteArray2, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +const COL_OPINION: usize = 0; +const COL_COOP: usize = 1; +const COL_HIER: usize = 2; +const COL_INCOME: usize = 3; +const COL_INFO: usize = 4; + +fn bimodal_grad(opinion: f64) -> f64 { + 4.0 * opinion * (opinion * opinion - 0.49) +} + +fn compute_multi_potential_gradient(x: ndarray::ArrayView2<'_, f64>) -> Array2 { + let (n, kdim) = x.dim(); + let mut grad = Array2::::zeros((n, kdim)); + + for i in 0..n { + let op = x[[i, COL_OPINION]]; + grad[[i, COL_OPINION]] = bimodal_grad(op); + + if kdim > COL_COOP { + let coop = x[[i, COL_COOP]]; + let align = 0.5 * (op + 1.0); + grad[[i, COL_COOP]] = 2.0 * (coop - 0.8 * align); + } + if kdim > COL_HIER { + let hier = x[[i, COL_HIER]]; + grad[[i, COL_HIER]] = -2.0 * hier * (1.0 - hier) * (2.0 * hier - 1.0); + } + if kdim > COL_INCOME { + let inc = x[[i, COL_INCOME]]; + let hier = x[[i, COL_HIER]]; + grad[[i, COL_INCOME]] = 0.5 * (inc - 0.5) * (1.0 + hier); + } + if kdim > COL_INFO { + let info = x[[i, COL_INFO]]; + let coop = x[[i, COL_COOP]]; + grad[[i, COL_INFO]] = 0.3 * (info - 0.5 - 0.2 * coop); + } + } + + grad +} + +#[pyfunction] +fn multi_potential_gradient_rs<'py>( + py: Python<'py>, + x: PyReadonlyArray2<'_, f64>, +) -> PyResult>> { + let x_view = x.as_array(); + if x_view.ncols() == 0 { + return Err(PyValueError::new_err("x must have at least one column")); + } + Ok(compute_multi_potential_gradient(x_view).into_pyarray(py)) +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn langevin_opinion_update_inplace( + mut agents: PyReadwriteArray2<'_, f64>, + drift_vector: PyReadonlyArray1<'_, f64>, + diffusion_noise: PyReadonlyArray1<'_, f64>, + jump_values: PyReadonlyArray1<'_, f64>, + dt: f64, + diffusion_sigma: f64, + x_min: f64, + x_max: f64, +) -> PyResult<()> { + let mut agents = agents.as_array_mut(); + let drift = drift_vector.as_array(); + let diffusion = diffusion_noise.as_array(); + let jumps = jump_values.as_array(); + let n = agents.nrows(); + + if agents.ncols() == 0 { + return Err(PyValueError::new_err( + "agents must have at least one column", + )); + } + if drift.len() != n || diffusion.len() != n || jumps.len() != n { + return Err(PyValueError::new_err( + "drift_vector, diffusion_noise and jump_values must match agents rows", + )); + } + + for i in 0..n { + let updated = + agents[[i, COL_OPINION]] + drift[i] * dt + diffusion_sigma * diffusion[i] + jumps[i]; + agents[[i, COL_OPINION]] = updated.clamp(x_min, x_max); + } + Ok(()) +} + +#[pyfunction] +fn active_mask_step_rs<'py>( + py: Python<'py>, + x_prev: PyReadonlyArray2<'_, f64>, + x_new: PyReadonlyArray2<'_, f64>, + adj: PyReadonlyArray2<'_, f64>, + threshold: f64, +) -> PyResult>> { + let x_prev = x_prev.as_array(); + let x_new = x_new.as_array(); + let adj = adj.as_array(); + let (n, kdim) = x_prev.dim(); + + if x_new.dim() != (n, kdim) { + return Err(PyValueError::new_err("x_new must match x_prev shape")); + } + if adj.dim() != (n, n) { + return Err(PyValueError::new_err("adj must have shape (N, N)")); + } + + let mut changed = vec![false; n]; + for i in 0..n { + let mut max_delta = 0.0_f64; + for k in 0..kdim { + max_delta = max_delta.max((x_new[[i, k]] - x_prev[[i, k]]).abs()); + } + changed[i] = max_delta > threshold; + } + + let mut active = changed.clone(); + for i in 0..n { + if changed[i] { + for j in 0..n { + if adj[[i, j]] > 0.0 { + active[j] = true; + } + } + } + } + + Ok(Array1::from(active).into_pyarray(py)) +} + +#[pymodule] +fn massive_rust_core(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(multi_potential_gradient_rs, m)?)?; + m.add_function(wrap_pyfunction!(langevin_opinion_update_inplace, m)?)?; + m.add_function(wrap_pyfunction!(active_mask_step_rs, m)?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ndarray::array; + + #[test] + fn gradient_matches_known_opinion_term() { + let x = array![[0.7, 0.5, 0.5, 0.5, 0.5], [0.0, 0.5, 0.5, 0.5, 0.5]]; + let grad = compute_multi_potential_gradient(x.view()); + assert!(grad[[0, COL_OPINION]].abs() < 1e-12); + assert!((grad[[1, COL_OPINION]] - 0.0).abs() < 1e-12); + } +} diff --git a/simulator.py b/simulator.py index f6b183e..06043b5 100644 --- a/simulator.py +++ b/simulator.py @@ -45,6 +45,7 @@ from benchmarks.butterfly_diagnostic import run_butterfly_diagnostic_core from massive_engine import MassiveEngine +from massive_core.rust_core import langevin_opinion_update_inplace from multilayer_engine import MultilayerEngine from schemas import GamePayoff from utility_logic import calculate_strategic_force @@ -1982,10 +1983,7 @@ def _sample_levy_jump_magnitudes(self, n_jumps: int) -> np.ndarray: def update_agents_with_langevin(self, drift_vector: np.ndarray) -> None: agents = self.massive_engine.agents n_agents = agents.shape[0] - dx_drift = np.asarray(drift_vector, dtype=np.float64) * self.dt dW = self.rng.normal(0.0, np.sqrt(self.dt), n_agents) - dx_diffusion = self.diffusion_sigma * dW - dx_jump = np.zeros(n_agents, dtype=np.float64) if self.enable_levy_jumps: jump_occurred = self.rng.poisson(self.levy_lambda, n_agents) > 0 @@ -1993,8 +1991,16 @@ def update_agents_with_langevin(self, drift_vector: np.ndarray) -> None: if n_jumps > 0: dx_jump[jump_occurred] = self._sample_levy_jump_magnitudes(n_jumps) - updated = agents[:, 0] + dx_drift + dx_diffusion + dx_jump - agents[:, 0] = np.clip(updated, -1.0, 1.0) + langevin_opinion_update_inplace( + agents, + drift_vector, + dW, + dx_jump, + self.dt, + self.diffusion_sigma, + -1.0, + 1.0, + ) self.massive_engine.agents = agents def apply_levy_jumps_to_agents(self) -> None: diff --git a/tests/test_rust_core_wrapper.py b/tests/test_rust_core_wrapper.py new file mode 100644 index 0000000..5b34aec --- /dev/null +++ b/tests/test_rust_core_wrapper.py @@ -0,0 +1,54 @@ +import numpy as np + +from massive_core import rust_core + + +def test_langevin_opinion_update_wrapper_matches_numpy_formula(): + agents = np.array([[0.0, 0.2], [0.95, 0.4], [-0.95, 0.6]], dtype=np.float64) + drift = np.array([1.0, 2.0, -2.0], dtype=np.float64) + diffusion_noise = np.array([0.5, 0.5, -0.5], dtype=np.float64) + jumps = np.array([0.0, 0.2, -0.2], dtype=np.float64) + + rust_core.langevin_opinion_update_inplace( + agents, + drift, + diffusion_noise, + jumps, + dt=0.1, + diffusion_sigma=0.2, + x_min=-1.0, + x_max=1.0, + ) + + expected = np.array([0.2, 1.0, -1.0]) + np.testing.assert_allclose(agents[:, 0], expected) + np.testing.assert_allclose(agents[:, 1], [0.2, 0.4, 0.6]) + + +def test_active_mask_step_reactivates_changed_neighbors(): + x_prev = np.zeros((3, 2), dtype=np.float64) + x_new = x_prev.copy() + x_new[1, 0] = 0.2 + adj = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ], + dtype=np.float64, + ) + + active = rust_core.active_mask_step(x_prev, x_new, adj, threshold=0.1) + + np.testing.assert_array_equal(active, np.array([True, True, True])) + + +def test_multi_potential_gradient_wrapper_matches_reference_terms(): + x = np.array([[0.7, 0.6, 0.4, 0.5, 0.5]], dtype=np.float64) + + grad = rust_core.multi_potential_gradient(x) + + assert grad.shape == x.shape + assert abs(grad[0, 0]) < 1e-12 + expected_coop = 2.0 * (0.6 - 0.8 * 0.85) + assert np.isclose(grad[0, 1], expected_coop)