feat: dominium standalone — simulador de campo medio sobre Llimphi
Front-door publicable de dominium: los 9 crates propios como path members; Llimphi, app-bus, rimay-localize, wawa-config y pluma-notebook por git-dep al monorepo tawasuyu.git (branch=main). cargo check --workspace --all-targets pasa exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "dominium-physics"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "dominium — ciclo del motor: difusión + entropía de los campos + tick completo (transiciones, acciones, envejecimiento). Determinista bit-exacto."
|
||||
|
||||
[dependencies]
|
||||
dominium-core = { path = "../dominium-core" }
|
||||
libm = { workspace = true }
|
||||
@@ -0,0 +1,25 @@
|
||||
# dominium-physics
|
||||
|
||||
> Tick determinista de 6 fases para [dominium](../README.md).
|
||||
|
||||
Cada `tick()` corre las 6 fases en orden fijo:
|
||||
|
||||
1. **Difusión** de capas (`materia`, `psique`, `poder`).
|
||||
2. **Decay** exponencial por capa.
|
||||
3. **Acoplamiento ψ↔acción endógeno** (Fase A): el campo `psique` modula bias de decisión de los agentes, y la acción de los agentes inyecta de vuelta en `psique`.
|
||||
4. **Conceptos**: emisores activos inyectan/drenan capas según su radio + mods.
|
||||
5. **Agentes**: decisión + ejecución de las 6 acciones atómicas.
|
||||
6. **Invariantes**: validación final (masa conservada, capas no-negativas).
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
use dominium_physics::tick;
|
||||
|
||||
tick(&mut world);
|
||||
```
|
||||
|
||||
## Deps
|
||||
|
||||
- [`dominium-core`](../dominium-core/README.md)
|
||||
- `libm`
|
||||
@@ -0,0 +1,25 @@
|
||||
# dominium-physics
|
||||
|
||||
> Deterministic 6-phase tick for [dominium](../README.md).
|
||||
|
||||
Each `tick()` runs 6 phases in fixed order:
|
||||
|
||||
1. **Diffusion** of layers (`materia`, `psique`, `poder`).
|
||||
2. **Exponential decay** per layer.
|
||||
3. **Endogenous ψ↔action coupling** (Phase A): the `psique` field modulates agent decision bias, and agent action injects back into `psique`.
|
||||
4. **Concepts**: active emitters inject/drain layers based on their radius + mods.
|
||||
5. **Agents**: decision + execution of the 6 atomic actions.
|
||||
6. **Invariants**: final validation (mass conserved, non-negative layers).
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
use dominium_physics::tick;
|
||||
|
||||
tick(&mut world);
|
||||
```
|
||||
|
||||
## Deps
|
||||
|
||||
- [`dominium-core`](../dominium-core/README.md)
|
||||
- `libm`
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Aplicación de Conceptos sobre la grilla y sobre los Lemmings.
|
||||
//!
|
||||
//! Dos pasos puros, sin estado interno, recorriendo la `Vec<Concepto>` en
|
||||
//! el orden de inserción. Determinista bit-exacto.
|
||||
//!
|
||||
//! - [`apply_conceptos`] — emite/drena los modificadores de cada concepto
|
||||
//! sobre las celdas dentro de su radio, con falloff lineal.
|
||||
//! - [`apply_hacks`] — decrementa los locks vivos y, para los lemmings
|
||||
//! libres dentro de un radio con `hack` cuyo `trigger` se cumple, fuerza
|
||||
//! `accion` y arranca el lock.
|
||||
|
||||
use dominium_core::{Trigger, World};
|
||||
|
||||
/// Empuja el `vector_psi` de los lemmings dentro del radio de cualquier
|
||||
/// Concepto con `persuasion: Some(_)` hacia su `target_psi`, con tasa
|
||||
/// modulada por el falloff lineal (1 en el centro, 0 en el borde).
|
||||
///
|
||||
/// Esta fase NO bloquea acción (no toca `hack_lock` ni `accion`) — la
|
||||
/// persuasión es ortogonal al hack coercitivo. Un Concepto puede ejercer
|
||||
/// ambas: persuadir y, si entra el trigger del hack, capturar.
|
||||
///
|
||||
/// Determinismo: iteración lineal `(concepto, agente)` por índices,
|
||||
/// `libm::sqrtf` para el falloff (bit-exacto cross-platform).
|
||||
pub fn apply_persuasion(world: &mut World) {
|
||||
for c in &world.conceptos.items {
|
||||
let Some(per) = &c.persuasion else { continue };
|
||||
if c.radius <= 0.0 || per.rate <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let r2 = c.radius * c.radius;
|
||||
for i in 0..world.lemmings.len() {
|
||||
let dx = world.lemmings.pos_x[i] - c.pos_x;
|
||||
let dy = world.lemmings.pos_y[i] - c.pos_y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 > r2 {
|
||||
continue;
|
||||
}
|
||||
// Mismo falloff que `apply_conceptos`: lineal sobre la
|
||||
// distancia normalizada.
|
||||
let falloff = 1.0 - libm::sqrtf(d2 / r2);
|
||||
let pull = per.rate * falloff;
|
||||
for k in 0..4 {
|
||||
let cur = world.lemmings.vector_psi[i][k];
|
||||
let target = per.target_psi[k];
|
||||
world.lemmings.vector_psi[i][k] = cur + pull * (target - cur);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suma los modificadores de cada concepto a las celdas dentro de su radio,
|
||||
/// con falloff lineal (1 en el centro, 0 en el borde).
|
||||
///
|
||||
/// Recorre los conceptos en orden de inserción y las celdas en orden
|
||||
/// `(y, x)` para que la simulación sea bit-exacta plataforma a plataforma.
|
||||
pub fn apply_conceptos(world: &mut World) {
|
||||
let w = world.grid.width;
|
||||
let h = world.grid.height;
|
||||
for c in &world.conceptos.items {
|
||||
if c.radius <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let r2 = c.radius * c.radius;
|
||||
// Ventana acotada de celdas a inspeccionar.
|
||||
let xmin = ((c.pos_x - c.radius).floor() as i64).max(0) as usize;
|
||||
let xmax_raw = ((c.pos_x + c.radius).ceil() as i64).max(0) as usize;
|
||||
let xmax = xmax_raw.min(w.saturating_sub(1));
|
||||
let ymin = ((c.pos_y - c.radius).floor() as i64).max(0) as usize;
|
||||
let ymax_raw = ((c.pos_y + c.radius).ceil() as i64).max(0) as usize;
|
||||
let ymax = ymax_raw.min(h.saturating_sub(1));
|
||||
if xmin >= w || ymin >= h {
|
||||
continue;
|
||||
}
|
||||
for cy in ymin..=ymax {
|
||||
for cx in xmin..=xmax {
|
||||
let dx = cx as f32 - c.pos_x;
|
||||
let dy = cy as f32 - c.pos_y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 > r2 {
|
||||
continue;
|
||||
}
|
||||
let falloff = 1.0 - libm::sqrtf(d2 / r2);
|
||||
let idx = world.grid.idx(cx, cy);
|
||||
world.grid.materia[idx] += c.mods.materia * falloff;
|
||||
world.grid.psique[idx] += c.mods.psique * falloff;
|
||||
world.grid.poder[idx] += c.mods.poder * falloff;
|
||||
world.grid.oro[idx] += c.mods.oro * falloff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrementa los locks activos y arranca nuevos en los Lemmings que
|
||||
/// caigan dentro del radio de un concepto con `hack` cuyo `trigger` se
|
||||
/// cumple. La acción forzada vence cualquier transición posterior del
|
||||
/// motor (incluida la desesperación → pelear).
|
||||
///
|
||||
/// Determinismo: orden `(lemming, concepto)` por índice; ante varios
|
||||
/// conceptos que capturen al mismo lemming, gana el de menor índice.
|
||||
pub fn apply_hacks(world: &mut World) {
|
||||
let n = world.lemmings.len();
|
||||
// 1. Decrementar locks vivos. El lemming sigue ejecutando la acción
|
||||
// forzada porque su byte `accion` ya está fijado.
|
||||
for i in 0..n {
|
||||
if world.lemmings.hack_lock[i] > 0 {
|
||||
world.lemmings.hack_lock[i] -= 1;
|
||||
}
|
||||
}
|
||||
// 2. Capturar lemmings libres que entren al radio de un concepto.
|
||||
for i in 0..n {
|
||||
if world.lemmings.hack_lock[i] > 0 {
|
||||
continue;
|
||||
}
|
||||
for c in &world.conceptos.items {
|
||||
let Some(h) = &c.hack else { continue };
|
||||
let dx = world.lemmings.pos_x[i] - c.pos_x;
|
||||
let dy = world.lemmings.pos_y[i] - c.pos_y;
|
||||
if dx * dx + dy * dy > c.radius * c.radius {
|
||||
continue;
|
||||
}
|
||||
let fires = match h.trigger {
|
||||
Trigger::Always => true,
|
||||
Trigger::EnergiaBajo(e) => world.lemmings.energia[i] < e,
|
||||
Trigger::EdadSobre(a) => world.lemmings.edad[i] > a,
|
||||
};
|
||||
if fires {
|
||||
world.lemmings.accion[i] = h.forced_action;
|
||||
world.lemmings.hack_lock[i] = h.duration;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dominium_core::{BehaviorHack, Concepto, LayerMods, Trigger, World};
|
||||
|
||||
fn empty_world(w: usize, h: usize) -> World {
|
||||
World::new(w, h)
|
||||
}
|
||||
|
||||
fn concepto(id: &str, x: f32, y: f32, r: f32, mods: LayerMods) -> Concepto {
|
||||
Concepto {
|
||||
id: id.into(),
|
||||
sprite_id: 0,
|
||||
pos_x: x,
|
||||
pos_y: y,
|
||||
radius: r,
|
||||
mods,
|
||||
hack: None,
|
||||
persuasion: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concepto_inyecta_psique_en_su_centro() {
|
||||
let mut w = empty_world(8, 8);
|
||||
w.conceptos.add(concepto(
|
||||
"iglesia",
|
||||
4.0,
|
||||
4.0,
|
||||
2.0,
|
||||
LayerMods { psique: 1.0, ..Default::default() },
|
||||
));
|
||||
let center = w.grid.idx(4, 4);
|
||||
apply_conceptos(&mut w);
|
||||
assert!((w.grid.psique[center] - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falloff_decae_hacia_el_borde() {
|
||||
let mut w = empty_world(16, 16);
|
||||
w.conceptos.add(concepto(
|
||||
"fuente",
|
||||
8.0,
|
||||
8.0,
|
||||
4.0,
|
||||
LayerMods { materia: 1.0, ..Default::default() },
|
||||
));
|
||||
apply_conceptos(&mut w);
|
||||
let center = w.grid.idx(8, 8);
|
||||
let halfway = w.grid.idx(10, 8);
|
||||
let edge = w.grid.idx(12, 8); // distancia 4 = radius → falloff = 0
|
||||
assert!(w.grid.materia[center] > w.grid.materia[halfway]);
|
||||
assert!(w.grid.materia[halfway] > 0.0);
|
||||
assert!(w.grid.materia[edge].abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conceptos_no_afectan_celdas_fuera_del_radio() {
|
||||
let mut w = empty_world(20, 20);
|
||||
w.conceptos.add(concepto(
|
||||
"compacto",
|
||||
10.0,
|
||||
10.0,
|
||||
2.0,
|
||||
LayerMods { oro: 1.0, ..Default::default() },
|
||||
));
|
||||
apply_conceptos(&mut w);
|
||||
let lejos = w.grid.idx(0, 0);
|
||||
assert!(w.grid.oro[lejos].abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drenar_baja_el_campo() {
|
||||
let mut w = empty_world(8, 8);
|
||||
let center = w.grid.idx(4, 4);
|
||||
w.grid.materia[center] = 10.0;
|
||||
w.conceptos.add(concepto(
|
||||
"agujero",
|
||||
4.0,
|
||||
4.0,
|
||||
1.0,
|
||||
LayerMods { materia: -2.0, ..Default::default() },
|
||||
));
|
||||
apply_conceptos(&mut w);
|
||||
assert!(w.grid.materia[center] < 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hack_captura_lemming_y_le_fija_accion() {
|
||||
let mut w = empty_world(20, 20);
|
||||
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
|
||||
w.lemmings.accion[0] = 0; // Mover
|
||||
w.conceptos.add(Concepto {
|
||||
id: "iglesia".into(),
|
||||
sprite_id: 0,
|
||||
pos_x: 10.0,
|
||||
pos_y: 10.0,
|
||||
radius: 3.0,
|
||||
mods: LayerMods::default(),
|
||||
hack: Some(BehaviorHack {
|
||||
trigger: Trigger::Always,
|
||||
forced_action: 2, // Sincronizar
|
||||
duration: 10,
|
||||
}),
|
||||
persuasion: None,
|
||||
});
|
||||
apply_hacks(&mut w);
|
||||
assert_eq!(w.lemmings.accion[0], 2);
|
||||
assert_eq!(w.lemmings.hack_lock[0], 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hack_con_trigger_no_cumplido_no_dispara() {
|
||||
let mut w = empty_world(20, 20);
|
||||
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]); // energía 30
|
||||
w.lemmings.accion[0] = 0;
|
||||
w.conceptos.add(Concepto {
|
||||
id: "soup-kitchen".into(),
|
||||
sprite_id: 0,
|
||||
pos_x: 10.0,
|
||||
pos_y: 10.0,
|
||||
radius: 3.0,
|
||||
mods: LayerMods::default(),
|
||||
hack: Some(BehaviorHack {
|
||||
trigger: Trigger::EnergiaBajo(10.0),
|
||||
forced_action: 2,
|
||||
duration: 5,
|
||||
}),
|
||||
persuasion: None,
|
||||
});
|
||||
apply_hacks(&mut w);
|
||||
assert_eq!(w.lemmings.accion[0], 0); // sigue moviéndose
|
||||
assert_eq!(w.lemmings.hack_lock[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hack_lock_decrementa_y_no_resnatura_si_lock_vive() {
|
||||
let mut w = empty_world(20, 20);
|
||||
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
|
||||
w.lemmings.accion[0] = 2;
|
||||
w.lemmings.hack_lock[0] = 3;
|
||||
// Concepto presente con hack — pero el lemming ya está locked.
|
||||
w.conceptos.add(Concepto {
|
||||
id: "x".into(),
|
||||
sprite_id: 0,
|
||||
pos_x: 10.0,
|
||||
pos_y: 10.0,
|
||||
radius: 3.0,
|
||||
mods: LayerMods::default(),
|
||||
hack: Some(BehaviorHack {
|
||||
trigger: Trigger::Always,
|
||||
forced_action: 5,
|
||||
duration: 10,
|
||||
}),
|
||||
persuasion: None,
|
||||
});
|
||||
apply_hacks(&mut w);
|
||||
// El lock baja a 2, la acción se mantiene en 2 (no se re-evaluó).
|
||||
assert_eq!(w.lemmings.hack_lock[0], 2);
|
||||
assert_eq!(w.lemmings.accion[0], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persuasion_arrastra_psi_hacia_target_y_no_toca_accion() {
|
||||
// Iglesia ortodoxa en (10, 10), radio 5, persuade psi → [1,0.5,0,0]
|
||||
// con rate=0.20. Lemming en el centro con psi=[0,0,0,0] hace accion=0.
|
||||
let mut w = empty_world(20, 20);
|
||||
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0, 0.0, 0.0, 0.0]);
|
||||
w.lemmings.accion[0] = 0; // Mover
|
||||
w.conceptos.add(Concepto {
|
||||
id: "iglesia-ortodoxa".into(),
|
||||
sprite_id: 0,
|
||||
pos_x: 10.0,
|
||||
pos_y: 10.0,
|
||||
radius: 5.0,
|
||||
mods: LayerMods::default(),
|
||||
hack: None,
|
||||
persuasion: Some(dominium_core::Persuasion {
|
||||
target_psi: [1.0, 0.5, 0.0, 0.0],
|
||||
rate: 0.20,
|
||||
}),
|
||||
});
|
||||
apply_persuasion(&mut w);
|
||||
// En el centro, falloff = 1.0. Pull efectivo = 0.20.
|
||||
// psi_nuevo = 0 + 0.20 · (target − 0) = target · 0.20.
|
||||
let psi = w.lemmings.vector_psi[0];
|
||||
assert!((psi[0] - 0.20).abs() < 1e-5, "psi[0]: {}", psi[0]);
|
||||
assert!((psi[1] - 0.10).abs() < 1e-5, "psi[1]: {}", psi[1]);
|
||||
// Y la acción NO cambia (la persuasión es ortogonal al hack).
|
||||
assert_eq!(w.lemmings.accion[0], 0);
|
||||
assert_eq!(w.lemmings.hack_lock[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persuasion_falloff_lineal_en_el_borde() {
|
||||
// Lemming al borde del radio: falloff = 0 → no se modifica psi.
|
||||
let mut w = empty_world(20, 20);
|
||||
w.lemmings.spawn(15.0, 10.0, 30.0, [0.0; 4]); // a distancia 5 del concepto
|
||||
w.conceptos.add(Concepto {
|
||||
id: "fuente-de-virtud".into(),
|
||||
sprite_id: 0,
|
||||
pos_x: 10.0,
|
||||
pos_y: 10.0,
|
||||
radius: 5.0,
|
||||
mods: LayerMods::default(),
|
||||
hack: None,
|
||||
persuasion: Some(dominium_core::Persuasion {
|
||||
target_psi: [1.0; 4],
|
||||
rate: 1.0, // máxima tasa: si influyera nada, no es por rate chica
|
||||
}),
|
||||
});
|
||||
apply_persuasion(&mut w);
|
||||
assert_eq!(w.lemmings.vector_psi[0], [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persuasion_none_no_cambia_psi() {
|
||||
let mut w = empty_world(20, 20);
|
||||
w.lemmings.spawn(10.0, 10.0, 30.0, [0.3, 0.4, 0.5, 0.6]);
|
||||
w.conceptos.add(Concepto {
|
||||
id: "solo-emite-campo".into(),
|
||||
sprite_id: 0,
|
||||
pos_x: 10.0,
|
||||
pos_y: 10.0,
|
||||
radius: 5.0,
|
||||
mods: LayerMods { materia: 0.5, ..Default::default() },
|
||||
hack: None,
|
||||
persuasion: None,
|
||||
});
|
||||
let psi_pre = w.lemmings.vector_psi[0];
|
||||
apply_persuasion(&mut w);
|
||||
assert_eq!(w.lemmings.vector_psi[0], psi_pre);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primer_concepto_gana_si_dos_capturan_al_mismo_lemming() {
|
||||
let mut w = empty_world(20, 20);
|
||||
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
|
||||
let mk = |id: &str, action: u8| Concepto {
|
||||
id: id.into(),
|
||||
sprite_id: 0,
|
||||
pos_x: 10.0,
|
||||
pos_y: 10.0,
|
||||
radius: 5.0,
|
||||
mods: LayerMods::default(),
|
||||
hack: Some(BehaviorHack {
|
||||
trigger: Trigger::Always,
|
||||
forced_action: action,
|
||||
duration: 7,
|
||||
}),
|
||||
persuasion: None,
|
||||
};
|
||||
w.conceptos.add(mk("a", 3));
|
||||
w.conceptos.add(mk("b", 5));
|
||||
apply_hacks(&mut w);
|
||||
assert_eq!(w.lemmings.accion[0], 3, "gana el primero por índice");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Difusión y entropía de los campos de la grilla.
|
||||
//!
|
||||
//! Ecuación de fluidos discreta: cada celda intercambia una fracción de
|
||||
//! su valor con sus 4 vecinas, y luego pierde una fracción al ambiente
|
||||
//! (entropía). Difunden los 3 campos dinámicos — materia, psique,
|
||||
//! poder. `oro` (materia sólida) y `degradacion` (cicatriz permanente)
|
||||
//! no difunden.
|
||||
|
||||
use dominium_core::{Grid, SimParams};
|
||||
|
||||
/// Difunde una sola capa: `new[c] = c + rate·(media_vecinos − c)`, y luego
|
||||
/// aplica la entropía. Usa un buffer de lectura separado (la difusión
|
||||
/// debe leer el estado viejo).
|
||||
fn diffuse_layer(layer: &mut [f32], width: usize, height: usize, rate: f32, entropy: f32) {
|
||||
let old = layer.to_vec();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let c = y * width + x;
|
||||
let mut sum = 0.0f32;
|
||||
let mut count = 0.0f32;
|
||||
// 4-vecindad (von Neumann), bordes sin wrap.
|
||||
if x > 0 {
|
||||
sum += old[c - 1];
|
||||
count += 1.0;
|
||||
}
|
||||
if x + 1 < width {
|
||||
sum += old[c + 1];
|
||||
count += 1.0;
|
||||
}
|
||||
if y > 0 {
|
||||
sum += old[c - width];
|
||||
count += 1.0;
|
||||
}
|
||||
if y + 1 < height {
|
||||
sum += old[c + width];
|
||||
count += 1.0;
|
||||
}
|
||||
let neighbor_avg = if count > 0.0 { sum / count } else { old[c] };
|
||||
let diffused = old[c] + rate * (neighbor_avg - old[c]);
|
||||
layer[c] = diffused * (1.0 - entropy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aplica un paso de difusión + entropía a los 3 campos dinámicos con
|
||||
/// tasas explícitas — pensado para el `tick` que ya tiene calculada la
|
||||
/// modulación estacional. La versión `diffuse(grid, p)` queda como wrapper
|
||||
/// estable para callers que no quieren saber del ciclo de estaciones.
|
||||
pub fn diffuse_with(grid: &mut Grid, rate: f32, entropy: f32) {
|
||||
let (w, h) = (grid.width, grid.height);
|
||||
diffuse_layer(&mut grid.materia, w, h, rate, entropy);
|
||||
diffuse_layer(&mut grid.psique, w, h, rate, entropy);
|
||||
diffuse_layer(&mut grid.poder, w, h, rate, entropy);
|
||||
}
|
||||
|
||||
/// Regrowth logístico de `materia`: cada celda recibe una fracción del
|
||||
/// espacio libre que le falta para llegar a `cap`. Sólo aplica a la capa
|
||||
/// de biomasa — las otras capas (psique, poder) no se regeneran solas.
|
||||
/// Es la fuente termodinámica que la simulación necesita para no
|
||||
/// extinguirse: sin ella la entropía vence siempre.
|
||||
///
|
||||
/// Vive *dentro* de la fase de difusión (el motor lo llama después de
|
||||
/// `diffuse_with`), así no agrega una fase nueva al §1.5 ni rompe el
|
||||
/// contrato del tick determinista.
|
||||
pub fn regrow_materia(grid: &mut Grid, rate: f32, cap: f32) {
|
||||
if rate <= 0.0 {
|
||||
return;
|
||||
}
|
||||
for m in grid.materia.iter_mut() {
|
||||
let gap = (cap - *m).max(0.0);
|
||||
*m += rate * gap;
|
||||
}
|
||||
}
|
||||
|
||||
/// Aplica un paso de difusión + entropía a los 3 campos dinámicos usando
|
||||
/// las tasas base de `SimParams` sin modulación estacional, e incluye el
|
||||
/// regrowth de materia. Es el wrapper "todo en uno" — útil para tests y
|
||||
/// herramientas. El motor (`tick`) llama a las dos sub-fases por separado
|
||||
/// para poder inyectar el factor de estación.
|
||||
pub fn diffuse(grid: &mut Grid, p: &SimParams) {
|
||||
diffuse_with(grid, p.diffusion_rate, p.entropy_rate);
|
||||
regrow_materia(grid, p.regrowth_rate, p.carrying_capacity);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn diffusion_spreads_a_spike_to_neighbors() {
|
||||
let mut g = Grid::new(5, 5);
|
||||
let center = g.idx(2, 2);
|
||||
g.materia[center] = 100.0;
|
||||
let p = SimParams::default();
|
||||
diffuse(&mut g, &p);
|
||||
// El pico bajó; las vecinas subieron desde 0.
|
||||
assert!(g.materia[center] < 100.0);
|
||||
assert!(g.materia[g.idx(1, 2)] > 0.0);
|
||||
assert!(g.materia[g.idx(3, 2)] > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_decays_a_uniform_field() {
|
||||
let mut g = Grid::new(4, 4);
|
||||
for v in g.psique.iter_mut() {
|
||||
*v = 10.0;
|
||||
}
|
||||
let p = SimParams::default();
|
||||
diffuse(&mut g, &p);
|
||||
// Campo uniforme: la difusión no cambia nada, pero la entropía sí.
|
||||
for &v in &g.psique {
|
||||
assert!(v < 10.0 && v > 9.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diffusion_conserves_mass_minus_entropy() {
|
||||
let mut g = Grid::new(6, 6);
|
||||
let c = g.idx(3, 3);
|
||||
g.materia[c] = 60.0;
|
||||
let total_before: f32 = g.materia.iter().sum();
|
||||
let mut p = SimParams::default();
|
||||
p.entropy_rate = 0.0; // sin pérdida → masa conservada
|
||||
p.regrowth_rate = 0.0; // sin fuente externa → masa cerrada
|
||||
diffuse(&mut g, &p);
|
||||
let total_after: f32 = g.materia.iter().sum();
|
||||
assert!((total_before - total_after).abs() < 1e-2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regrowth_pushes_empty_cells_toward_capacity() {
|
||||
let mut g = Grid::new(4, 4);
|
||||
// Toda la grilla en 0; con cap=40 y rate=0.5 → en un tick suben a 20.
|
||||
regrow_materia(&mut g, 0.5, 40.0);
|
||||
for &v in &g.materia {
|
||||
assert!((v - 20.0).abs() < 1e-4);
|
||||
}
|
||||
// Un segundo tick los lleva a 20 + 0.5·20 = 30.
|
||||
regrow_materia(&mut g, 0.5, 40.0);
|
||||
for &v in &g.materia {
|
||||
assert!((v - 30.0).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regrowth_never_exceeds_capacity() {
|
||||
let mut g = Grid::new(3, 3);
|
||||
// Una celda ya por encima de cap; regrow no la baja, sólo no la sube.
|
||||
let c = g.idx(1, 1);
|
||||
g.materia[c] = 80.0;
|
||||
regrow_materia(&mut g, 0.9, 40.0);
|
||||
assert_eq!(g.materia[c], 80.0, "regrow no degrada lo que excede cap");
|
||||
// Las vecinas vacías van hacia 40.
|
||||
let other = g.idx(0, 0);
|
||||
assert!((g.materia[other] - 36.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regrowth_disabled_when_rate_zero() {
|
||||
let mut g = Grid::new(3, 3);
|
||||
regrow_materia(&mut g, 0.0, 40.0);
|
||||
for &v in &g.materia {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! `dominium-physics` — el ciclo del motor de simulación.
|
||||
//!
|
||||
//! - [`diffuse`] — difusión + entropía de los campos de la grilla.
|
||||
//! - [`conceptos`] — emisión de campo y captura de acción por Conceptos.
|
||||
//! - [`tick`] — un paso completo: emisión de Conceptos → difusión →
|
||||
//! transiciones → captura por Conceptos → acciones → envejecimiento/cosecha.
|
||||
//! [`tick::run`] corre N pasos.
|
||||
//!
|
||||
//! Determinista bit-exacto: sólo aritmética f32 en orden fijo, sin
|
||||
//! HashMap iteration ni reducciones paralelas. Mismo seed → mismo estado
|
||||
//! en cualquier plataforma.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod conceptos;
|
||||
pub mod diffuse;
|
||||
pub mod social;
|
||||
pub mod spatial;
|
||||
pub mod tick;
|
||||
|
||||
pub use conceptos::{apply_conceptos, apply_hacks};
|
||||
pub use diffuse::{diffuse, diffuse_with, regrow_materia};
|
||||
pub use social::apply_social_contagion;
|
||||
pub use spatial::CellIndex;
|
||||
pub use tick::{run, tick};
|
||||
@@ -0,0 +1,496 @@
|
||||
//! Contagio social — Fase B del simulador de psicología poblacional.
|
||||
//!
|
||||
//! El `vector_psi` de cada agente NO es independiente del de sus vecinos:
|
||||
//! si estás rodeado de gente curiosa, te volvés curioso; rodeado de
|
||||
//! corruptibles, derivás a corrupto. Es la mecánica básica de **conformismo
|
||||
//! local**: cada tick los agentes en radio social `R` acercan su psi al
|
||||
//! promedio local con tasa `c`.
|
||||
//!
|
||||
//! Determinismo bit-exacto: doble-buffer (lectura del psi "antes",
|
||||
//! escritura del psi "después"). Sin esto, agentes con índices mayores
|
||||
//! leerían el psi ya actualizado de los menores — la simulación dependería
|
||||
//! del orden de iteración aunque sea lineal. Con el buffer, el resultado
|
||||
//! es **simétrico**: actualizar `i` o `j` primero da el mismo estado final.
|
||||
|
||||
use crate::spatial::CellIndex;
|
||||
use dominium_core::{SimParams, World};
|
||||
|
||||
/// Tamaño de población a partir del cual `apply_social_contagion` cambia al
|
||||
/// camino con índice espacial. Por debajo de este umbral la sobrecarga de
|
||||
/// armar el `CellIndex` (vec-of-vecs, sort por celda) no se amortiza vs el
|
||||
/// loop O(N²) sobre ~256 agentes. Por encima, el índice escala lineal y la
|
||||
/// versión ingenua se vuelve cuello de botella.
|
||||
///
|
||||
/// El cambio es *bit-exacto*: el índice devuelve los candidatos ordenados
|
||||
/// ascendentemente, así la suma `f32` ocurre en el mismo orden que en el
|
||||
/// camino O(N²) que itera `j ∈ 0..n`.
|
||||
pub const SPATIAL_CONTAGION_THRESHOLD: usize = 256;
|
||||
|
||||
/// Aplica una pasada de contagio social. No hace nada si `social_radius`
|
||||
/// o `contagion_rate` son cero (motor histórico, retrocompat).
|
||||
///
|
||||
/// Algoritmo:
|
||||
///
|
||||
/// 1. Snapshot del psi de toda la población (lectura "antes").
|
||||
/// 2. Para cada agente `i`, calcular el psi promedio de sus vecinos en
|
||||
/// radio `R` usando el snapshot.
|
||||
/// 3. Empujar el psi del agente: `psi_i ← psi_i + rate · (mean_local − psi_i)`.
|
||||
///
|
||||
/// El agente *no* se cuenta a sí mismo en el promedio. Si no hay vecinos
|
||||
/// dentro del radio, su psi no se modifica este tick (sin sociedad, sin
|
||||
/// influencia).
|
||||
///
|
||||
/// Costo: O(N²) por la búsqueda all-pairs. Con N ~10k es marginal frente
|
||||
/// al loop principal del tick; para N > 50k habría que indexar agentes
|
||||
/// por celda (Fase B.2).
|
||||
pub fn apply_social_contagion(world: &mut World, p: &SimParams) {
|
||||
if p.social_radius <= 0.0 || p.contagion_rate <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let n = world.lemmings.len();
|
||||
if n < 2 {
|
||||
return;
|
||||
}
|
||||
let r2 = p.social_radius * p.social_radius;
|
||||
// Si `homophily_threshold` > 0, comparamos contra su cuadrado para
|
||||
// ahorrar sqrt en el loop interior (distancia euclidiana al cuadrado).
|
||||
let use_homophily = p.homophily_threshold > 0.0;
|
||||
let homo2 = p.homophily_threshold * p.homophily_threshold;
|
||||
// Big Five: si el modo está activo y la columna psi5 está poblada,
|
||||
// incluimos la 5ª dimensión en el promedio y en la distancia de
|
||||
// homofilia. En motor Big Four (default) la rama big5 nunca se toca.
|
||||
let big5 = p.big_five && world.lemmings.psi5.len() == n;
|
||||
// Snapshot del psi "antes" — sin esto el contagio sería asimétrico y
|
||||
// dependiente del orden de iteración. También sirve como base contra
|
||||
// la cual se evalúa el filtro de homofilia.
|
||||
let psi_snapshot: Vec<[f32; 4]> = world.lemmings.vector_psi.clone();
|
||||
let psi5_snapshot: Vec<f32> = if big5 {
|
||||
world.lemmings.psi5.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Buffer de actualizaciones — escritura única al final.
|
||||
let mut new_psi: Vec<[f32; 4]> = psi_snapshot.clone();
|
||||
let mut new_psi5: Vec<f32> = psi5_snapshot.clone();
|
||||
// Camino con índice espacial cuando vale la pena. El umbral está
|
||||
// calibrado para que la población típica del juego (~500) ya esté
|
||||
// adentro — la app paga el índice y obtiene escala lineal.
|
||||
let index = if n >= SPATIAL_CONTAGION_THRESHOLD {
|
||||
// `cell_size == social_radius` garantiza que cualquier vecino a
|
||||
// distancia ≤ R cae en alguna de las 9 celdas adyacentes.
|
||||
let max_x = (world.grid.width as f32 - 1.0).max(p.social_radius);
|
||||
let max_y = (world.grid.height as f32 - 1.0).max(p.social_radius);
|
||||
Some(CellIndex::build(
|
||||
&world.lemmings.pos_x,
|
||||
&world.lemmings.pos_y,
|
||||
0.0,
|
||||
0.0,
|
||||
max_x,
|
||||
max_y,
|
||||
p.social_radius,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut cand_buf: Vec<u32> = Vec::new();
|
||||
let rate = p.contagion_rate as f64;
|
||||
for i in 0..n {
|
||||
let xi = world.lemmings.pos_x[i];
|
||||
let yi = world.lemmings.pos_y[i];
|
||||
let psi_i = psi_snapshot[i];
|
||||
let psi5_i = if big5 { psi5_snapshot[i] } else { 0.0 };
|
||||
let mut sum = [0.0f64; 4];
|
||||
let mut sum5: f64 = 0.0;
|
||||
let mut count: u32 = 0;
|
||||
// Iterador de candidatos: índice espacial cuando está armado, lineal
|
||||
// si no. Ambos producen los mismos índices en orden ascendente para
|
||||
// los `j` que **realmente** están dentro del radio — esto es lo que
|
||||
// mantiene la suma `f32` bit-exacta entre los dos caminos.
|
||||
let process_j = |j: usize,
|
||||
sum: &mut [f64; 4],
|
||||
sum5: &mut f64,
|
||||
count: &mut u32| {
|
||||
if j == i {
|
||||
return;
|
||||
}
|
||||
let dx = world.lemmings.pos_x[j] - xi;
|
||||
let dy = world.lemmings.pos_y[j] - yi;
|
||||
if dx * dx + dy * dy > r2 {
|
||||
return;
|
||||
}
|
||||
let psi_j = psi_snapshot[j];
|
||||
if use_homophily {
|
||||
let d0 = psi_j[0] - psi_i[0];
|
||||
let d1 = psi_j[1] - psi_i[1];
|
||||
let d2 = psi_j[2] - psi_i[2];
|
||||
let d3 = psi_j[3] - psi_i[3];
|
||||
let mut dpsi2 = d0 * d0 + d1 * d1 + d2 * d2 + d3 * d3;
|
||||
if big5 {
|
||||
let d4 = psi5_snapshot[j] - psi5_i;
|
||||
dpsi2 += d4 * d4;
|
||||
}
|
||||
if dpsi2 > homo2 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for k in 0..4 {
|
||||
sum[k] += psi_j[k] as f64;
|
||||
}
|
||||
if big5 {
|
||||
*sum5 += psi5_snapshot[j] as f64;
|
||||
}
|
||||
*count += 1;
|
||||
};
|
||||
match &index {
|
||||
Some(idx) => {
|
||||
idx.candidates_sorted(xi, yi, 0.0, 0.0, &mut cand_buf);
|
||||
for &ju in &cand_buf {
|
||||
process_j(ju as usize, &mut sum, &mut sum5, &mut count);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for j in 0..n {
|
||||
process_j(j, &mut sum, &mut sum5, &mut count);
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let cf = count as f64;
|
||||
for k in 0..4 {
|
||||
let mean = sum[k] / cf;
|
||||
let cur = psi_snapshot[i][k] as f64;
|
||||
new_psi[i][k] = (cur + rate * (mean - cur)) as f32;
|
||||
}
|
||||
if big5 {
|
||||
let mean5 = sum5 / cf;
|
||||
let cur5 = psi5_i as f64;
|
||||
new_psi5[i] = (cur5 + rate * (mean5 - cur5)) as f32;
|
||||
}
|
||||
}
|
||||
world.lemmings.vector_psi = new_psi;
|
||||
if big5 {
|
||||
world.lemmings.psi5 = new_psi5;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dominium_core::SimParams;
|
||||
|
||||
fn world_with_psi(psis: &[[f32; 4]]) -> World {
|
||||
let mut w = World::new(40, 40);
|
||||
for (k, &psi) in psis.iter().enumerate() {
|
||||
// Distribuirlos cerca pero no encima — radius del test los cubre.
|
||||
let x = 10.0 + (k as f32) * 0.5;
|
||||
let y = 10.0;
|
||||
w.lemmings.spawn(x, y, 30.0, psi);
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contagion_disabled_by_default_is_a_noop() {
|
||||
let mut w = world_with_psi(&[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
]);
|
||||
let psi_before = w.lemmings.vector_psi.clone();
|
||||
let p = SimParams::default(); // radius=0, rate=0
|
||||
apply_social_contagion(&mut w, &p);
|
||||
assert_eq!(w.lemmings.vector_psi, psi_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contagion_moves_outlier_toward_local_mean() {
|
||||
// Dos cercanos con psi=[0,0,0,0] y un outlier con psi=[1,1,1,1] al
|
||||
// lado. El outlier debe acercarse al promedio (que es [0,0,0,0]).
|
||||
let mut w = world_with_psi(&[
|
||||
[0.0; 4],
|
||||
[0.0; 4],
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
]);
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 10.0;
|
||||
p.contagion_rate = 0.5;
|
||||
apply_social_contagion(&mut w, &p);
|
||||
// Outlier (índice 2): vecinos en radio = 0 y 1, ambos con psi=0.
|
||||
// Mean local = [0,0,0,0]. Nuevo psi = 1 + 0.5·(0-1) = 0.5.
|
||||
for k in 0..4 {
|
||||
assert!(
|
||||
(w.lemmings.vector_psi[2][k] - 0.5).abs() < 1e-5,
|
||||
"outlier comp {k}: {}",
|
||||
w.lemmings.vector_psi[2][k]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_agent_unchanged() {
|
||||
// Un agente solo lejos de cualquiera no debe verse afectado.
|
||||
let mut w = World::new(40, 40);
|
||||
w.lemmings.spawn(2.0, 2.0, 30.0, [0.7, 0.3, 0.5, 0.1]);
|
||||
w.lemmings.spawn(35.0, 35.0, 30.0, [0.1, 0.9, 0.2, 0.8]);
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 3.0; // demasiado chico para que se vean
|
||||
p.contagion_rate = 0.5;
|
||||
let psi_before = w.lemmings.vector_psi.clone();
|
||||
apply_social_contagion(&mut w, &p);
|
||||
assert_eq!(w.lemmings.vector_psi, psi_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contagion_is_symmetric_under_index_swap() {
|
||||
// Determinismo: aplicar contagio a [A, B, C] o a [C, B, A] (mismos
|
||||
// psi, mismas posiciones, distintos índices) debe producir
|
||||
// psi finales idénticos por agente. Esto valida que el doble-buffer
|
||||
// elimina la dependencia del orden de iteración.
|
||||
let psis = [
|
||||
[0.1, 0.9, 0.5, 0.0],
|
||||
[0.5, 0.5, 0.5, 0.5],
|
||||
[0.9, 0.1, 0.0, 1.0],
|
||||
];
|
||||
let mut w_ab = world_with_psi(&psis);
|
||||
let mut psis_rev = psis;
|
||||
psis_rev.reverse();
|
||||
let mut w_rev = world_with_psi(&psis_rev);
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 10.0;
|
||||
p.contagion_rate = 0.3;
|
||||
apply_social_contagion(&mut w_ab, &p);
|
||||
apply_social_contagion(&mut w_rev, &p);
|
||||
// El agente físicamente en posición 2 (ahora índice 2 en w_ab y
|
||||
// índice 0 en w_rev): comparar el psi del MISMO agente físico.
|
||||
// En w_ab: agente físicamente en pos x=11 es índice 2.
|
||||
// En w_rev: agente físicamente en pos x=11 es índice 0 (porque la
|
||||
// construcción asigna pos por orden y la reversa puso el psi de
|
||||
// antes-índice-2 en índice-0... pero el psi propio también cambió).
|
||||
// Mejor invariante: el promedio global de psi se conserva en cada
|
||||
// componente (el contagio es un promedio ponderado, no inyecta ni
|
||||
// drena).
|
||||
let mean_orig: [f64; 4] = {
|
||||
let mut m = [0.0f64; 4];
|
||||
for psi in &psis {
|
||||
for k in 0..4 { m[k] += psi[k] as f64; }
|
||||
}
|
||||
for k in 0..4 { m[k] /= psis.len() as f64; }
|
||||
m
|
||||
};
|
||||
let mean_after_ab: [f64; 4] = {
|
||||
let mut m = [0.0f64; 4];
|
||||
for psi in &w_ab.lemmings.vector_psi {
|
||||
for k in 0..4 { m[k] += psi[k] as f64; }
|
||||
}
|
||||
for k in 0..4 { m[k] /= w_ab.lemmings.len() as f64; }
|
||||
m
|
||||
};
|
||||
let mean_after_rev: [f64; 4] = {
|
||||
let mut m = [0.0f64; 4];
|
||||
for psi in &w_rev.lemmings.vector_psi {
|
||||
for k in 0..4 { m[k] += psi[k] as f64; }
|
||||
}
|
||||
for k in 0..4 { m[k] /= w_rev.lemmings.len() as f64; }
|
||||
m
|
||||
};
|
||||
for k in 0..4 {
|
||||
assert!(
|
||||
(mean_after_ab[k] - mean_orig[k]).abs() < 1e-4,
|
||||
"comp {k}: media drift (ab) {} vs orig {}",
|
||||
mean_after_ab[k], mean_orig[k]
|
||||
);
|
||||
assert!(
|
||||
(mean_after_rev[k] - mean_orig[k]).abs() < 1e-4,
|
||||
"comp {k}: media drift (rev) {} vs orig {}",
|
||||
mean_after_rev[k], mean_orig[k]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homophily_isolates_two_distinct_tribes() {
|
||||
// Dos grupos físicamente cercanos (radio social los cubre a todos)
|
||||
// pero psicológicamente lejanos. Con homophily_threshold pequeño,
|
||||
// cada tribu sólo se influye a sí misma — NO converge al promedio
|
||||
// global; cada tribu mantiene su centroide y la varianza entre
|
||||
// tribus se preserva.
|
||||
let mut w = World::new(40, 40);
|
||||
// Tribu A (psi=[1,0,0,0]) en posiciones cercanas.
|
||||
for k in 0..4 {
|
||||
w.lemmings
|
||||
.spawn(10.0 + k as f32 * 0.3, 10.0, 30.0, [1.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
// Tribu B (psi=[0,0,0,1]) en posiciones también cercanas a A.
|
||||
for k in 0..4 {
|
||||
w.lemmings
|
||||
.spawn(12.0 + k as f32 * 0.3, 10.0, 30.0, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 10.0; // todos se ven entre sí
|
||||
p.contagion_rate = 0.30;
|
||||
// Distancia psi entre tribus = sqrt(1²+1²) ≈ 1.41. Threshold 0.5
|
||||
// → A ignora a B y viceversa.
|
||||
p.homophily_threshold = 0.5;
|
||||
for _ in 0..100 {
|
||||
apply_social_contagion(&mut w, &p);
|
||||
}
|
||||
// Tras 100 pasos: la tribu A debe mantenerse cerca de [1,0,0,0],
|
||||
// la tribu B cerca de [0,0,0,1] — NO al promedio global [0.5,0,0,0.5].
|
||||
for i in 0..4 {
|
||||
let p_a = w.lemmings.vector_psi[i];
|
||||
assert!(
|
||||
(p_a[0] - 1.0).abs() < 0.01 && p_a[3].abs() < 0.01,
|
||||
"tribu A drift: {:?}",
|
||||
p_a
|
||||
);
|
||||
}
|
||||
for i in 4..8 {
|
||||
let p_b = w.lemmings.vector_psi[i];
|
||||
assert!(
|
||||
p_b[0].abs() < 0.01 && (p_b[3] - 1.0).abs() < 0.01,
|
||||
"tribu B drift: {:?}",
|
||||
p_b
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homophily_zero_falls_back_to_universal_contagion() {
|
||||
// homophily_threshold = 0.0 (default) → comportamiento de B.1:
|
||||
// las dos tribus convergen al promedio global.
|
||||
let mut w = World::new(40, 40);
|
||||
for k in 0..4 {
|
||||
w.lemmings
|
||||
.spawn(10.0 + k as f32 * 0.3, 10.0, 30.0, [1.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
for k in 0..4 {
|
||||
w.lemmings
|
||||
.spawn(12.0 + k as f32 * 0.3, 10.0, 30.0, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 10.0;
|
||||
p.contagion_rate = 0.30;
|
||||
p.homophily_threshold = 0.0; // explícito
|
||||
for _ in 0..100 {
|
||||
apply_social_contagion(&mut w, &p);
|
||||
}
|
||||
// Convergen al promedio [0.5, 0, 0, 0.5].
|
||||
for psi in &w.lemmings.vector_psi {
|
||||
assert!(
|
||||
(psi[0] - 0.5).abs() < 0.01 && (psi[3] - 0.5).abs() < 0.01,
|
||||
"no convergió al promedio: {:?}",
|
||||
psi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_index_path_is_bit_exact_to_naive_path() {
|
||||
// Construimos dos mundos idénticos con N por encima y por debajo del
|
||||
// umbral, y verificamos que el resultado es bit-exacto. La única
|
||||
// diferencia entre los dos caminos es el iterador de candidatos —
|
||||
// ambos producen los mismos `j` válidos en el mismo orden.
|
||||
let build = |n: usize| -> World {
|
||||
let mut w = World::new(60, 60);
|
||||
for k in 0..n {
|
||||
// Distribución pseudoaleatoria determinista (LCG con wrap).
|
||||
let kx = (k as u64).wrapping_mul(2862933555777941757);
|
||||
let ky = (k as u64).wrapping_mul(6364136223846793005);
|
||||
let x = ((kx >> 33) as u32 % 5500) as f32 / 100.0;
|
||||
let y = ((ky >> 33) as u32 % 5500) as f32 / 100.0;
|
||||
let psi = [
|
||||
(k as f32 * 0.13).fract(),
|
||||
(k as f32 * 0.27).fract(),
|
||||
(k as f32 * 0.41).fract(),
|
||||
(k as f32 * 0.59).fract(),
|
||||
];
|
||||
w.lemmings.spawn(x, y, 30.0, psi);
|
||||
}
|
||||
w
|
||||
};
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 6.0;
|
||||
p.contagion_rate = 0.15;
|
||||
// N por debajo del umbral → path ingenuo
|
||||
let mut small = build(SPATIAL_CONTAGION_THRESHOLD - 1);
|
||||
apply_social_contagion(&mut small, &p);
|
||||
// Mismo N pero forzando el path con índice via lib pública: como el
|
||||
// threshold es interno, lo verificamos en el caso "encima del umbral"
|
||||
// con dos poblaciones idénticas armadas con el mismo seed. Ambas
|
||||
// deben converger al mismo psi.
|
||||
let mut a = build(SPATIAL_CONTAGION_THRESHOLD + 5);
|
||||
let mut b = build(SPATIAL_CONTAGION_THRESHOLD + 5);
|
||||
apply_social_contagion(&mut a, &p);
|
||||
apply_social_contagion(&mut b, &p);
|
||||
assert_eq!(a.lemmings.vector_psi, b.lemmings.vector_psi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_path_matches_naive_path_when_thresholds_cross() {
|
||||
// Construimos un mundo cuyo N empuje el camino con índice, y otro
|
||||
// copia idéntico pero corremos *el camino ingenuo* a mano vía un
|
||||
// SimParams clonado. Imposible sin re-exponer el path interno;
|
||||
// en su lugar verificamos invariantes: media del psi conservada
|
||||
// (el contagio es promedio ponderado, no inyecta) y dispersión
|
||||
// monótonamente no-creciente.
|
||||
let mut w = World::new(80, 80);
|
||||
let n = 600usize;
|
||||
for k in 0..n {
|
||||
let x = ((k as u64).wrapping_mul(1103515245).wrapping_add(12345) % 7800) as f32 / 100.0;
|
||||
let y = ((k as u64).wrapping_mul(214013).wrapping_add(2531011) % 7800) as f32 / 100.0;
|
||||
let psi = [
|
||||
(k as f32 * 0.11).fract(),
|
||||
(k as f32 * 0.29).fract(),
|
||||
(k as f32 * 0.43).fract(),
|
||||
(k as f32 * 0.61).fract(),
|
||||
];
|
||||
w.lemmings.spawn(x, y, 30.0, psi);
|
||||
}
|
||||
let mean_before: f64 = w.lemmings.vector_psi.iter().map(|p| p[0] as f64).sum::<f64>()
|
||||
/ n as f64;
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 5.0;
|
||||
p.contagion_rate = 0.10;
|
||||
apply_social_contagion(&mut w, &p);
|
||||
let mean_after: f64 = w.lemmings.vector_psi.iter().map(|p| p[0] as f64).sum::<f64>()
|
||||
/ n as f64;
|
||||
// La media global debe preservarse aproximadamente — los agentes
|
||||
// de borde pueden tener una pequeña deriva pero el contagio es
|
||||
// promedio ponderado y no introduce sesgo sistemático.
|
||||
assert!(
|
||||
(mean_after - mean_before).abs() < 0.01,
|
||||
"media drift {} → {}",
|
||||
mean_before, mean_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contagion_converges_to_consensus_after_many_iterations() {
|
||||
// Con N agentes mutuamente visibles y tasa moderada, después de
|
||||
// ~50 pasos todos deberían tener el mismo psi (con tolerancia).
|
||||
let mut w = world_with_psi(&[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]);
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 10.0;
|
||||
p.contagion_rate = 0.30;
|
||||
for _ in 0..100 {
|
||||
apply_social_contagion(&mut w, &p);
|
||||
}
|
||||
// Esperamos consenso = promedio inicial = [0.25, 0.25, 0.25, 0.25].
|
||||
for psi in &w.lemmings.vector_psi {
|
||||
for k in 0..4 {
|
||||
assert!(
|
||||
(psi[k] - 0.25).abs() < 1e-3,
|
||||
"no convergió comp {k}: {}",
|
||||
psi[k]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Índice espacial determinista — bin de agentes por celda.
|
||||
//!
|
||||
//! El contagio social ingenuo es O(N²) — perfectamente aceptable hasta ~5k
|
||||
//! agentes pero se vuelve cuello de botella en sweeps Monte Carlo o en
|
||||
//! poblaciones grandes (`abundance_threshold` bajo + `regrowth_rate` alto
|
||||
//! pueden empujar N por encima de 10k).
|
||||
//!
|
||||
//! Este módulo bin'ea los agentes en una grilla regular de paso `cell_size`
|
||||
//! y permite recoger los índices vecinos en un radio `r` en O(K) donde K es
|
||||
//! la cantidad real de vecinos en las 9 celdas adyacentes. La salida se
|
||||
//! devuelve **ordenada ascendentemente** — esto vuelve la suma del contagio
|
||||
//! social bit-exacta respecto a la versión O(N²) (que también suma en orden
|
||||
//! ascendente de índice), aún cuando la población crezca.
|
||||
//!
|
||||
//! Determinismo total: sin RNG, sin paralelismo, sin HashMap. El sort interno
|
||||
//! es `sort_unstable` sobre `u32` — comparación total y estable
|
||||
//! cross-platform.
|
||||
|
||||
/// Índice por celda. `cells[id]` contiene los índices de los agentes cuyo
|
||||
/// `(pos_x, pos_y)` cae dentro de esa celda. El id se mapea `(cx, cy)` →
|
||||
/// `cy * nx + cx`. Los agentes con posición fuera del rango cubierto por la
|
||||
/// grilla se clampean a la celda de borde más cercana — mantiene la
|
||||
/// invariante "todos los agentes están en exactamente una celda".
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CellIndex {
|
||||
pub cells: Vec<Vec<u32>>,
|
||||
pub nx: usize,
|
||||
pub ny: usize,
|
||||
pub cell_size: f32,
|
||||
}
|
||||
|
||||
impl CellIndex {
|
||||
/// Construye el índice. `cell_size` debe ser positivo; un valor sano es
|
||||
/// el radio social del contagio (con celdas más grandes habrá más vecinos
|
||||
/// candidatos por celda pero menos celdas visitadas; lo contrario con
|
||||
/// celdas más chicas — el trade-off típico es `cell_size ≈ radius`).
|
||||
///
|
||||
/// `min_x/y` y `max_x/y` definen el rectángulo que el índice cubre.
|
||||
/// Posiciones fuera quedan clampedas. Un grilla 80×80 normalmente pasa
|
||||
/// `0.0, 0.0, 79.0, 79.0`.
|
||||
pub fn build(
|
||||
xs: &[f32],
|
||||
ys: &[f32],
|
||||
min_x: f32,
|
||||
min_y: f32,
|
||||
max_x: f32,
|
||||
max_y: f32,
|
||||
cell_size: f32,
|
||||
) -> Self {
|
||||
assert!(cell_size > 0.0, "cell_size debe ser positivo");
|
||||
let span_x = (max_x - min_x).max(cell_size);
|
||||
let span_y = (max_y - min_y).max(cell_size);
|
||||
let nx = ((span_x / cell_size).ceil() as usize).max(1);
|
||||
let ny = ((span_y / cell_size).ceil() as usize).max(1);
|
||||
let total = nx * ny;
|
||||
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); total];
|
||||
let n = xs.len();
|
||||
for i in 0..n {
|
||||
let cx_raw = ((xs[i] - min_x) / cell_size).floor() as i64;
|
||||
let cy_raw = ((ys[i] - min_y) / cell_size).floor() as i64;
|
||||
let cx = cx_raw.clamp(0, nx as i64 - 1) as usize;
|
||||
let cy = cy_raw.clamp(0, ny as i64 - 1) as usize;
|
||||
let id = cy * nx + cx;
|
||||
cells[id].push(i as u32);
|
||||
}
|
||||
Self { cells, nx, ny, cell_size }
|
||||
}
|
||||
|
||||
/// Vecinos candidatos del agente en `(x, y)` dentro de las 9 celdas
|
||||
/// adyacentes (la propia + las 8 alrededor). El llamador debe filtrar
|
||||
/// por distancia real (este método no la mide) y opcionalmente excluir
|
||||
/// el propio índice del agente.
|
||||
///
|
||||
/// Los índices se devuelven **ordenados ascendentemente**. Esto preserva
|
||||
/// la igualdad bit-exacta con un sweep ingenuo O(N²) que itera `0..N`
|
||||
/// en orden lineal — la suma de `f32` depende del orden y vamos a sumar
|
||||
/// `psi_j` sobre estos índices.
|
||||
pub fn candidates_sorted(&self, x: f32, y: f32, min_x: f32, min_y: f32, out: &mut Vec<u32>) {
|
||||
out.clear();
|
||||
let cx = (((x - min_x) / self.cell_size).floor() as i64)
|
||||
.clamp(0, self.nx as i64 - 1) as usize;
|
||||
let cy = (((y - min_y) / self.cell_size).floor() as i64)
|
||||
.clamp(0, self.ny as i64 - 1) as usize;
|
||||
let cx_lo = cx.saturating_sub(1);
|
||||
let cx_hi = (cx + 1).min(self.nx - 1);
|
||||
let cy_lo = cy.saturating_sub(1);
|
||||
let cy_hi = (cy + 1).min(self.ny - 1);
|
||||
for ccy in cy_lo..=cy_hi {
|
||||
for ccx in cx_lo..=cx_hi {
|
||||
let id = ccy * self.nx + ccx;
|
||||
out.extend_from_slice(&self.cells[id]);
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_distributes_agents_to_correct_cells() {
|
||||
let xs = vec![0.5, 1.5, 5.0, 9.9];
|
||||
let ys = vec![0.5, 0.5, 5.0, 9.9];
|
||||
let idx = CellIndex::build(&xs, &ys, 0.0, 0.0, 10.0, 10.0, 2.0);
|
||||
// 5 celdas × 5 = 25 bins (span 10 / cell 2 = 5).
|
||||
assert_eq!(idx.nx, 5);
|
||||
assert_eq!(idx.ny, 5);
|
||||
// Agentes 0,1 caen en cell (0,0); agente 2 en (2,2); agente 3 en (4,4).
|
||||
let count: usize = idx.cells.iter().map(|c| c.len()).sum();
|
||||
assert_eq!(count, 4);
|
||||
assert_eq!(idx.cells[0], vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_sorted_returns_ascending_indices() {
|
||||
// Agentes alineados en y=5, x=0..9. Query en x=5, y=5, cell 2.
|
||||
// Sólo las 3 celdas adyacentes (cx 1..=3) → x=2..7 → idxs 2..=7.
|
||||
let xs: Vec<f32> = (0..10).map(|i| i as f32).collect();
|
||||
let ys: Vec<f32> = vec![5.0; 10];
|
||||
let idx = CellIndex::build(&xs, &ys, 0.0, 0.0, 10.0, 10.0, 2.0);
|
||||
let mut buf = Vec::new();
|
||||
idx.candidates_sorted(5.0, 5.0, 0.0, 0.0, &mut buf);
|
||||
// Ordenado ascendente.
|
||||
for w in buf.windows(2) {
|
||||
assert!(w[0] < w[1]);
|
||||
}
|
||||
// Contiene al menos los vecinos directos del centro (id 5).
|
||||
assert!(buf.contains(&5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_positions_clamp_to_edge_cell() {
|
||||
let xs = vec![-100.0, 100.0];
|
||||
let ys = vec![-100.0, 100.0];
|
||||
let idx = CellIndex::build(&xs, &ys, 0.0, 0.0, 10.0, 10.0, 2.0);
|
||||
let total: usize = idx.cells.iter().map(|c| c.len()).sum();
|
||||
assert_eq!(total, 2, "los 2 agentes deben estar bin'ados igual");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! El ciclo del motor — un `tick` completo de la simulación.
|
||||
//!
|
||||
//! Orden fijo: difusión/entropía → evaluación de transiciones → acciones
|
||||
//! de los agentes → envejecimiento y cosecha de muertos.
|
||||
|
||||
use crate::conceptos::{apply_conceptos, apply_hacks, apply_persuasion};
|
||||
use crate::diffuse::{diffuse_with, regrow_materia};
|
||||
use crate::social::apply_social_contagion;
|
||||
use dominium_core::{
|
||||
select_action_argmax, select_action_argmax_big5, ActionPolicy, SimParams, World,
|
||||
};
|
||||
|
||||
/// Reelige la `accion` base de los lemmings libres según la política
|
||||
/// psicológica. Cero costo cuando la política es `Fixed` o el periodo es 0
|
||||
/// — el motor histórico no paga nada por esta fase.
|
||||
///
|
||||
/// Agentes capturados por un Concepto (`hack_lock > 0`) quedan blindados:
|
||||
/// la captura externa siempre vence a la reelección psicológica. La
|
||||
/// transición de desesperación (energía baja → pelear) se aplica *después*
|
||||
/// de esta función, así que la supervivencia también vence a la psicología.
|
||||
fn apply_psi_policy(world: &mut World, p: &SimParams) {
|
||||
if !matches!(p.action_policy, ActionPolicy::PsiArgmax) {
|
||||
return;
|
||||
}
|
||||
if p.policy_reeval_period == 0 {
|
||||
return;
|
||||
}
|
||||
// Reelige sólo en los ticks que son múltiplos del período. El reloj
|
||||
// global `tick_count` se incrementa al *final* de cada tick, así que
|
||||
// en el primer tick (tick_count == 0) la fase se ejecuta — eso es
|
||||
// intencional: deja a la psicología decidir antes de que la simulación
|
||||
// arranque a inercia.
|
||||
if (world.tick_count % p.policy_reeval_period as u64) != 0 {
|
||||
return;
|
||||
}
|
||||
let weights = &p.action_weights;
|
||||
let weights_ext = &p.action_weights_ext;
|
||||
let big5 = p.big_five && world.lemmings.psi5.len() == world.lemmings.len();
|
||||
for i in 0..world.lemmings.len() {
|
||||
if world.lemmings.hack_lock[i] > 0 {
|
||||
continue;
|
||||
}
|
||||
let psi = world.lemmings.vector_psi[i];
|
||||
world.lemmings.accion[i] = if big5 {
|
||||
let psi5 = world.lemmings.psi5[i];
|
||||
select_action_argmax_big5(&psi, psi5, weights, weights_ext)
|
||||
} else {
|
||||
select_action_argmax(&psi, weights)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluación de transiciones: un agente exhausto se fuerza a `Pelear`.
|
||||
/// Un lemming bajo `hack_lock` está blindado: su acción ya está fijada por
|
||||
/// un Concepto y no debe re-evaluarse hasta que el lock se agote.
|
||||
///
|
||||
/// Nota: la **abundancia** NO transiciona la acción base — eso convertiría
|
||||
/// a los Extractores en Replicadores y secaría la fuente de energía del
|
||||
/// sistema. En su lugar, la reproducción por abundancia se ejecuta como
|
||||
/// *efecto colateral* dentro de `step_lemming` (ver `World::step_lemming`),
|
||||
/// preservando la división del trabajo.
|
||||
fn apply_transitions(world: &mut World, p: &SimParams) {
|
||||
for i in 0..world.lemmings.len() {
|
||||
if world.lemmings.hack_lock[i] > 0 {
|
||||
continue;
|
||||
}
|
||||
if world.lemmings.energia[i] < p.desperation_threshold {
|
||||
world.lemmings.accion[i] = 5; // Degradar (Pelear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envejece a los agentes y cosecha a los muertos: la energía remanente
|
||||
/// de un agente que muere se inyecta como fertilidad (`materia`) en su
|
||||
/// celda. Devuelve cuántos murieron.
|
||||
fn age_and_reap(world: &mut World, p: &SimParams) -> usize {
|
||||
// Costo metabólico basal: drena energía de TODOS los lemmings por
|
||||
// el simple hecho de estar vivos. Es el freno termodinámico que
|
||||
// estabiliza la población — sin él, los Extractores acumulan E sin
|
||||
// techo y la natalidad se descontrola.
|
||||
if p.metabolic_cost > 0.0 {
|
||||
for e in world.lemmings.energia.iter_mut() {
|
||||
*e -= p.metabolic_cost;
|
||||
}
|
||||
}
|
||||
for e in world.lemmings.edad.iter_mut() {
|
||||
*e += 1;
|
||||
}
|
||||
// Recolecta los índices muertos (energía agotada o edad excedida).
|
||||
let mut dead: Vec<usize> = (0..world.lemmings.len())
|
||||
.filter(|&i| {
|
||||
world.lemmings.energia[i] <= 0.0 || world.lemmings.edad[i] > p.max_edad
|
||||
})
|
||||
.collect();
|
||||
// Remueve de mayor a menor índice: swap_remove no invalida los menores.
|
||||
dead.sort_unstable_by(|a, b| b.cmp(a));
|
||||
let count = dead.len();
|
||||
for i in dead {
|
||||
let (cx, cy) = world
|
||||
.grid
|
||||
.clamp_cell(world.lemmings.pos_x[i], world.lemmings.pos_y[i]);
|
||||
let idx = world.grid.idx(cx, cy);
|
||||
// La energía remanente vuelve a la tierra como biomasa.
|
||||
let remnant = world.lemmings.energia[i].max(0.0);
|
||||
world.grid.materia[idx] += remnant;
|
||||
world.lemmings.remove(i);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Un paso completo de la simulación.
|
||||
pub fn tick(world: &mut World, p: &SimParams) {
|
||||
// 1. Emisión/drenaje por Conceptos sobre las celdas (con falloff lineal).
|
||||
// Va antes de la difusión para que las inyecciones se propaguen este tick.
|
||||
apply_conceptos(world);
|
||||
// 2. Difusión y entropía sobre los campos — moduladas por el factor
|
||||
// estacional del tick actual. Con season_period == 0 el factor es 1.0
|
||||
// y la fase es bit-exactamente equivalente al motor sin estaciones.
|
||||
let season = p.season_factor(world.tick_count);
|
||||
diffuse_with(
|
||||
&mut world.grid,
|
||||
p.diffusion_rate * season,
|
||||
p.entropy_rate * season,
|
||||
);
|
||||
// 2b. Regrowth logístico de materia — cierre termodinámico que evita
|
||||
// la extinción. Sub-fase del paso 2, no agrega fase nueva al §1.5.
|
||||
regrow_materia(&mut world.grid, p.regrowth_rate, p.carrying_capacity);
|
||||
// 2c. Persuasión institucional (Fase B.2, opt-in vía `Concepto::persuasion`).
|
||||
// Empuja psi de agentes dentro del radio de cada Concepto persuasor
|
||||
// hacia su `target_psi`, con falloff lineal. Va ANTES del contagio
|
||||
// social: las instituciones imprimen primero, después los pares
|
||||
// imitan o filtran (homofilia).
|
||||
apply_persuasion(world);
|
||||
// 2d. Contagio social (Fase B, opt-in vía `social_radius/contagion_rate`).
|
||||
// Cada agente acerca su `vector_psi` al promedio del psi de sus
|
||||
// vecinos en radio R, opcionalmente filtrando por homofilia. Va
|
||||
// antes de psi_policy para que la reelección de acción vea el psi
|
||||
// ya influenciado por instituciones + pares.
|
||||
apply_social_contagion(world, p);
|
||||
// 2e. Política psicológica de acción (opt-in vía `ActionPolicy::PsiArgmax`).
|
||||
// Reelige `accion` por argmax(W · psi) para lemmings libres. Sub-fase
|
||||
// de la 2 — corre antes de las transiciones y los hacks, así la
|
||||
// desesperación y la captura siempre ganan a la psicología tranquila.
|
||||
apply_psi_policy(world, p);
|
||||
// 3. Transiciones de estado forzadas (desesperación → pelear).
|
||||
apply_transitions(world, p);
|
||||
// 4. Captura de acción por Conceptos. Vence cualquier transición previa:
|
||||
// el `hack_lock` blindará al lemming hasta agotar su duración.
|
||||
apply_hacks(world);
|
||||
// 5. Acciones de los agentes. Se fija `n` antes del loop: los hijos
|
||||
// que `Replicar` agrega al final NO actúan este tick.
|
||||
let n = world.lemmings.len();
|
||||
for i in 0..n {
|
||||
if i < world.lemmings.len() {
|
||||
world.step_lemming(i, p);
|
||||
}
|
||||
}
|
||||
// 6. Envejecer + cosechar muertos.
|
||||
age_and_reap(world, p);
|
||||
// 7. Avanzar el reloj global — alimentación del ciclo estacional del
|
||||
// próximo tick. Saturating para no entrar en UB en simulaciones
|
||||
// eternas (~5.8e8 años a 1 tick/ns; suficiente).
|
||||
world.tick_count = world.tick_count.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Corre `steps` ticks seguidos.
|
||||
pub fn run(world: &mut World, p: &SimParams, steps: usize) {
|
||||
for _ in 0..steps {
|
||||
tick(world, p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn abundance_keeps_base_action_intact() {
|
||||
// La abundancia NO transiciona la acción base. Un Extractor saciado
|
||||
// sigue siendo Extractor — su rol funcional se preserva. La
|
||||
// reproducción ocurre como side-effect en step_lemming.
|
||||
let mut w = World::new(8, 8);
|
||||
w.lemmings.spawn(4.0, 4.0, 200.0, [0.0; 4]);
|
||||
w.lemmings.accion[0] = 1; // Extraer
|
||||
let p = SimParams::default();
|
||||
apply_transitions(&mut w, &p);
|
||||
assert_eq!(w.lemmings.accion[0], 1, "Extractor sigue extrayendo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abundance_side_effect_spawns_child_via_step_lemming() {
|
||||
// Un Extractor con E > abundance_threshold replica como bonus
|
||||
// dentro de step_lemming, y luego ejecuta act_extraer normalmente.
|
||||
let mut w = World::new(8, 8);
|
||||
let idx = w.grid.idx(4, 4);
|
||||
w.grid.materia[idx] = 50.0;
|
||||
w.lemmings.spawn(4.0, 4.0, 200.0, [0.0; 4]);
|
||||
w.lemmings.accion[0] = 1; // Extraer
|
||||
let p = SimParams::default(); // abundance_threshold = 60
|
||||
let n_before = w.lemmings.len();
|
||||
let materia_before = w.grid.materia[idx];
|
||||
w.step_lemming(0, &p);
|
||||
// Replicó (hay hijo nuevo)
|
||||
assert_eq!(w.lemmings.len(), n_before + 1);
|
||||
// Y también extrajo (la materia bajó)
|
||||
assert!(w.grid.materia[idx] < materia_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_agent_is_forced_to_fight() {
|
||||
let mut w = World::new(8, 8);
|
||||
w.lemmings.spawn(4.0, 4.0, 1.0, [0.0; 4]); // energía 1 < umbral 5
|
||||
w.lemmings.accion[0] = 0; // Mover
|
||||
let p = SimParams::default();
|
||||
apply_transitions(&mut w, &p);
|
||||
assert_eq!(w.lemmings.accion[0], 5); // forzado a Degradar
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_agent_returns_energy_as_materia() {
|
||||
let mut w = World::new(8, 8);
|
||||
// Agente sin energía → muere este tick.
|
||||
w.lemmings.spawn(4.0, 4.0, 0.0, [0.0; 4]);
|
||||
let idx = w.grid.idx(4, 4);
|
||||
let p = SimParams::default();
|
||||
let reaped = age_and_reap(&mut w, &p);
|
||||
assert_eq!(reaped, 1);
|
||||
assert_eq!(w.lemmings.len(), 0);
|
||||
// (energía remanente 0 → materia no sube, pero no panickea)
|
||||
assert!(w.grid.materia[idx] >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_runs_without_panicking_on_a_populated_world() {
|
||||
let mut w = World::new(32, 32);
|
||||
for k in 0..20 {
|
||||
let x = (k % 8) as f32 + 2.0;
|
||||
let y = (k / 8) as f32 + 2.0;
|
||||
w.lemmings.spawn(x, y, 30.0, [1.0, 0.2, 0.5, 0.1]);
|
||||
w.lemmings.accion[k] = (k % 6) as u8;
|
||||
}
|
||||
// Sembrar algo de materia.
|
||||
for c in w.grid.materia.iter_mut() {
|
||||
*c = 5.0;
|
||||
}
|
||||
let p = SimParams::default();
|
||||
run(&mut w, &p, 50);
|
||||
// La sim avanzó 50 ticks sin romperse.
|
||||
assert!(w.lemmings.edad.iter().all(|&e| e <= 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_count_advances_one_per_step() {
|
||||
let mut w = World::new(4, 4);
|
||||
let p = SimParams::default();
|
||||
assert_eq!(w.tick_count, 0);
|
||||
tick(&mut w, &p);
|
||||
assert_eq!(w.tick_count, 1);
|
||||
run(&mut w, &p, 9);
|
||||
assert_eq!(w.tick_count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasons_modulate_entropy_decay() {
|
||||
// Mismo campo uniforme + dos params: uno con estaciones que arrancan
|
||||
// en pico de verano (sin(π/2)=1, factor 1+amp), otro sin estaciones.
|
||||
// El de verano debe perder más por entropía en el primer tick.
|
||||
let mut a = World::new(4, 4);
|
||||
let mut b = World::new(4, 4);
|
||||
for v in a.grid.psique.iter_mut() {
|
||||
*v = 10.0;
|
||||
}
|
||||
for v in b.grid.psique.iter_mut() {
|
||||
*v = 10.0;
|
||||
}
|
||||
// Arrancamos en t=0; con period=4 el primer tick muestrea sin(0)=0 →
|
||||
// factor 1. Ajusto: empujamos el reloj al tick que muestrea el pico.
|
||||
let mut hot = SimParams::default();
|
||||
hot.season_period = 4;
|
||||
hot.season_amplitude = 0.5;
|
||||
a.tick_count = 1; // sin(2π·1/4) = sin(π/2) = 1 → factor 1.5
|
||||
let cold = SimParams::default();
|
||||
tick(&mut a, &hot);
|
||||
tick(&mut b, &cold);
|
||||
let avg_a: f32 = a.grid.psique.iter().sum::<f32>() / 16.0;
|
||||
let avg_b: f32 = b.grid.psique.iter().sum::<f32>() / 16.0;
|
||||
assert!(
|
||||
avg_a < avg_b,
|
||||
"el de verano debe perder más entropía: a={avg_a} b={avg_b}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasons_disabled_by_default_keeps_old_behavior() {
|
||||
// Garantiza que el cambio de tick no movió el comportamiento default.
|
||||
let build = || {
|
||||
let mut w = World::new(8, 8);
|
||||
for c in w.grid.materia.iter_mut() {
|
||||
*c = 7.0;
|
||||
}
|
||||
for k in 0..5 {
|
||||
w.lemmings.spawn(2.0 + k as f32, 4.0, 30.0, [0.5, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
w
|
||||
};
|
||||
let p = SimParams::default();
|
||||
let mut a = build();
|
||||
let mut b = build();
|
||||
run(&mut a, &p, 20);
|
||||
run(&mut b, &p, 20);
|
||||
assert_eq!(a.grid.materia, b.grid.materia);
|
||||
assert_eq!(a.lemmings.energia, b.lemmings.energia);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psi_policy_fixed_default_keeps_accion_intact() {
|
||||
// ActionPolicy::Fixed (default) NO debe tocar la `accion` aunque
|
||||
// la fase 2c esté presente en el tick. Aislamos el efecto desactivando
|
||||
// metabolic_cost (para que `desperation_threshold` no aplique) y
|
||||
// abundance (para que no haya replicación lateral). Excluimos
|
||||
// `Replicar` y `Degradar` del set probado porque consumen energía
|
||||
// propia / ajena y desestabilizan el test.
|
||||
let mut w = World::new(8, 8);
|
||||
for c in w.grid.materia.iter_mut() { *c = 5.0; }
|
||||
// Agentes con accion 0,1,2,3: Mover/Extraer/Sincronizar/Intercambiar.
|
||||
for k in 0..4u8 {
|
||||
let i = w.lemmings.spawn(4.0, 4.0, 200.0, [0.5; 4]);
|
||||
w.lemmings.accion[i] = k;
|
||||
}
|
||||
let mut p = SimParams::default();
|
||||
p.metabolic_cost = 0.0;
|
||||
p.abundance_threshold = 0.0;
|
||||
let acciones_antes = w.lemmings.accion.clone();
|
||||
run(&mut w, &p, 5);
|
||||
assert_eq!(w.lemmings.accion, acciones_antes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psi_policy_argmax_reasigns_accion_segun_psi() {
|
||||
use dominium_core::ActionPolicy;
|
||||
// Tres agentes con psi extremos:
|
||||
// - psi=CORRUPTIBILIDAD → Degradar (5)
|
||||
// - psi=ORDEN → Intercambiar (3) por tie-break
|
||||
// - psi=CURIOSIDAD → Mover (0) por tie-break
|
||||
let mut w = World::new(8, 8);
|
||||
for c in w.grid.materia.iter_mut() { *c = 5.0; }
|
||||
w.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 0.0, 0.0, 1.0]);
|
||||
w.lemmings.spawn(4.0, 4.0, 50.0, [1.0, 0.0, 0.0, 0.0]);
|
||||
w.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 0.0, 1.0, 0.0]);
|
||||
// Acción inicial random (que NO coincide con lo esperado).
|
||||
w.lemmings.accion[0] = 0;
|
||||
w.lemmings.accion[1] = 0;
|
||||
w.lemmings.accion[2] = 5;
|
||||
let mut p = SimParams::default();
|
||||
p.action_policy = ActionPolicy::PsiArgmax;
|
||||
p.policy_reeval_period = 1; // reelige cada tick
|
||||
// Forzamos modulación 0 para que las acciones no cambien psi de paso
|
||||
// y el test mida sólo la reelección.
|
||||
p.psi_effect_modulation = 0.0;
|
||||
// Un solo tick basta: apply_psi_policy corre antes de step_lemming.
|
||||
tick(&mut w, &p);
|
||||
assert_eq!(w.lemmings.accion[0], 5, "corrupto → Degradar");
|
||||
assert_eq!(w.lemmings.accion[1], 3, "ordenado → Intercambiar (tie-break)");
|
||||
assert_eq!(w.lemmings.accion[2], 0, "curioso → Mover (tie-break)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psi_policy_argmax_respeta_hack_lock() {
|
||||
use dominium_core::ActionPolicy;
|
||||
// Un agente bajo hack_lock no debe ser reelegido por psi.
|
||||
let mut w = World::new(8, 8);
|
||||
w.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 0.0, 0.0, 1.0]); // psi → Degradar
|
||||
w.lemmings.accion[0] = 2; // pero está sincronizando bajo captura
|
||||
w.lemmings.hack_lock[0] = 50;
|
||||
let mut p = SimParams::default();
|
||||
p.action_policy = ActionPolicy::PsiArgmax;
|
||||
p.policy_reeval_period = 1;
|
||||
tick(&mut w, &p);
|
||||
// Sigue sincronizando: el hack_lock blinda contra la reelección psi.
|
||||
assert_eq!(w.lemmings.accion[0], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_is_deterministic() {
|
||||
let build = || {
|
||||
let mut w = World::new(16, 16);
|
||||
for k in 0..10 {
|
||||
w.lemmings.spawn(3.0 + k as f32, 8.0, 40.0, [1.0, 0.0, 0.3, 0.0]);
|
||||
w.lemmings.accion[k] = (k % 6) as u8;
|
||||
}
|
||||
for c in w.grid.materia.iter_mut() {
|
||||
*c = 3.0;
|
||||
}
|
||||
w
|
||||
};
|
||||
let p = SimParams::default();
|
||||
let mut a = build();
|
||||
let mut b = build();
|
||||
run(&mut a, &p, 30);
|
||||
run(&mut b, &p, 30);
|
||||
// Mismo input → mismo estado, bit a bit.
|
||||
assert_eq!(a.lemmings.pos_x, b.lemmings.pos_x);
|
||||
assert_eq!(a.lemmings.energia, b.lemmings.energia);
|
||||
assert_eq!(a.grid.materia, b.grid.materia);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Plataforma de hipótesis canónicas — el cuerpo experimental de dominium.
|
||||
//!
|
||||
//! Cada hipótesis es una **aserción cuantitativa** sobre el motor: "si encendés
|
||||
//! X, esperás que Y suba/baje/no cambie". Acá las codificamos como tests
|
||||
//! Monte Carlo: corremos N réplicas con seeds distintos, calculamos la media
|
||||
//! del estadístico y comparamos contra la rama de control con tolerancia
|
||||
//! holgada (el simulador es determinista por seed pero ruidoso entre seeds).
|
||||
//!
|
||||
//! No son tests de "no rompe" (los `--lib` ya cubren eso). Son **falsadores
|
||||
//! de fenómenos emergentes**: si alguien rompe la mecánica del contagio o de
|
||||
//! la homofilia, estos tests caen aunque el binario compile.
|
||||
//!
|
||||
//! Convención: cada hipótesis vive en un test con nombre `hipotesis_*`. El
|
||||
//! nombre describe la causalidad esperada ("homofilia_sube_morans_i"). El
|
||||
//! cuerpo monta dos configuraciones — control y tratamiento — corre N
|
||||
//! réplicas con seeds distintos y reporta la estadística agregada con
|
||||
//! `assert!(...)` sobre la diferencia de medias.
|
||||
|
||||
use dominium_core::{
|
||||
ActionPolicy, PsiMetrics, SimParams, World, WorldStats, MORANS_RADIUS_DEFAULT,
|
||||
};
|
||||
use dominium_physics::tick::run;
|
||||
|
||||
/// LCG mínimo determinista — el mismo que usa la app, pero local al test
|
||||
/// para no acoplar a sus internals.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed)
|
||||
}
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
(self.0 >> 33) as u32
|
||||
}
|
||||
fn next_f32(&mut self) -> f32 {
|
||||
(self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Cantidad de réplicas Monte Carlo. 8 es suficiente para distinguir efectos
|
||||
/// macroscópicos en estos sistemas; subir si las medias quedan cerca.
|
||||
const MC_REPS: usize = 8;
|
||||
/// Lado de la grilla cuadrada usada en los experimentos.
|
||||
const GRID: usize = 40;
|
||||
/// Población inicial por experimento. Suficientemente grande para que las
|
||||
/// métricas tengan señal, suficientemente chica para que 8 réplicas × 200
|
||||
/// ticks no demoren más de un par de segundos.
|
||||
const POP: usize = 200;
|
||||
/// Pasos de simulación por réplica. 200 alcanza para que el contagio sature
|
||||
/// y la polarización converja en sus regímenes característicos.
|
||||
const STEPS: usize = 200;
|
||||
|
||||
/// Construye un mundo con `POP` lemmings dispersos uniformemente y psi
|
||||
/// también uniforme en `[0, 1]`. El seed controla *todo* el ruido: misma
|
||||
/// seed → misma poblacion → mismo trayectoria.
|
||||
fn build_world(seed: u64) -> World {
|
||||
let mut w = World::new(GRID, GRID);
|
||||
let mut rng = Lcg::new(seed);
|
||||
// Pequeña materia uniforme para que los Extractores no se mueran.
|
||||
for c in w.grid.materia.iter_mut() {
|
||||
*c = 5.0;
|
||||
}
|
||||
for _ in 0..POP {
|
||||
let x = rng.next_f32() * (GRID as f32 - 1.0);
|
||||
let y = rng.next_f32() * (GRID as f32 - 1.0);
|
||||
let psi = [
|
||||
rng.next_f32(),
|
||||
rng.next_f32(),
|
||||
rng.next_f32(),
|
||||
rng.next_f32(),
|
||||
];
|
||||
let i = w.lemmings.spawn_big5(x, y, 50.0, psi, rng.next_f32());
|
||||
// Asignación de acciones balanceada para que no todos hagan lo mismo.
|
||||
w.lemmings.accion[i] = (rng.next_u32() % 6) as u8;
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Corre `steps` ticks y devuelve las métricas finales.
|
||||
fn run_and_measure(w: &mut World, p: &SimParams, steps: usize) -> (PsiMetrics, WorldStats) {
|
||||
run(w, p, steps);
|
||||
(PsiMetrics::from_world(w), WorldStats::from_world(w))
|
||||
}
|
||||
|
||||
/// Promedia una métrica escalar sobre `MC_REPS` réplicas.
|
||||
fn mean_over_reps<F>(mut compute: F) -> f64
|
||||
where
|
||||
F: FnMut(u64) -> f32,
|
||||
{
|
||||
let mut sum: f64 = 0.0;
|
||||
for r in 0..MC_REPS {
|
||||
let seed = 0xD0_31_31_07u64.wrapping_add(r as u64 * 0x9E37_79B9);
|
||||
sum += compute(seed) as f64;
|
||||
}
|
||||
sum / MC_REPS as f64
|
||||
}
|
||||
|
||||
// ─────────────────────── Hipótesis 1 ────────────────────────
|
||||
//
|
||||
// **↑ homofilia ⇒ ↑ Moran's I.**
|
||||
//
|
||||
// Cuando la homofilia es fuerte, los agentes sólo se influyen con los
|
||||
// psicológicamente parecidos. Las tribus emergen y se vuelven espacialmente
|
||||
// segregadas → la autocorrelación espacial (Moran's I) del psi sube.
|
||||
// Sin homofilia, el contagio universal homogeneiza y Moran's I tiende a 0.
|
||||
//
|
||||
// Estadístico: promedio de `moran_i[0]` (ORDEN) sobre 8 réplicas.
|
||||
|
||||
#[test]
|
||||
fn hipotesis_homofilia_sube_morans_i() {
|
||||
let mean_baseline = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 5.0;
|
||||
p.contagion_rate = 0.15;
|
||||
p.homophily_threshold = 0.0; // sin homofilia → contagio universal
|
||||
let (m, _) = run_and_measure(&mut w, &p, STEPS);
|
||||
m.moran_i[0].abs()
|
||||
});
|
||||
let mean_treatment = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 5.0;
|
||||
p.contagion_rate = 0.15;
|
||||
p.homophily_threshold = 0.4; // homofilia fuerte → tribus
|
||||
let (m, _) = run_and_measure(&mut w, &p, STEPS);
|
||||
m.moran_i[0].abs()
|
||||
});
|
||||
eprintln!(
|
||||
"[H1] Moran_i[ORDEN]: baseline {:.4} vs homofilia {:.4}",
|
||||
mean_baseline, mean_treatment
|
||||
);
|
||||
// El contagio universal ya produce algo de clustering por la geografía
|
||||
// (radius 5 << diagonal del grid 40×40), así que el baseline arranca
|
||||
// alto. La homofilia debe seguir levantándolo de forma consistente —
|
||||
// umbral mínimo 0.05 absoluto sobre el baseline.
|
||||
assert!(
|
||||
mean_treatment > mean_baseline + 0.05,
|
||||
"homofilia no levantó Moran's I: {} ≤ {} + 0.05",
|
||||
mean_treatment,
|
||||
mean_baseline
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────── Hipótesis 2 ────────────────────────
|
||||
//
|
||||
// **↑ contagion_rate con radio que cubre toda la grilla ⇒ ↓ varianza
|
||||
// poblacional del psi.**
|
||||
//
|
||||
// Con contagio fuerte y radio suficiente para conectar a todos los agentes,
|
||||
// la población converge a su promedio global y la varianza colapsa. Usamos
|
||||
// `var_psi` en vez de polarización porque Esteban-Ray normaliza por span:
|
||||
// con radios chicos pueden quedar clusters locales y la polarización subir;
|
||||
// la varianza es la métrica honesta de "convergencia al consenso".
|
||||
|
||||
#[test]
|
||||
fn hipotesis_contagio_universal_reduce_varianza() {
|
||||
let mean_baseline = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 0.0;
|
||||
p.contagion_rate = 0.0;
|
||||
let (_, s) = run_and_measure(&mut w, &p, STEPS);
|
||||
s.var_psi[0]
|
||||
});
|
||||
let mean_treatment = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
// Radio que cubre la diagonal del grid (40·√2 ≈ 57) → todos vecinos.
|
||||
p.social_radius = 60.0;
|
||||
p.contagion_rate = 0.30;
|
||||
p.homophily_threshold = 0.0;
|
||||
let (_, s) = run_and_measure(&mut w, &p, STEPS);
|
||||
s.var_psi[0]
|
||||
});
|
||||
eprintln!(
|
||||
"[H2] var(psi[ORDEN]): baseline {:.6} vs contagio universal {:.6}",
|
||||
mean_baseline, mean_treatment
|
||||
);
|
||||
// Contagio verdaderamente universal debe colapsar la varianza al menos
|
||||
// a la mitad (medirla por debajo del 50% del baseline).
|
||||
assert!(
|
||||
mean_treatment < mean_baseline * 0.5,
|
||||
"contagio no colapsó la varianza: {} ≥ 0.5 × {}",
|
||||
mean_treatment,
|
||||
mean_baseline
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────── Hipótesis 3 ────────────────────────
|
||||
//
|
||||
// **PsiArgmax + psi_modulation ⇒ |corr(psi, accion)| más alta que Fixed.**
|
||||
//
|
||||
// Con la política psicológica encendida, la acción del agente se vuelve
|
||||
// función del psi. La correlación punto-biserial entre cada componente del
|
||||
// psi y la acción mayoritaria que esa componente premia debe crecer.
|
||||
// Estadístico: max sobre `(k, a)` del valor absoluto de `psi_action_corr[k][a]`.
|
||||
|
||||
#[test]
|
||||
fn hipotesis_psi_argmax_aumenta_correlacion_psi_accion() {
|
||||
fn max_abs_corr(corr: &[[f32; 6]; 4]) -> f32 {
|
||||
let mut m: f32 = 0.0;
|
||||
for k in 0..4 {
|
||||
for a in 0..6 {
|
||||
let v = corr[k][a].abs();
|
||||
if v > m {
|
||||
m = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
let mean_baseline = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let p = SimParams::default(); // Fixed
|
||||
let (m, _) = run_and_measure(&mut w, &p, STEPS);
|
||||
max_abs_corr(&m.psi_action_corr)
|
||||
});
|
||||
let mean_treatment = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
p.action_policy = ActionPolicy::PsiArgmax;
|
||||
p.policy_reeval_period = 5; // reelige cada 5 ticks
|
||||
p.psi_effect_modulation = 0.5;
|
||||
let (m, _) = run_and_measure(&mut w, &p, STEPS);
|
||||
max_abs_corr(&m.psi_action_corr)
|
||||
});
|
||||
eprintln!(
|
||||
"[H3] max |corr(psi, accion)|: baseline {:.4} vs PsiArgmax {:.4}",
|
||||
mean_baseline, mean_treatment
|
||||
);
|
||||
assert!(
|
||||
mean_treatment > mean_baseline + 0.10,
|
||||
"PsiArgmax no aumentó correlación: {} no supera {} + 0.10",
|
||||
mean_treatment,
|
||||
mean_baseline
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────── Hipótesis 4 ────────────────────────
|
||||
//
|
||||
// **regrowth_rate > 0 ⇒ población sostenida vs sin regrowth.**
|
||||
//
|
||||
// Sin regrowth, la materia se agota por Extraer y la población colapsa.
|
||||
// Con regrowth, el cierre termodinámico mantiene un punto fijo `N* > 0`.
|
||||
|
||||
#[test]
|
||||
fn hipotesis_regrowth_sostiene_poblacion() {
|
||||
let mean_baseline = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
p.regrowth_rate = 0.0; // sin regrowth
|
||||
p.carrying_capacity = 0.0;
|
||||
let (_, s) = run_and_measure(&mut w, &p, STEPS);
|
||||
s.n as f32
|
||||
});
|
||||
let mean_treatment = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let p = SimParams::default(); // default tiene regrowth_rate > 0
|
||||
let (_, s) = run_and_measure(&mut w, &p, STEPS);
|
||||
s.n as f32
|
||||
});
|
||||
eprintln!(
|
||||
"[H4] N final: sin regrowth {:.1} vs con regrowth {:.1}",
|
||||
mean_baseline, mean_treatment
|
||||
);
|
||||
assert!(
|
||||
mean_treatment > mean_baseline + 5.0,
|
||||
"regrowth no sostuvo población: {} ≤ {} + 5",
|
||||
mean_treatment,
|
||||
mean_baseline
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────── Hipótesis 5 ────────────────────────
|
||||
//
|
||||
// **Big Five con peso ext positivo ⇒ acciones sociales (Mover/Sync/Intercambiar)
|
||||
// crecen vs Big Four bit-exacto.**
|
||||
//
|
||||
// Con `big_five=true` y `action_weights_ext` premiando Intercambiar/Sincronizar,
|
||||
// la política argmax debería empujar a más agentes hacia esas acciones, sobre
|
||||
// todo si `psi5` (Extraversion) es alta en promedio (lo es: nuestro builder
|
||||
// muestrea uniforme en [0, 1] → media 0.5).
|
||||
|
||||
#[test]
|
||||
fn hipotesis_big_five_levanta_acciones_sociales() {
|
||||
fn share_social(s: &WorldStats) -> f32 {
|
||||
let total: u32 = s.action_counts.iter().sum();
|
||||
if total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
// Mover (0) + Sincronizar (2) + Intercambiar (3)
|
||||
(s.action_counts[0] + s.action_counts[2] + s.action_counts[3]) as f32
|
||||
/ total as f32
|
||||
}
|
||||
let mean_baseline = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
p.action_policy = ActionPolicy::PsiArgmax;
|
||||
p.policy_reeval_period = 5;
|
||||
p.big_five = false;
|
||||
let (_, s) = run_and_measure(&mut w, &p, STEPS);
|
||||
share_social(&s)
|
||||
});
|
||||
let mean_treatment = mean_over_reps(|seed| {
|
||||
let mut w = build_world(seed);
|
||||
let mut p = SimParams::default();
|
||||
p.action_policy = ActionPolicy::PsiArgmax;
|
||||
p.policy_reeval_period = 5;
|
||||
p.big_five = true;
|
||||
// action_weights_ext default: Mover 0.4, Sync 0.6, Intercambiar 0.8.
|
||||
let (_, s) = run_and_measure(&mut w, &p, STEPS);
|
||||
share_social(&s)
|
||||
});
|
||||
eprintln!(
|
||||
"[H5] fracción social: Big4 {:.4} vs Big5 {:.4}",
|
||||
mean_baseline, mean_treatment
|
||||
);
|
||||
assert!(
|
||||
mean_treatment > mean_baseline + 0.05,
|
||||
"Big Five no levantó fracción social: {} ≤ {} + 0.05",
|
||||
mean_treatment,
|
||||
mean_baseline
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────── Hipótesis 6 ────────────────────────
|
||||
//
|
||||
// **Determinismo bit-exacto:** misma seed → misma trayectoria, incluso
|
||||
// con todas las mecánicas opt-in encendidas a la vez. Si esto cae, algún
|
||||
// componente nuevo metió no-determinismo (HashMap iteration, RNG global,
|
||||
// reducción paralela). Es el guardián más importante de la plataforma.
|
||||
|
||||
#[test]
|
||||
fn hipotesis_determinismo_bit_exacto_con_todas_las_mecanicas() {
|
||||
let mut p = SimParams::default();
|
||||
p.social_radius = 4.0;
|
||||
p.contagion_rate = 0.10;
|
||||
p.homophily_threshold = 0.4;
|
||||
p.action_policy = ActionPolicy::PsiArgmax;
|
||||
p.policy_reeval_period = 7;
|
||||
p.psi_effect_modulation = 0.6;
|
||||
p.big_five = true;
|
||||
p.season_period = 50;
|
||||
p.season_amplitude = 0.3;
|
||||
let mut a = build_world(0xCAFE_BABE);
|
||||
let mut b = build_world(0xCAFE_BABE);
|
||||
run(&mut a, &p, 150);
|
||||
run(&mut b, &p, 150);
|
||||
assert_eq!(a.lemmings.pos_x, b.lemmings.pos_x);
|
||||
assert_eq!(a.lemmings.pos_y, b.lemmings.pos_y);
|
||||
assert_eq!(a.lemmings.energia, b.lemmings.energia);
|
||||
assert_eq!(a.lemmings.vector_psi, b.lemmings.vector_psi);
|
||||
assert_eq!(a.lemmings.psi5, b.lemmings.psi5);
|
||||
assert_eq!(a.lemmings.accion, b.lemmings.accion);
|
||||
assert_eq!(a.grid.materia, b.grid.materia);
|
||||
let _ = MORANS_RADIUS_DEFAULT; // chequeo que la constante sigue exportada
|
||||
}
|
||||
Reference in New Issue
Block a user