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:
2026-06-16 23:22:40 +00:00
commit 1860b51f70
70 changed files with 19902 additions and 0 deletions
@@ -0,0 +1,15 @@
[package]
name = "dominium-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium — núcleo del simulador de campo medio: grilla SoA de 5 capas + Lemmings vectoriales + las 6 acciones atómicas. Sin deps gráficas."
[dependencies]
serde = { workspace = true }
libm = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+19
View File
@@ -0,0 +1,19 @@
# dominium-core
> Datos + 6 acciones atómicas + Conceptos JSON para [dominium](../README.md). Sin gráficos.
`Grid` con 5 capas (`materia`, `psique`, `poder`, `oro`, `degradacion`) en `Vec<f32>` indexados `y * width + x`. `Agent` con vector estado + decisión. Seis acciones atómicas: `Mover`, `Tomar`, `Soltar`, `Transmitir`, `Atacar`, `Descansar`. `Concepto` carga emisores de campo via JSON (`id+pos+radio+mods+hack`).
## API
```rust
use dominium_core::{World, Concept};
let mut w = World::new(256, 256, seed);
w.cargar_conceptos(&conceptos_json)?;
```
## Deps
- `serde`, `libm`
- Cero deps gráficas (regla inviolable)
@@ -0,0 +1,19 @@
# dominium-core
> Data + 6 atomic actions + JSON Concepts for [dominium](../README.md). No graphics.
`Grid` with 5 layers (`materia`, `psique`, `poder`, `oro`, `degradacion`) in `Vec<f32>` indexed `y * width + x`. `Agent` with vector state + decision. Six atomic actions: `Mover`, `Tomar`, `Soltar`, `Transmitir`, `Atacar`, `Descansar`. `Concepto` loads field emitters via JSON (`id+pos+radio+mods+hack`).
## API
```rust
use dominium_core::{World, Concept};
let mut w = World::new(256, 256, seed);
w.cargar_conceptos(&conceptos_json)?;
```
## Deps
- `serde`, `libm`
- Zero graphics deps (inviolable rule)
@@ -0,0 +1,254 @@
//! Conceptos — emisores de campo metaprogramables.
//!
//! Un `Concepto` es una **entidad de diseño**, no de código. Lleva una
//! posición, un radio, modificadores por capa (cuánto emite/drena de
//! `materia/psique/poder/oro` por tick a cada celda dentro del radio) y un
//! `BehaviorHack` opcional que captura la acción de los Lemmings que entran
//! a su radio.
//!
//! Para el motor, una "iglesia", un "banco" o una "comuna" no son tipos
//! distintos: son la misma estructura con números diferentes. La iglesia es
//! `mods.psique > 0, mods.materia < 0` con `hack: forced_action = Sincronizar`.
//! El banco es `mods.oro < 0, mods.poder > 0`. La comuna es `mods.materia >
//! 0, mods.degradacion no se toca` (degradacion no es modificable: es
//! cicatriz emergente del extraer).
//!
//! La unidad es **una pieza de datos**: serializable a JSON, generable por
//! cualquier productor externo (un humano en un panel, un script, una IA
//! offline) sin tocar el código del motor.
//!
//! El motor sigue siendo *tonto*: en `dominium-physics` recorre la lista de
//! conceptos y suma los modificadores con un falloff lineal. Cero IA, cero
//! embeddings, cero narrativa. Solo álgebra sobre la grilla.
use serde::{Deserialize, Serialize};
/// Emisión/drenaje por tick en una celda en el centro del radio. En el
/// borde el valor cae linealmente a cero (falloff lineal).
///
/// `degradacion` no se modifica desde un Concepto — es cicatriz emergente
/// del extraer de los Lemmings, no algo que un emisor pueda revertir.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct LayerMods {
pub materia: f32,
pub psique: f32,
pub poder: f32,
pub oro: f32,
}
/// Condición que dispara un `BehaviorHack` sobre un Lemming que cae dentro
/// del radio del Concepto.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Trigger {
/// Cualquier Lemming en el radio queda capturado.
Always,
/// Solo si la `energia` del Lemming está por debajo del umbral.
EnergiaBajo(f32),
/// Solo si la `edad` del Lemming es mayor al umbral.
EdadSobre(u32),
}
/// Toma de control de la acción de un Lemming durante `duration` ticks.
///
/// Mientras esté capturado (`hack_lock > 0`), el Lemming ejecuta
/// `forced_action` ignorando cualquier transición que el motor le aplicaría
/// (incluida la desesperación → pelear).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BehaviorHack {
pub trigger: Trigger,
/// Byte de la acción forzada (0-5; ver [`crate::Action`]).
pub forced_action: u8,
/// Ticks que dura el hack desde que se aplica.
pub duration: u32,
}
/// Influencia psicológica de un Concepto — Fase B.2.
///
/// A diferencia del `BehaviorHack` (que CONGELA acción por N ticks, una
/// metáfora de coerción/captura), la `Persuasion` empuja el `vector_psi`
/// del agente hacia un objetivo cada tick mientras esté dentro del radio,
/// sin tocar su acción. Es la mecánica canónica de **persuasión** /
/// **propaganda**: el agente sigue siendo libre de actuar, pero su
/// psicología deriva.
///
/// El falloff es lineal (1 en el centro, 0 en el borde) — la mismo que
/// usa `LayerMods` sobre la grilla. Así un agente que entra y sale del
/// radio acumula influencia proporcional al tiempo expuesto y a su
/// proximidad al centro.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Persuasion {
/// Objetivo psicológico hacia el que se empuja el `vector_psi` del
/// agente. Convención `[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`.
/// Ej. una "iglesia ortodoxa" usaría `[1.0, 0.5, 0.0, 0.0]`.
pub target_psi: [f32; 4],
/// Tasa de convergencia por tick a falloff 1.0 (centro del radio).
/// `psi_nuevo = psi + rate · falloff · (target psi)`. Rango útil
/// 0.01..0.10. Valores grandes producen "lavado de cerebro" en pocos
/// ticks; chicos generan deriva lenta.
pub rate: f32,
}
/// Un emisor de campo metaprogramable.
///
/// `sprite_id` es opaco al motor: solo viaja del JSON hasta el backend
/// gráfico (que decide qué pintar). El motor no le mira el valor.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Concepto {
/// Nombre legible (ej. `"iglesia"`, `"banco-central"`, `"comuna"`).
/// Solo informativo: el motor lo ignora.
pub id: String,
/// Identificador opaco del sprite que el backend usa para dibujarlo.
#[serde(default)]
pub sprite_id: u32,
pub pos_x: f32,
pub pos_y: f32,
/// Radio de influencia en unidades de celda.
pub radius: f32,
/// Cuánto emite/drena por tick en el centro (cae linealmente al borde).
pub mods: LayerMods,
/// Toma de control opcional. `None` = solo emite campo.
#[serde(default)]
pub hack: Option<BehaviorHack>,
/// Persuasión psicológica opcional (Fase B.2). `None` = el Concepto
/// sólo emite campo / hackea acción. Cuando está presente, ADEMÁS
/// empuja el `vector_psi` de los lemmings dentro del radio cada tick.
/// Es ortogonal al `hack`: un Concepto puede coercer una acción Y
/// persuadir psi simultáneamente.
#[serde(default)]
pub persuasion: Option<Persuasion>,
}
/// Colección lineal. Sin ordenamiento, sin índice espacial: la sim es
/// chica (decenas de conceptos × miles de celdas/Lemmings) y el costo es
/// despreciable. La iteración es determinista por orden de inserción.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Conceptos {
pub items: Vec<Concepto>,
}
impl Conceptos {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn clear(&mut self) {
self.items.clear();
}
/// Agrega un concepto al final. Devuelve su índice.
pub fn add(&mut self, c: Concepto) -> usize {
self.items.push(c);
self.items.len() - 1
}
/// Elimina por índice con `swap_remove` — O(1), no preserva el orden.
pub fn remove(&mut self, i: usize) {
self.items.swap_remove(i);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_and_remove_swap() {
let mut cs = Conceptos::new();
let a = cs.add(Concepto {
id: "a".into(),
sprite_id: 0,
pos_x: 1.0,
pos_y: 1.0,
radius: 5.0,
mods: LayerMods::default(),
hack: None,
persuasion: None,
});
let _b = cs.add(Concepto {
id: "b".into(),
sprite_id: 0,
pos_x: 2.0,
pos_y: 2.0,
radius: 5.0,
mods: LayerMods::default(),
hack: None,
persuasion: None,
});
assert_eq!((a, cs.len()), (0, 2));
cs.remove(a);
assert_eq!(cs.len(), 1);
assert_eq!(cs.items[0].id, "b");
}
#[test]
fn json_roundtrip_preserves_concepto() {
let c = Concepto {
id: "iglesia".into(),
sprite_id: 42,
pos_x: 10.0,
pos_y: 10.0,
radius: 6.0,
mods: LayerMods { materia: -0.1, psique: 0.8, poder: 0.3, oro: 0.0 },
hack: Some(BehaviorHack {
trigger: Trigger::EnergiaBajo(20.0),
forced_action: 2,
duration: 50,
}),
persuasion: None,
};
let s = serde_json::to_string(&c).expect("serializa");
let back: Concepto = serde_json::from_str(&s).expect("deserializa");
assert_eq!(c, back);
}
#[test]
fn json_collection_roundtrip() {
let mut cs = Conceptos::new();
cs.add(Concepto {
id: "iglesia".into(),
sprite_id: 1,
pos_x: 8.0,
pos_y: 8.0,
radius: 5.0,
mods: LayerMods { psique: 0.5, ..Default::default() },
hack: None,
persuasion: None,
});
cs.add(Concepto {
id: "banco".into(),
sprite_id: 2,
pos_x: 30.0,
pos_y: 12.0,
radius: 4.0,
mods: LayerMods { oro: -0.2, poder: 0.4, ..Default::default() },
hack: None,
persuasion: None,
});
let s = serde_json::to_string(&cs).expect("serializa");
let back: Conceptos = serde_json::from_str(&s).expect("deserializa");
assert_eq!(cs, back);
}
#[test]
fn default_optional_fields_in_json() {
// sprite_id y hack tienen serde(default); deben aceptar JSONs minimalistas.
let raw = r#"{
"id": "minimal",
"pos_x": 0.0,
"pos_y": 0.0,
"radius": 1.0,
"mods": { "materia": 0.0, "psique": 0.0, "poder": 0.0, "oro": 0.0 }
}"#;
let c: Concepto = serde_json::from_str(raw).expect("deserializa minimal");
assert_eq!(c.sprite_id, 0);
assert!(c.hack.is_none());
}
}
@@ -0,0 +1,180 @@
//! Clasificación cualitativa del estado del mundo — "qué época estamos
//! viviendo" a partir de las métricas agregadas.
//!
//! No es una capa del motor: el motor sigue ignorando que existen "edades de
//! oro". Esto es un **lector** que toma `WorldStats` y traduce los números a
//! una etiqueta legible para mostrar en el HUD o etiquetar filas del CSV.
//!
//! Las heurísticas son honestas y pocas: seis arquetipos con umbrales fijos,
//! orden de prelación explícito. Cuando el mundo no encaja en ningún
//! arquetipo, cae a [`Epoch::Equilibrio`].
use crate::metrics::WorldStats;
use serde::{Deserialize, Serialize};
/// Arquetipos macro que el mundo puede atravesar. La clasificación corre
/// sobre la `WorldStats` instantánea — no hay memoria, así que el "Auge" no
/// implica que la pob esté creciendo (no tenemos derivadas), sino que el
/// estado actual *parece un auge*: mucha materia, mucha energía, Gini bajo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Epoch {
/// Mundo vacío o casi — la población se extinguió o está al borde.
Colapso,
/// Mucha gente, poca materia/energía promedio: hay hambre.
Hambruna,
/// Gini extremo: pocos concentran la energía, la mayoría malvive.
Imperio,
/// Mucha materia, mucha energía, Gini moderado: prosperidad amplia.
EdadDeOro,
/// Más materia que pob., reservas creciendo: el motor está "respirando".
Auge,
/// Default: nada extremo, el sistema flota.
Equilibrio,
}
impl Epoch {
/// Etiqueta corta y legible para HUD/CSV. Sin tilde donde podría romper
/// renderers ASCII-only.
pub fn label(self) -> &'static str {
match self {
Epoch::Colapso => "colapso",
Epoch::Hambruna => "hambruna",
Epoch::Imperio => "imperio",
Epoch::EdadDeOro => "edad-de-oro",
Epoch::Auge => "auge",
Epoch::Equilibrio => "equilibrio",
}
}
/// Clasifica el mundo según `stats`. Orden de prelación: colapso →
/// hambruna → imperio → edad-de-oro → auge → equilibrio. La primera
/// regla que matchea gana — los umbrales están elegidos para que sólo
/// una matchee a la vez en la práctica.
pub fn classify(stats: &WorldStats) -> Epoch {
// 1. Colapso: muy poca gente — o se está extinguiendo o ya pasó.
if stats.n < 5 {
return Epoch::Colapso;
}
let nf = stats.n as f32;
let energia_por_capita = stats.total_energia / nf;
let materia_por_capita = stats.total_materia / nf;
// 2. Hambruna: muchos, poca energía y poca materia disponible.
if energia_por_capita < 8.0 && materia_por_capita < 30.0 {
return Epoch::Hambruna;
}
// 3. Imperio: concentración brutal de energía aunque haya recursos.
if stats.gini_energia > 0.55 {
return Epoch::Imperio;
}
// 4. Edad de oro: holgura energética y suelo fértil, sin grandes
// diferencias. El umbral de materia es absoluto para que mundos
// chicos no clasifiquen como "edad de oro" a falta de masa.
if energia_por_capita > 25.0
&& materia_por_capita > 60.0
&& stats.gini_energia < 0.35
{
return Epoch::EdadDeOro;
}
// 5. Auge: materia abundante sin que la energía haya explotado aún.
// Si materia_por_capita es alto pero no llegamos al combo de
// "edad de oro" es porque la energía aún no se reparte — está
// pasando algo bueno pero todavía no llegó a la mesa.
if materia_por_capita > 80.0 {
return Epoch::Auge;
}
// 6. Default.
Epoch::Equilibrio
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::World;
fn stats_from(
n: usize,
energia: f32,
materia: f32,
gini: f32,
) -> WorldStats {
WorldStats {
n,
gini_energia: gini,
var_psi: [0.0; 4],
action_counts: [0; 6],
total_materia: materia,
total_psique: 0.0,
total_poder: 0.0,
total_oro: 0.0,
total_degradacion: 0.0,
mean_edad: 0.0,
total_energia: energia,
}
}
#[test]
fn colapso_when_population_is_tiny() {
let s = stats_from(2, 100.0, 100.0, 0.0);
assert_eq!(Epoch::classify(&s), Epoch::Colapso);
}
#[test]
fn hambruna_when_per_capita_is_low() {
// 50 lemmings, 50 energía total (1.0/cap), 100 materia total (2.0/cap).
let s = stats_from(50, 50.0, 100.0, 0.1);
assert_eq!(Epoch::classify(&s), Epoch::Hambruna);
}
#[test]
fn imperio_when_gini_is_high() {
// Hay recursos pero un puñado concentra todo.
let s = stats_from(50, 5000.0, 5000.0, 0.7);
assert_eq!(Epoch::classify(&s), Epoch::Imperio);
}
#[test]
fn edad_de_oro_when_abundant_and_egalitarian() {
// 30/cap energía, 100/cap materia, gini bajo.
let s = stats_from(50, 1500.0, 5000.0, 0.20);
assert_eq!(Epoch::classify(&s), Epoch::EdadDeOro);
}
#[test]
fn auge_when_materia_abundant_but_energy_modest() {
// Mucha materia/cap pero energía/cap insuficiente para edad de oro.
let s = stats_from(50, 600.0, 5000.0, 0.30);
assert_eq!(Epoch::classify(&s), Epoch::Auge);
}
#[test]
fn equilibrio_is_the_default() {
let s = stats_from(50, 700.0, 2000.0, 0.30);
assert_eq!(Epoch::classify(&s), Epoch::Equilibrio);
}
#[test]
fn empty_world_collapses() {
let w = World::new(4, 4);
let s = WorldStats::from_world(&w);
assert_eq!(Epoch::classify(&s), Epoch::Colapso);
}
#[test]
fn label_is_stable_and_ascii_safe() {
// Sanity para CSV/logs: ningún arquetipo emite cadena vacía.
for e in [
Epoch::Colapso,
Epoch::Hambruna,
Epoch::Imperio,
Epoch::EdadDeOro,
Epoch::Auge,
Epoch::Equilibrio,
] {
let l = e.label();
assert!(!l.is_empty());
assert!(l.is_ascii());
}
}
}
@@ -0,0 +1,455 @@
//! Eventos discretos — Fase D.1 del simulador.
//!
//! Hasta ahora el mundo evolucionaba sólo por su dinámica interna
//! (difusión, agentes, Conceptos estáticos). Los eventos discretos son
//! **perturbaciones puntuales** que el experimentador inyecta en ticks
//! específicos para medir la respuesta poblacional: una sequía, una
//! noticia, una pandemia mental.
//!
//! Cada `Event` lleva el `tick` exacto en el que se dispara. El CLI carga
//! una *timeline* JSON (lista ordenada de eventos) y antes de cada
//! `tick()` aplica los que coinciden con el reloj global.
//!
//! Determinismo: la aplicación es lineal (sin random), los eventos se
//! procesan en orden de aparición en la lista. Mismas listas en x86 y
//! ARM → mismas trayectorias bit-exactas.
use crate::lemmings::{PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_MIEDO, PSI_ORDEN};
use crate::world::World;
use serde::{Deserialize, Serialize};
/// Identificador semántico de una capa del Sustrato. Se serializa como
/// string (`"materia"`, `"psique"`, …) para que las timelines JSON sean
/// legibles a ojo, no como bytes opacos.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LayerId {
Materia,
Psique,
Poder,
Oro,
Degradacion,
}
/// Variantes de evento. Diseñadas para ser ortogonales: cada una toca
/// exactamente un eje del mundo (capa de grilla, vector_psi de agentes, o
/// la lista de agentes mismos). Se evita el "mega-evento" porque rompe
/// la composabilidad.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum EventKind {
/// Suma `amount` (con falloff lineal) a la capa indicada en una región
/// circular. `amount` puede ser negativo (drenaje). Modela: sequía,
/// descubrimiento de oro, plaga sobre la materia, contaminación.
Shock {
layer: LayerId,
x: f32,
y: f32,
radius: f32,
amount: f32,
},
/// Suma un delta a `vector_psi` de los agentes en una región circular,
/// con falloff lineal en el centro→borde. Modela: noticia, manifiesto,
/// shock cultural. Cero efecto sobre la grilla.
PsiNudge {
x: f32,
y: f32,
radius: f32,
delta_psi: [f32; 4],
},
/// Spawnea `n` agentes con `psi/energia/accion` iguales. Si `radius > 0`
/// y `n > 1`, los dispersa en una rejilla en espiral de Vogel
/// (determinista, simétrica) dentro del círculo; si `radius == 0` o
/// `n == 1`, todos quedan en `(x, y)`. Modela: migración, refugiados,
/// nacimiento de una colonia.
Spawn {
x: f32,
y: f32,
n: u32,
radius: f32,
energia: f32,
psi: [f32; 4],
accion: u8,
},
/// Mata todos los agentes dentro del radio. Determinista total: la
/// fracción que muere no es probabilística — todo el que está
/// adentro, muere. Modela: pandemia regional, genocidio, terremoto
/// localizado. Para fracciones parciales, encadená varios `Kill` con
/// radios concéntricos en distintos ticks.
Kill { x: f32, y: f32, radius: f32 },
}
/// Un evento etiquetado con el tick en que debe dispararse.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
/// Reloj global (`World::tick_count`) en el que se aplica.
pub tick: u64,
#[serde(flatten)]
pub kind: EventKind,
}
/// Aplica un único evento al mundo. Funcionalmente puro respecto del tick
/// (no consulta `world.tick_count` — quién llame decide *cuándo* lo aplica
/// según su propia política).
pub fn apply_event(world: &mut World, ev: &EventKind) {
match ev {
EventKind::Shock { layer, x, y, radius, amount } => {
apply_shock_on_layer(world, *layer, *x, *y, *radius, *amount);
}
EventKind::PsiNudge { x, y, radius, delta_psi } => {
apply_psi_nudge(world, *x, *y, *radius, *delta_psi);
}
EventKind::Spawn { x, y, n, radius, energia, psi, accion } => {
apply_spawn(world, *x, *y, *n, *radius, *energia, *psi, *accion);
}
EventKind::Kill { x, y, radius } => {
apply_kill(world, *x, *y, *radius);
}
}
}
fn apply_shock_on_layer(
world: &mut World,
layer: LayerId,
x: f32,
y: f32,
radius: f32,
amount: f32,
) {
if radius <= 0.0 {
return;
}
let r2 = radius * radius;
let w = world.grid.width;
let h = world.grid.height;
let xmin = ((x - radius).floor() as i64).max(0) as usize;
let xmax_raw = ((x + radius).ceil() as i64).max(0) as usize;
let xmax = xmax_raw.min(w.saturating_sub(1));
let ymin = ((y - radius).floor() as i64).max(0) as usize;
let ymax_raw = ((y + radius).ceil() as i64).max(0) as usize;
let ymax = ymax_raw.min(h.saturating_sub(1));
if xmin >= w || ymin >= h {
return;
}
for cy in ymin..=ymax {
for cx in xmin..=xmax {
let dx = cx as f32 - x;
let dy = cy as f32 - 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);
let delta = amount * falloff;
match layer {
LayerId::Materia => world.grid.materia[idx] += delta,
LayerId::Psique => world.grid.psique[idx] += delta,
LayerId::Poder => world.grid.poder[idx] += delta,
LayerId::Oro => world.grid.oro[idx] += delta,
LayerId::Degradacion => world.grid.degradacion[idx] += delta,
}
}
}
}
/// Spawnea `n` agentes determinísticamente. Espiral de Vogel
/// (golden-angle): para `k ∈ 0..n`, `θ_k = k · 137.5077°` y
/// `r_k = radius · sqrt(k / (n-1))`. Distribuye uniformemente sin RNG —
/// el patrón es bit-exacto cross-platform vía libm.
fn apply_spawn(
world: &mut World,
x: f32,
y: f32,
n: u32,
radius: f32,
energia: f32,
psi: [f32; 4],
accion: u8,
) {
if n == 0 {
return;
}
let max_x = world.grid.width as f32 - 1.0;
let max_y = world.grid.height as f32 - 1.0;
let radius_eff = radius.max(0.0);
// Golden angle en radianes: π · (3 √5).
let golden = std::f32::consts::PI * (3.0 - libm::sqrtf(5.0));
let nf = n as f32;
for k in 0..n {
let (px, py) = if radius_eff > 0.0 && n > 1 {
let kf = k as f32;
let theta = kf * golden;
// Distancia normalizada por raíz cuadrada — distribución uniforme
// en el disco. `+ 0.5` centra el primer punto fuera del origen.
let r = radius_eff * libm::sqrtf((kf + 0.5) / nf);
(
x + r * libm::cosf(theta),
y + r * libm::sinf(theta),
)
} else {
(x, y)
};
let px = px.clamp(0.0, max_x);
let py = py.clamp(0.0, max_y);
let i = world.lemmings.spawn(px, py, energia, psi);
world.lemmings.accion[i] = accion.min(5);
}
}
/// Mata determinísticamente todos los agentes dentro del radio. Recorre
/// índices al revés para que `swap_remove` no invalide los menores que
/// todavía no procesamos. Bit-exacto cross-platform.
fn apply_kill(world: &mut World, x: f32, y: f32, radius: f32) {
if radius <= 0.0 {
return;
}
let r2 = radius * radius;
// Recolectar índices a matar primero, luego matarlos en orden decreciente.
let mut to_kill: Vec<usize> = Vec::new();
for i in 0..world.lemmings.len() {
let dx = world.lemmings.pos_x[i] - x;
let dy = world.lemmings.pos_y[i] - y;
if dx * dx + dy * dy <= r2 {
to_kill.push(i);
}
}
// Sort descendente: `swap_remove` mueve el último al hueco; si vamos
// de mayor a menor, los índices menores siguen siendo válidos.
to_kill.sort_unstable_by(|a, b| b.cmp(a));
for i in to_kill {
world.lemmings.remove(i);
}
}
fn apply_psi_nudge(world: &mut World, x: f32, y: f32, radius: f32, delta: [f32; 4]) {
if radius <= 0.0 {
return;
}
let r2 = radius * radius;
for i in 0..world.lemmings.len() {
let dx = world.lemmings.pos_x[i] - x;
let dy = world.lemmings.pos_y[i] - y;
let d2 = dx * dx + dy * dy;
if d2 > r2 {
continue;
}
let falloff = 1.0 - libm::sqrtf(d2 / r2);
let psi = &mut world.lemmings.vector_psi[i];
psi[PSI_ORDEN] += delta[PSI_ORDEN] * falloff;
psi[PSI_MIEDO] += delta[PSI_MIEDO] * falloff;
psi[PSI_CURIOSIDAD] += delta[PSI_CURIOSIDAD] * falloff;
psi[PSI_CORRUPTIBILIDAD] += delta[PSI_CORRUPTIBILIDAD] * falloff;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shock_materia_inyecta_y_falloff_lineal() {
let mut w = World::new(20, 20);
apply_event(
&mut w,
&EventKind::Shock {
layer: LayerId::Materia,
x: 10.0,
y: 10.0,
radius: 4.0,
amount: 100.0,
},
);
let center = w.grid.idx(10, 10);
let halfway = w.grid.idx(12, 10);
let edge = w.grid.idx(14, 10); // distancia 4 = radius → falloff 0
assert!((w.grid.materia[center] - 100.0).abs() < 1e-4);
assert!(w.grid.materia[halfway] > 0.0);
assert!(w.grid.materia[halfway] < 100.0);
assert!(w.grid.materia[edge].abs() < 1e-5);
}
#[test]
fn shock_negativo_drena() {
let mut w = World::new(8, 8);
for c in w.grid.materia.iter_mut() {
*c = 50.0;
}
apply_event(
&mut w,
&EventKind::Shock {
layer: LayerId::Materia,
x: 4.0,
y: 4.0,
radius: 2.0,
amount: -30.0,
},
);
let center = w.grid.idx(4, 4);
assert!(
(w.grid.materia[center] - 20.0).abs() < 1e-4,
"drenó {} en lugar de 30",
50.0 - w.grid.materia[center]
);
}
#[test]
fn psi_nudge_empuja_vector_psi_de_agentes_en_radio() {
let mut w = World::new(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]); // dentro
w.lemmings.spawn(0.0, 0.0, 30.0, [0.5; 4]); // afuera
let psi_pre_outside = w.lemmings.vector_psi[1];
apply_event(
&mut w,
&EventKind::PsiNudge {
x: 10.0,
y: 10.0,
radius: 5.0,
delta_psi: [0.3, 0.0, 0.0, 0.0],
},
);
// Agente en el centro: falloff = 1, psi[0] sube 0.3.
assert!((w.lemmings.vector_psi[0][0] - 0.3).abs() < 1e-5);
// Agente fuera del radio: sin cambios.
assert_eq!(w.lemmings.vector_psi[1], psi_pre_outside);
}
#[test]
fn spawn_zero_n_is_noop() {
let mut w = World::new(20, 20);
apply_event(
&mut w,
&EventKind::Spawn {
x: 10.0, y: 10.0, n: 0, radius: 5.0,
energia: 30.0, psi: [0.5; 4], accion: 0,
},
);
assert_eq!(w.lemmings.len(), 0);
}
#[test]
fn spawn_one_agent_at_point() {
let mut w = World::new(20, 20);
apply_event(
&mut w,
&EventKind::Spawn {
x: 10.0, y: 10.0, n: 1, radius: 0.0,
energia: 42.0, psi: [0.1, 0.2, 0.3, 0.4], accion: 3,
},
);
assert_eq!(w.lemmings.len(), 1);
assert_eq!(w.lemmings.pos_x[0], 10.0);
assert_eq!(w.lemmings.pos_y[0], 10.0);
assert_eq!(w.lemmings.energia[0], 42.0);
assert_eq!(w.lemmings.vector_psi[0], [0.1, 0.2, 0.3, 0.4]);
assert_eq!(w.lemmings.accion[0], 3);
}
#[test]
fn spawn_n_disperses_in_radius_deterministically() {
// Espiral de Vogel: para n=20, radius=5, todos los agentes deben
// caer dentro del círculo (distancia ≤ radius) y la distribución
// debe ser repetible bit-exacto.
let mut a = World::new(40, 40);
let mut b = World::new(40, 40);
let ev = EventKind::Spawn {
x: 20.0, y: 20.0, n: 20, radius: 5.0,
energia: 30.0, psi: [0.5; 4], accion: 1,
};
apply_event(&mut a, &ev);
apply_event(&mut b, &ev);
assert_eq!(a.lemmings.len(), 20);
assert_eq!(a.lemmings.pos_x, b.lemmings.pos_x);
assert_eq!(a.lemmings.pos_y, b.lemmings.pos_y);
// Todos dentro del círculo (+ pequeña tolerancia por sqrt).
for i in 0..a.lemmings.len() {
let dx = a.lemmings.pos_x[i] - 20.0;
let dy = a.lemmings.pos_y[i] - 20.0;
let d = libm::sqrtf(dx * dx + dy * dy);
assert!(d <= 5.0 + 1e-3, "agente {i} fuera del círculo: d={d}");
}
// No todos en el mismo punto (verificación de dispersión efectiva).
let center_dx = a.lemmings.pos_x[0] - 20.0;
let center_dy = a.lemmings.pos_y[0] - 20.0;
let other_dx = a.lemmings.pos_x[10] - 20.0;
let other_dy = a.lemmings.pos_y[10] - 20.0;
assert!(
(center_dx - other_dx).abs() > 0.5 || (center_dy - other_dy).abs() > 0.5,
"agentes 0 y 10 demasiado cerca — la espiral no dispersó"
);
}
#[test]
fn kill_removes_agents_inside_radius() {
let mut w = World::new(40, 40);
// 3 dentro del radio (centro 20,20, r=5), 2 afuera.
w.lemmings.spawn(20.0, 20.0, 30.0, [0.0; 4]); // dentro
w.lemmings.spawn(22.0, 20.0, 30.0, [0.1; 4]); // dentro
w.lemmings.spawn(19.0, 21.0, 30.0, [0.2; 4]); // dentro
w.lemmings.spawn(30.0, 30.0, 30.0, [0.3; 4]); // afuera
w.lemmings.spawn(5.0, 5.0, 30.0, [0.4; 4]); // afuera
apply_event(&mut w, &EventKind::Kill { x: 20.0, y: 20.0, radius: 5.0 });
assert_eq!(w.lemmings.len(), 2);
// Los sobrevivientes son los dos lejos — sus psi se preservan
// (no exigimos orden por swap_remove, pero deben ser los originales).
let psis: Vec<[f32; 4]> = w.lemmings.vector_psi.clone();
assert!(psis.contains(&[0.3; 4]));
assert!(psis.contains(&[0.4; 4]));
}
#[test]
fn kill_zero_radius_is_noop() {
let mut w = World::new(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
apply_event(&mut w, &EventKind::Kill { x: 10.0, y: 10.0, radius: 0.0 });
assert_eq!(w.lemmings.len(), 1);
}
#[test]
fn timeline_json_roundtrip() {
let events = vec![
Event {
tick: 50,
kind: EventKind::Shock {
layer: LayerId::Materia,
x: 10.0,
y: 10.0,
radius: 5.0,
amount: -100.0,
},
},
Event {
tick: 100,
kind: EventKind::PsiNudge {
x: 20.0,
y: 20.0,
radius: 8.0,
delta_psi: [0.0, 0.5, 0.0, 0.0],
},
},
Event {
tick: 150,
kind: EventKind::Spawn {
x: 5.0,
y: 5.0,
n: 10,
radius: 2.0,
energia: 40.0,
psi: [0.2, 0.3, 0.4, 0.1],
accion: 1,
},
},
Event {
tick: 200,
kind: EventKind::Kill {
x: 25.0,
y: 25.0,
radius: 4.0,
},
},
];
let s = serde_json::to_string(&events).expect("serializa");
let back: Vec<Event> = serde_json::from_str(&s).expect("deserializa");
assert_eq!(events, back);
}
}
@@ -0,0 +1,89 @@
//! El Sustrato Plano — grilla SoA de 5 capas de `f32`.
use serde::{Deserialize, Serialize};
/// Grilla de campos: 5 capas paralelas, cada una `width × height` `f32`,
/// indexadas `y * width + x`. Toda la física opera sobre estos arrays
/// contiguos (cache-friendly).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Grid {
pub width: usize,
pub height: usize,
/// Biomasa / energía / alimento disponible.
pub materia: Vec<f32>,
/// Densidad de información / frecuencia dogmática.
pub psique: Vec<f32>,
/// Tensión de control / deuda / atractores del Estado Profundo.
pub poder: Vec<f32>,
/// Materia prima densa intercambiable.
pub oro: Vec<f32>,
/// Contaminación / cicatrices industriales del suelo.
pub degradacion: Vec<f32>,
}
impl Grid {
/// Grilla de `width × height` con todas las capas en cero.
pub fn new(width: usize, height: usize) -> Self {
let n = width * height;
Self {
width,
height,
materia: vec![0.0; n],
psique: vec![0.0; n],
poder: vec![0.0; n],
oro: vec![0.0; n],
degradacion: vec![0.0; n],
}
}
/// Cantidad de celdas (`width * height`).
pub fn cells(&self) -> usize {
self.width * self.height
}
/// Índice plano de `(x, y)`. El caller garantiza bounds válidos.
pub fn idx(&self, x: usize, y: usize) -> usize {
y * self.width + x
}
/// `true` si `(x, y)` cae dentro de la grilla.
pub fn in_bounds(&self, x: i64, y: i64) -> bool {
x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height
}
/// Clampa una coordenada continua a una celda válida.
pub fn clamp_cell(&self, x: f32, y: f32) -> (usize, usize) {
let cx = (x.floor() as i64).clamp(0, self.width as i64 - 1) as usize;
let cy = (y.floor() as i64).clamp(0, self.height as i64 - 1) as usize;
(cx, cy)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_grid_is_zeroed() {
let g = Grid::new(8, 4);
assert_eq!(g.cells(), 32);
assert!(g.materia.iter().all(|&v| v == 0.0));
assert_eq!(g.materia.len(), 32);
}
#[test]
fn idx_and_bounds() {
let g = Grid::new(10, 5);
assert_eq!(g.idx(3, 2), 23);
assert!(g.in_bounds(9, 4));
assert!(!g.in_bounds(10, 4));
assert!(!g.in_bounds(-1, 0));
}
#[test]
fn clamp_cell_keeps_in_range() {
let g = Grid::new(10, 10);
assert_eq!(g.clamp_cell(-5.0, 3.7), (0, 3));
assert_eq!(g.clamp_cell(99.0, 99.0), (9, 9));
}
}
@@ -0,0 +1,250 @@
//! Los Agentes Vectoriales — Lemmings en Structure-of-Arrays.
//!
//! Sin objetos ni punteros por agente: vectores paralelos indexados por
//! un `usize` continuo. Datos crudos alineados en caché.
use serde::{Deserialize, Serialize};
/// Índices de las cuatro componentes de `vector_psi`.
pub const PSI_ORDEN: usize = 0;
pub const PSI_MIEDO: usize = 1;
pub const PSI_CURIOSIDAD: usize = 2;
pub const PSI_CORRUPTIBILIDAD: usize = 3;
/// Quinta componente *opcional* del psi — la dimensión de Extraversión del
/// modelo Big Five. Mapea a sociabilidad / asertividad / energía social.
/// Vive en su propio `Vec<f32>` (`Lemmings::psi5`) en lugar de extender el
/// `vector_psi` a `[f32; 5]` para preservar bit-exactitud y serde compat con
/// motores Big Four históricos.
pub const PSI_EXTRAVERSION: usize = 4;
/// Valor default de `psi5` cuando se hace `spawn` sin especificarlo o cuando
/// un `World` antiguo se deserializa sin la columna. Elegimos 0.5 para que
/// sea "ni introvertido ni extravertido" y la psicología quede neutral.
pub const PSI_EXTRAVERSION_DEFAULT: f32 = 0.5;
/// Población de Lemmings en SoA. Todos los vectores tienen el mismo largo.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Lemmings {
pub pos_x: Vec<f32>,
pub pos_y: Vec<f32>,
/// Contador incremental de ticks de vida.
pub edad: Vec<u32>,
/// Escalar de salud; si llega a 0 el agente muere.
pub energia: Vec<f32>,
/// Tensores de sesgo interno `[Orden, Miedo, Curiosidad, Corruptibilidad]`.
pub vector_psi: Vec<[f32; 4]>,
/// Byte discriminador de la máquina de estados (0-5).
pub accion: Vec<u8>,
/// Ticks restantes de captura por un `BehaviorHack` de un Concepto.
/// Mientras es > 0, el Lemming ejecuta su `accion` sin reevaluar
/// transiciones (la captura sobrescribe a la desesperación).
pub hack_lock: Vec<u32>,
/// Quinta dimensión opcional del psi — Big Five Extraversion. Cuando el
/// motor corre en modo Big Four (`SimParams::big_five == false`), este
/// vector se mantiene poblado con el default `PSI_EXTRAVERSION_DEFAULT`
/// pero no afecta ninguna ecuación. Saves históricos sin esta columna
/// vienen vacíos y se rellenan vía [`Lemmings::ensure_psi5_len`].
#[serde(default)]
pub psi5: Vec<f32>,
}
impl Lemmings {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.pos_x.len()
}
pub fn is_empty(&self) -> bool {
self.pos_x.is_empty()
}
/// Instancia un Lemming nuevo (edad 0). Devuelve su índice. La quinta
/// componente del psi (Big Five Extraversion) queda en el default neutral
/// `PSI_EXTRAVERSION_DEFAULT`; usar [`Lemmings::spawn_big5`] para fijarla.
pub fn spawn(&mut self, x: f32, y: f32, energia: f32, psi: [f32; 4]) -> usize {
self.spawn_big5(x, y, energia, psi, PSI_EXTRAVERSION_DEFAULT)
}
/// Como [`spawn`], pero pone explícitamente el quinto componente `psi5`
/// (Big Five Extraversion). Usar cuando el motor corre con
/// `SimParams::big_five = true` y los agentes nacen con una distribución
/// de extraversión no trivial.
pub fn spawn_big5(
&mut self,
x: f32,
y: f32,
energia: f32,
psi: [f32; 4],
psi5: f32,
) -> usize {
let i = self.len();
self.pos_x.push(x);
self.pos_y.push(y);
self.edad.push(0);
self.energia.push(energia);
self.vector_psi.push(psi);
self.accion.push(0);
self.hack_lock.push(0);
self.psi5.push(psi5);
i
}
/// Elimina el Lemming `i` por `swap_remove` — O(1), no preserva el
/// orden (el último ocupa el hueco).
pub fn remove(&mut self, i: usize) {
self.pos_x.swap_remove(i);
self.pos_y.swap_remove(i);
self.edad.swap_remove(i);
self.energia.swap_remove(i);
self.vector_psi.swap_remove(i);
self.accion.swap_remove(i);
self.hack_lock.swap_remove(i);
// El `psi5` de saves Big Four puede estar vacío — sólo recortamos si
// hay algo. Mantiene la invariante "len == pos_x.len() len == 0".
if !self.psi5.is_empty() {
self.psi5.swap_remove(i);
}
}
/// Asegura que `psi5` tenga el mismo largo que `pos_x`, rellenando con
/// `PSI_EXTRAVERSION_DEFAULT` lo que falte. Idempotente. Sirve para
/// "ascender" saves Big Four a Big Five sin perder la población vieja.
pub fn ensure_psi5_len(&mut self) {
let n = self.pos_x.len();
if self.psi5.len() < n {
self.psi5.resize(n, PSI_EXTRAVERSION_DEFAULT);
} else if self.psi5.len() > n {
self.psi5.truncate(n);
}
}
/// Lectura segura del quinto componente. Cuando `psi5` está vacío
/// (saves históricos Big Four) devuelve `PSI_EXTRAVERSION_DEFAULT`; con
/// `i` fuera de rango, también — usar sólo con índices válidos.
pub fn psi5_at(&self, i: usize) -> f32 {
self.psi5.get(i).copied().unwrap_or(PSI_EXTRAVERSION_DEFAULT)
}
/// Distancia euclidiana al cuadrado entre dos Lemmings (sin `sqrt` —
/// suficiente para comparar cercanía y bit-exacto).
pub fn dist2(&self, a: usize, b: usize) -> f32 {
let dx = self.pos_x[a] - self.pos_x[b];
let dy = self.pos_y[a] - self.pos_y[b];
dx * dx + dy * dy
}
/// Índice del Lemming vivo más cercano a `i` (distinto de `i`), o
/// `None` si es el único. Determinista: ante empate gana el menor
/// índice.
pub fn nearest(&self, i: usize) -> Option<usize> {
let mut best: Option<(usize, f32)> = None;
for j in 0..self.len() {
if j == i {
continue;
}
let d = self.dist2(i, j);
if best.map(|(_, bd)| d < bd).unwrap_or(true) {
best = Some((j, d));
}
}
best.map(|(j, _)| j)
}
/// Índice del Lemming vivo con **menor energía** distinto de `i`. Es
/// el destinatario de `act_intercambiar` cuando la estrategia es
/// "redistribución solidaria": en lugar de donar al vecino físico
/// más cercano (que puede ser igualmente pobre), busca al más
/// necesitado del mundo. Determinista: ante empate, menor índice.
pub fn poorest(&self, i: usize) -> Option<usize> {
let mut best: Option<(usize, f32)> = None;
for j in 0..self.len() {
if j == i {
continue;
}
let e = self.energia[j];
if best.map(|(_, be)| e < be).unwrap_or(true) {
best = Some((j, e));
}
}
best.map(|(j, _)| j)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spawn_and_remove() {
let mut l = Lemmings::new();
let a = l.spawn(1.0, 1.0, 10.0, [0.0; 4]);
let _b = l.spawn(2.0, 2.0, 20.0, [0.0; 4]);
assert_eq!((a, l.len()), (0, 2));
l.remove(a);
assert_eq!(l.len(), 1);
// swap_remove: el agente "b" ocupa el índice 0.
assert_eq!(l.energia[0], 20.0);
}
#[test]
fn spawn_default_pone_psi5_neutral() {
let mut l = Lemmings::new();
let i = l.spawn(1.0, 1.0, 10.0, [0.5; 4]);
assert_eq!(l.psi5.len(), l.pos_x.len());
assert_eq!(l.psi5_at(i), PSI_EXTRAVERSION_DEFAULT);
}
#[test]
fn spawn_big5_fija_psi5_explicito() {
let mut l = Lemmings::new();
let i = l.spawn_big5(0.0, 0.0, 10.0, [0.5; 4], 0.9);
assert!((l.psi5_at(i) - 0.9).abs() < 1e-6);
}
#[test]
fn ensure_psi5_len_completa_columna_faltante() {
// Simula un save Big Four cargado por serde sin la columna psi5.
let mut l = Lemmings {
pos_x: vec![1.0, 2.0, 3.0],
pos_y: vec![1.0, 2.0, 3.0],
edad: vec![0; 3],
energia: vec![10.0; 3],
vector_psi: vec![[0.5; 4]; 3],
accion: vec![0; 3],
hack_lock: vec![0; 3],
psi5: Vec::new(),
};
l.ensure_psi5_len();
assert_eq!(l.psi5.len(), 3);
for v in &l.psi5 {
assert!((*v - PSI_EXTRAVERSION_DEFAULT).abs() < 1e-6);
}
}
#[test]
fn remove_actualiza_psi5() {
let mut l = Lemmings::new();
l.spawn_big5(0.0, 0.0, 10.0, [0.0; 4], 0.1);
l.spawn_big5(0.0, 0.0, 10.0, [0.0; 4], 0.9);
assert_eq!(l.psi5, vec![0.1, 0.9]);
l.remove(0);
// swap_remove deja el último (0.9) en el hueco 0.
assert_eq!(l.psi5, vec![0.9]);
}
#[test]
fn nearest_picks_closest_and_breaks_ties_by_index() {
let mut l = Lemmings::new();
l.spawn(0.0, 0.0, 1.0, [0.0; 4]); // 0
l.spawn(10.0, 0.0, 1.0, [0.0; 4]); // 1 — lejos
l.spawn(1.0, 0.0, 1.0, [0.0; 4]); // 2 — cerca de 0
assert_eq!(l.nearest(0), Some(2));
// Único agente → None.
let mut solo = Lemmings::new();
solo.spawn(0.0, 0.0, 1.0, [0.0; 4]);
assert_eq!(solo.nearest(0), None);
}
}
@@ -0,0 +1,48 @@
//! `dominium-core` — el núcleo lógico del simulador de campo medio.
//!
//! Laboratorio de complejidad emergente: los agentes (Lemmings) no toman
//! decisiones cognitivas — reaccionan mecánicamente a los campos de una
//! grilla plana ejecutando una de 6 acciones atómicas fijas. Civilización,
//! guerra, fe y poder son patrones emergentes, no algoritmos.
//!
//! - [`grid`] — el Sustrato Plano: 5 capas SoA de `f32`.
//! - [`lemmings`] — los Agentes Vectoriales en Structure-of-Arrays.
//! - [`world`] — el `World` + las 6 acciones atómicas (`Action`).
//! - [`params`] — `SimParams`, las constantes que los sliders ajustan.
//! - [`conceptos`] — emisores de campo metaprogramables (datos puros).
//!
//! Cero dependencias gráficas (regla inviolable de la spec): sólo `serde`.
//! La difusión/entropía/cinemática viven en `dominium-physics`; el
//! renderizado isométrico en `dominium-iso` + `dominium-render-plan`.
#![forbid(unsafe_code)]
pub mod conceptos;
pub mod epoch;
pub mod events;
pub mod grid;
pub mod lemmings;
pub mod metrics;
pub mod params;
pub mod psi_metrics;
pub mod world;
pub mod worldgen;
pub use conceptos::{BehaviorHack, Concepto, Conceptos, LayerMods, Persuasion, Trigger};
pub use epoch::Epoch;
pub use events::{apply_event, Event, EventKind, LayerId};
pub use grid::Grid;
pub use lemmings::{
Lemmings, PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_EXTRAVERSION, PSI_EXTRAVERSION_DEFAULT,
PSI_MIEDO, PSI_ORDEN,
};
pub use metrics::WorldStats;
pub use psi_metrics::{
kmeans_psi, morans_i_for, KMeansResult, PsiMetrics, KMEANS_EPS, KMEANS_K, KMEANS_MAX_ITER,
MORANS_RADIUS_DEFAULT, POLARIZATION_ALPHA, POLARIZATION_BINS,
};
pub use params::{
ActionPolicy, SimParams, TradeTarget, RELIEVE_DEGRADACION, RELIEVE_MATERIA, RELIEVE_ORO,
RELIEVE_PODER, RELIEVE_PSIQUE,
};
pub use world::{select_action_argmax, select_action_argmax_big5, Action, World};
@@ -0,0 +1,235 @@
//! Estadísticas agregadas del mundo — **lectura pura, no muta nada**.
//!
//! Pensado para alimentar HUDs, CSV del CLI y eventuales tests de invariantes
//! macro (¿la energía total decae? ¿el Gini se dispara con ciertos packs?).
//!
//! Determinista bit-exacto: itera en orden lineal, suma `f32` en el mismo
//! orden en cualquier plataforma, sin paralelismo ni hashing.
use crate::world::World;
/// Foto del estado agregado del mundo en un instante.
///
/// Convención del `vector_psi`: las 4 componentes en orden
/// `[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct WorldStats {
/// Cantidad de Lemmings vivos.
pub n: usize,
/// Coeficiente de Gini sobre `energia` ∈ [0, 1]. 0 = perfecta igualdad,
/// 1 = un único agente concentra todo. `0.0` si `n < 2`.
pub gini_energia: f32,
/// Varianza poblacional de cada componente del `vector_psi` ∈ ℝ⁺. `0.0`
/// para componentes con `n == 0`.
pub var_psi: [f32; 4],
/// Conteo de cuántos Lemmings ejecutan cada `Action` (0..=5).
pub action_counts: [u32; 6],
/// Suma de las 5 capas del Sustrato — útil para detectar drift de masa.
pub total_materia: f32,
pub total_psique: f32,
pub total_poder: f32,
pub total_oro: f32,
pub total_degradacion: f32,
/// Media de `edad` (0 si `n == 0`).
pub mean_edad: f32,
/// Suma de `energia` (0 si `n == 0`).
pub total_energia: f32,
}
impl WorldStats {
/// Calcula todas las métricas en una sola pasada por agente + cinco
/// sumas lineales por las capas. Asignación: un `Vec<f32>` temporal del
/// largo de la población para el Gini (ordenamiento necesario).
pub fn from_world(w: &World) -> Self {
let n = w.lemmings.len();
let mut action_counts = [0u32; 6];
let mut sum_psi = [0.0f64; 4];
let mut sum_psi2 = [0.0f64; 4];
let mut sum_edad: u64 = 0;
let mut sum_energia: f64 = 0.0;
for i in 0..n {
let a = w.lemmings.accion[i];
if (a as usize) < action_counts.len() {
action_counts[a as usize] += 1;
}
let psi = w.lemmings.vector_psi[i];
for k in 0..4 {
let v = psi[k] as f64;
sum_psi[k] += v;
sum_psi2[k] += v * v;
}
sum_edad += w.lemmings.edad[i] as u64;
sum_energia += w.lemmings.energia[i] as f64;
}
// Var(X) = E[X²] E[X]²; en f64 internamente, downcast al final.
let mut var_psi = [0.0f32; 4];
if n > 0 {
let nf = n as f64;
for k in 0..4 {
let mean = sum_psi[k] / nf;
let v = (sum_psi2[k] / nf) - mean * mean;
var_psi[k] = v.max(0.0) as f32;
}
}
let mean_edad = if n > 0 { (sum_edad as f64 / n as f64) as f32 } else { 0.0 };
let gini_energia = gini_of(&w.lemmings.energia);
let g = &w.grid;
Self {
n,
gini_energia,
var_psi,
action_counts,
total_materia: sum_layer(&g.materia),
total_psique: sum_layer(&g.psique),
total_poder: sum_layer(&g.poder),
total_oro: sum_layer(&g.oro),
total_degradacion: sum_layer(&g.degradacion),
mean_edad,
total_energia: sum_energia as f32,
}
}
}
/// Suma de `f32` acumulada en `f64` para no perder precisión en grillas
/// grandes — la salida es `f32` pero el orden de la suma queda fijado por
/// el orden lineal del slice, así que sigue siendo bit-exacto.
fn sum_layer(layer: &[f32]) -> f32 {
let mut acc: f64 = 0.0;
for &v in layer {
acc += v as f64;
}
acc as f32
}
/// Gini sobre energía no-negativa. Implementación clásica vía orden ascendente:
///
/// ```text
/// G = ( 2·Σ(i · x_i) (n+1)·Σx_i ) / ( n · Σx_i )
/// ```
///
/// Robusto a entradas vacías (→ 0.0), a `Σx_i == 0` (→ 0.0) y a valores
/// negativos (los considera 0 — la energía nunca debería ser negativa pero
/// `act_degradar` puede dejarla en rojo un tick antes de la cosecha).
fn gini_of(values: &[f32]) -> f32 {
let n = values.len();
if n < 2 {
return 0.0;
}
let mut v: Vec<f32> = values.iter().map(|x| x.max(0.0)).collect();
// `sort_by` con comparación total — `f32` no implementa `Ord`. Las NaN
// son imposibles aquí (sólo aritmética cerrada sobre f32 finitos), pero
// por las dudas las tratamos como iguales para no panickear.
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut sum: f64 = 0.0;
let mut weighted: f64 = 0.0;
for (i, &x) in v.iter().enumerate() {
sum += x as f64;
weighted += (i + 1) as f64 * x as f64;
}
if sum <= 0.0 {
return 0.0;
}
let nf = n as f64;
let g = (2.0 * weighted - (nf + 1.0) * sum) / (nf * sum);
g.clamp(0.0, 1.0) as f32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SimParams;
#[test]
fn empty_world_yields_zeros() {
let w = World::new(4, 4);
let s = WorldStats::from_world(&w);
assert_eq!(s.n, 0);
assert_eq!(s.gini_energia, 0.0);
assert_eq!(s.action_counts, [0; 6]);
assert_eq!(s.var_psi, [0.0; 4]);
assert_eq!(s.mean_edad, 0.0);
}
#[test]
fn gini_zero_when_all_equal() {
assert_eq!(gini_of(&[10.0, 10.0, 10.0, 10.0]), 0.0);
}
#[test]
fn gini_one_when_only_one_has_value() {
// 0,0,0,…,100 → cerca de 1 (no exactamente; la cota teórica es (n-1)/n)
let n = 100;
let mut v = vec![0.0f32; n];
v[n - 1] = 100.0;
let g = gini_of(&v);
let expected_upper = (n as f32 - 1.0) / n as f32;
assert!((g - expected_upper).abs() < 1e-3, "gini={g}");
}
#[test]
fn gini_rises_with_inequality() {
let flat = gini_of(&[5.0, 5.0, 5.0, 5.0]);
let mid = gini_of(&[2.0, 4.0, 6.0, 8.0]);
let sharp = gini_of(&[0.5, 0.5, 0.5, 18.5]);
assert!(flat < mid);
assert!(mid < sharp);
}
#[test]
fn action_counts_match_population_distribution() {
let mut w = World::new(8, 8);
// 3 con accion=2, 2 con accion=0, 1 con accion=5.
for _ in 0..3 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0; 4]);
w.lemmings.accion[i] = 2;
}
for _ in 0..2 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0; 4]);
w.lemmings.accion[i] = 0;
}
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0; 4]);
w.lemmings.accion[i] = 5;
let s = WorldStats::from_world(&w);
assert_eq!(s.action_counts[0], 2);
assert_eq!(s.action_counts[2], 3);
assert_eq!(s.action_counts[5], 1);
assert_eq!(s.n, 6);
}
#[test]
fn var_psi_zero_when_population_is_uniform() {
let mut w = World::new(4, 4);
for _ in 0..10 {
w.lemmings.spawn(0.0, 0.0, 1.0, [0.5, 0.5, 0.5, 0.5]);
}
let s = WorldStats::from_world(&w);
for k in 0..4 {
assert!(s.var_psi[k] < 1e-6, "var[{k}] no es cero: {}", s.var_psi[k]);
}
}
#[test]
fn layer_totals_track_grid_state() {
let mut w = World::new(4, 4);
let idx = w.grid.idx(1, 1);
w.grid.materia[idx] = 5.0;
w.grid.oro[idx] = 3.0;
let s = WorldStats::from_world(&w);
assert!((s.total_materia - 5.0).abs() < 1e-5);
assert!((s.total_oro - 3.0).abs() < 1e-5);
}
#[test]
fn stats_silent_passthrough_with_simparams() {
// Sanity: SimParams sigue siendo construible sin el módulo nuevo.
let _ = SimParams::default();
let w = World::new(2, 2);
let _ = WorldStats::from_world(&w);
}
}
@@ -0,0 +1,398 @@
//! Constantes globales de la simulación.
//!
//! Son las que los sliders del Panel de Control alimentan en vivo: cada
//! una sintoniza una de las ecuaciones del núcleo.
use serde::{Deserialize, Serialize};
/// Política de elección de la `accion` base de los Lemmings.
///
/// El motor histórico fija la acción una sola vez (en `seed` / al replicarse
/// se hereda del padre) y nunca la recalcula salvo por transiciones de
/// supervivencia (desesperación → pelear) o captura por Conceptos
/// (`apply_hacks`). Eso convierte al `vector_psi` en una variable casi
/// decorativa: la psicología del agente no decide qué hace, sólo cómo se
/// mueve.
///
/// `PsiArgmax` cierra el bucle: cada `policy_reeval_period` ticks, los
/// agentes libres (sin `hack_lock`) recalculan su byte de acción tomando el
/// `argmax` de `action_weights · vector_psi`. Determinista bit-exacto: sin
/// RNG, sin softmax — comparación lineal de 6 escalares con tie-break por
/// menor índice. Es el complemento mínimo que vuelve endógena la
/// heterogeneidad poblacional sin romper §1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActionPolicy {
/// Comportamiento histórico: la acción se asigna al spawn (o se hereda
/// del padre en `Replicar`) y sólo cambia por transiciones de
/// supervivencia o hacks. La psicología no decide qué hace el agente.
Fixed,
/// La acción se reelige cada `policy_reeval_period` ticks como
/// `argmax(action_weights · vector_psi)`. Determinista, sin RNG.
PsiArgmax,
}
impl Default for ActionPolicy {
fn default() -> Self {
ActionPolicy::Fixed
}
}
/// A quién dona un Lemming cuando ejecuta `act_intercambiar`. Permite
/// elegir entre la semántica original (vecino físico) y la redistribución
/// solidaria (el más necesitado del mundo) — esta última es la que cierra
/// el ciclo termodinámico y produce un punto fijo `N* > 0`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TradeTarget {
/// Dona al vecino físico más cercano. Comportamiento histórico de
/// `act_intercambiar`. Conserva la semántica geográfica pero no
/// redistribuye eficientemente — la energía oscila localmente y los
/// Replicadores aislados se agotan.
Nearest,
/// Dona al lemming con menor energía global (O(n) determinista).
/// "Solidaridad universal": los Traders ricos alimentan a los
/// Replicadores pobres, sostiene la natalidad.
Poorest,
}
impl Default for TradeTarget {
fn default() -> Self {
TradeTarget::Poorest
}
}
/// Parámetros que gobiernan las 6 acciones y el ciclo de vida.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimParams {
/// Velocidad de desplazamiento de `Mover` (celdas por tick).
pub move_speed: f32,
/// Energía que consume un paso de `Mover`.
pub move_cost: f32,
/// Cantidad extraída de la celda por `Extraer`.
pub extract_rate: f32,
/// Degradación añadida al suelo por cada `Extraer`.
pub degr_per_extract: f32,
/// Tasa de convergencia de `vector_psi` en `Sincronizar` (0-1).
pub sync_rate: f32,
/// Energía transferida por `Intercambiar`.
pub trade_amount: f32,
/// Umbral de energía para que `Replicar` dispare.
pub replicate_threshold: f32,
/// Fracción de la energía del padre que hereda el hijo en `Replicar`.
pub child_energy_frac: f32,
/// Daño de energía que inflige `Degradar`.
pub fight_damage: f32,
/// Fracción del daño que el atacante absorbe como energía.
pub absorb_frac: f32,
/// Umbral de energía bajo el cual el agente se fuerza a `Pelear`.
pub desperation_threshold: f32,
/// Umbral de energía por encima del cual el agente se fuerza a
/// `Replicar` — el atractor simétrico de la desesperación. Cierra el
/// ciclo termodinámico: sin esta transición, los Replicadores
/// genéticos se agotan en pocas generaciones y `dN/dt < 0`
/// estructural. `0.0` deshabilita la transición (motor pre-2026-05-26).
#[serde(default)]
pub abundance_threshold: f32,
/// A quién dona un Lemming cuando ejecuta `act_intercambiar`.
/// Default `Poorest` — la redistribución solidaria es la que cierra
/// el ciclo termodinámico del sistema. Ver [`TradeTarget`].
#[serde(default)]
pub trade_target: TradeTarget,
/// Edad máxima; al superarla el agente muere.
pub max_edad: u32,
/// Costo metabólico basal: energía drenada cada tick a TODOS los
/// lemmings por el simple hecho de estar vivos, independiente de la
/// acción. Es el freno termodinámico que estabiliza la población —
/// sin él, los Extractores acumulan E sin techo y la natalidad
/// (vía abundance side-effect) se descontrola. Con él, dE/dt → 0
/// cuando N llega a la capacidad de carga del territorio.
/// `0.0` deshabilita (motor pre-2026-05-26).
#[serde(default)]
pub metabolic_cost: f32,
/// Fracción que cada celda difunde hacia sus 4 vecinas por tick (0-1).
pub diffusion_rate: f32,
/// Tasa de pérdida natural (entropía) de los campos por tick (0-1).
pub entropy_rate: f32,
/// Pesos por capa que definen el **relieve físico** que sienten los
/// lemmings al moverse (no es lo mismo que el `ZWeights` del render —
/// el render puede mostrar una vista distinta de la "altura"). El
/// gradiente del relieve atrae/repele en `act_mover` y cobra
/// `climb_cost` extra de energía por unidad subida.
pub relieve: [f32; 5],
/// Energía consumida por unidad de relieve **subido** en `act_mover`
/// (los lemmings no pagan extra al bajar). El score de un candidato
/// se reduce en `climb_cost · max(0, z_dst z_src)` antes de elegir.
pub climb_cost: f32,
/// Período del ciclo estacional, en ticks. Una estación completa
/// (verano→invierno→verano) toma `season_period` ticks. `0` deshabilita
/// el ciclo y el motor se comporta como antes (campos sin modulación).
#[serde(default)]
pub season_period: u32,
/// Amplitud del ciclo estacional, ∈ [0, 1]. Modula multiplicativamente
/// `diffusion_rate` y `entropy_rate` por un factor
/// `1 + amp · sin(2π · t / period)`. Con `0.0` no hay ciclo (equivalente
/// a `season_period = 0`). Es el "clima" del mundo: en verano (factor
/// alto) los campos difunden y decaen más rápido; en invierno se
/// congelan. Cero semántica de calendario — son sólo dos floats que
/// pasan por la libm.
#[serde(default)]
pub season_amplitude: f32,
/// Fracción del *espacio libre* que la naturaleza repuebla con materia
/// por tick (regrowth logístico). En cada celda:
/// `materia += regrowth_rate · max(0, carrying_capacity materia)`.
/// Vive *dentro* de la fase de difusión — no agrega una fase nueva al
/// §1.5. Es el cierre termodinámico del motor: sin esta fuente la
/// entropía vence siempre y la población se extingue.
#[serde(default)]
pub regrowth_rate: f32,
/// Asíntota del regrowth: hacia este valor empuja la materia por
/// celda. Inyecciones por Conceptos o por muerte de lemmings pueden
/// superarlo; el regrowth nunca lo hace.
#[serde(default)]
pub carrying_capacity: f32,
/// Intensidad con la que el `vector_psi` del agente modula los efectos
/// de sus 5 acciones físicas (Mover, Extraer, Intercambiar, Replicar,
/// Degradar). Con `0.0` los efectos son idénticos al motor histórico
/// — bit-exacto. Con `> 0`, el psi entra en cada cantidad afín:
///
/// - `Mover`: `move_cost ← move_cost · (1 + mod · 0.5 · psi[MIEDO])`
/// — el miedoso se cansa más al moverse.
/// - `Extraer`: `extract_rate ← extract_rate · (1 + mod · psi[CORRUPTIBILIDAD])`
/// — el corrupto saca más del suelo y deja más cicatriz.
/// - `Intercambiar`: `trade_amount ← trade_amount · max(0, 1 + mod ·
/// (psi[ORDEN] psi[CORRUPTIBILIDAD]))` — el ordenado comparte, el
/// corrupto retiene.
/// - `Replicar`: `replicate_threshold ← replicate_threshold · max(0.1,
/// 1 mod · 0.3 · psi[ORDEN])` — el ordenado replica antes.
/// - `Degradar`: `fight_damage ← fight_damage · max(0, 1 + mod ·
/// (psi[CORRUPTIBILIDAD] psi[MIEDO]))` — el miedoso pega menos, el
/// corrupto más.
///
/// Rango sugerido `[0, 1]`. Valores > 1 amplifican la heterogeneidad
/// pero pueden producir efectos no-monotónicos cuando un psi extremo
/// hace flip al signo del factor (los clamps a 0/0.1 lo previenen).
#[serde(default)]
pub psi_effect_modulation: f32,
/// Política de elección de la `accion` base. Ver [`ActionPolicy`].
/// Default `Fixed` → comportamiento histórico bit-exacto.
#[serde(default)]
pub action_policy: ActionPolicy,
/// Pesos `[accion][componente_psi]` para `ActionPolicy::PsiArgmax`. Una
/// matriz 6×4 — fila `a` = qué tan atractiva es la acción `a` para cada
/// componente del psi. Cuando la política es `Fixed` se ignora.
///
/// Default semánticamente plausible (independiente del comportamiento
/// histórico porque sólo se consulta con `PsiArgmax`):
/// - `Mover` (0): premia CURIOSIDAD, penaliza MIEDO.
/// - `Extraer` (1): premia ORDEN y CORRUPTIBILIDAD.
/// - `Sincronizar` (2): premia CURIOSIDAD.
/// - `Intercambiar` (3): premia ORDEN, penaliza MIEDO.
/// - `Replicar` (4): premia ORDEN.
/// - `Degradar` (5): premia CORRUPTIBILIDAD, penaliza MIEDO.
#[serde(default = "default_action_weights")]
pub action_weights: [[f32; 4]; 6],
/// Cada cuántos ticks reelige la acción la `ActionPolicy::PsiArgmax`.
/// `0` deshabilita la reelección incluso si la política es `PsiArgmax`
/// (failsafe: la matriz sólo "se enciende" cuando hay periodo). Valores
/// típicos: 10..200. Períodos chicos pueden volver al sistema neurótico
/// (cambia de oficio cada poco); muy grandes, inerte.
#[serde(default)]
pub policy_reeval_period: u32,
/// Radio de influencia social (Fase B): cada agente acerca su
/// `vector_psi` al promedio del psi de los vecinos que estén a
/// distancia euclidiana ≤ `social_radius`. `0.0` (default) deshabilita
/// el contagio — el motor histórico no paga nada.
///
/// **Costo**: O(N²) determinista, aceptable hasta ~10k agentes por la
/// grilla típica. Sin índice espacial: para poblaciones masivas habría
/// que indexar celdas por agente — pendiente para Fase B.2.
#[serde(default)]
pub social_radius: f32,
/// Tasa de convergencia del contagio social (Fase B). Cada tick, los
/// agentes en el radio acercan su psi al promedio local por
/// `psi_nuevo = psi + rate · (psi_local psi)`. `0.0` (default) =
/// sin contagio incluso si `social_radius > 0`. Rango útil 0.01..0.20:
/// valores grandes producen conformismo brutal (todos convergen al
/// mismo psi), valores chicos preservan diversidad.
#[serde(default)]
pub contagion_rate: f32,
/// Umbral de homofilia (Fase B.2): un vecino dentro del `social_radius`
/// sólo influye al agente si su distancia psi euclidiana es menor a
/// este umbral. Mismo psi → siempre influye; psi muy distinto → no
/// influye en absoluto. Es el "sólo escucho a los míos" canónico de la
/// psicología social.
///
/// `0.0` (default) = sin filtro de homofilia → contagio universal
/// (motor B.1: produce homogeneización con tasas altas). Rango útil
/// 0.3..1.0 — con threshold chico emergen **tribus aisladas** y la
/// polarización **sube** en vez de bajar; con threshold grande, recae
/// al comportamiento de B.1.
#[serde(default)]
pub homophily_threshold: f32,
/// Activa el modelo Big Five (5 dimensiones) en vez de las 4 históricas.
/// Cuando es `true`:
/// - El contagio social incluye la quinta dimensión (Extraversion).
/// - La política `PsiArgmax` consulta `action_weights_ext` además de
/// `action_weights`.
/// - Las métricas `PsiMetrics` calculan `polarization_ext` y `moran_i_ext`.
/// - La homofilia mide distancia en 5D (en vez de 4D).
///
/// `false` (default) → bit-exacto al motor histórico Big Four.
#[serde(default)]
pub big_five: bool,
/// Columna extendida de `action_weights` para la quinta dimensión del
/// psi (Extraversion). Sólo se consulta cuando `big_five = true`. Default
/// cero → la 5ª dimensión empieza neutra y el caller la sintoniza.
///
/// Default semánticamente plausible:
/// - Mover (0), Sincronizar (2), Intercambiar (3): premian extraversión.
/// - Extraer (1), Replicar (4), Degradar (5): neutrales.
#[serde(default = "default_action_weights_ext")]
pub action_weights_ext: [f32; 6],
}
/// Default de `SimParams::action_weights_ext` — peso por acción para la 5ª
/// dimensión del psi (Big Five Extraversion). Acciones sociales (Mover,
/// Sincronizar, Intercambiar) premian extraversión; las solitarias o
/// agresivas (Extraer, Replicar, Degradar) son neutrales.
fn default_action_weights_ext() -> [f32; 6] {
// 0 Mover, 1 Extraer, 2 Sincronizar, 3 Intercambiar, 4 Replicar, 5 Degradar
[0.4, 0.0, 0.6, 0.8, 0.0, -0.2]
}
/// Default de `SimParams::action_weights` — fila por acción, columna por
/// componente del `vector_psi` (`[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`).
fn default_action_weights() -> [[f32; 4]; 6] {
[
// 0 Mover O M C K
[0.0, -0.5, 1.0, 0.0],
// 1 Extraer O M C K
[0.6, 0.0, 0.0, 0.8],
// 2 Sincronizar O M C K
[0.0, 0.0, 1.0, 0.0],
// 3 Intercambiar O M C K
[1.0, -0.4, 0.0, 0.0],
// 4 Replicar O M C K
[1.0, 0.0, 0.0, 0.0],
// 5 Degradar O M C K
[0.0, -0.8, 0.0, 1.0],
]
}
/// Índices semánticos para indexar `SimParams::relieve`. Coinciden con el
/// orden de capas del `Grid`.
pub const RELIEVE_MATERIA: usize = 0;
pub const RELIEVE_PSIQUE: usize = 1;
pub const RELIEVE_PODER: usize = 2;
pub const RELIEVE_ORO: usize = 3;
pub const RELIEVE_DEGRADACION: usize = 4;
impl SimParams {
/// Factor multiplicativo del ciclo estacional para el tick `t`. Vale
/// `1.0 + season_amplitude · sin(2π · t / season_period)` cuando hay
/// ciclo activo, y `1.0` cuando `season_period == 0` o
/// `season_amplitude == 0.0`. Resultado siempre clamped a `[0, 2]` para
/// que la modulación no invierta el signo de las tasas.
///
/// **Determinismo bit-exacto**: usamos `libm::sinf` para evitar
/// divergencias entre `f32::sin` de x86 vs ARM. El argumento se calcula
/// en `f64` y se castea al final, así fases consecutivas no acumulan
/// drift por wrap-around de grandes `t`.
pub fn season_factor(&self, t: u64) -> f32 {
if self.season_period == 0 || self.season_amplitude == 0.0 {
return 1.0;
}
let period = self.season_period as f64;
// Fase en [0, 2π) — modular antes de pasar a f32 para no perder
// precisión cuando t es grande.
let phase = ((t as f64).rem_euclid(period)) / period;
let arg = (phase * std::f64::consts::TAU) as f32;
let s = libm::sinf(arg);
(1.0 + self.season_amplitude * s).clamp(0.0, 2.0)
}
}
impl Default for SimParams {
fn default() -> Self {
Self {
move_speed: 1.0,
move_cost: 0.06,
// Extracción generosa: la principal fuente de energía del sistema.
extract_rate: 2.5,
degr_per_extract: 0.02,
sync_rate: 0.10,
// Intercambio AGRESIVO: el mecanismo de redistribución que evita
// que el Gini suba a 1 y los Replicadores se agoten.
// Sin redistribución, la energía se concentra en Extractores y
// los Replicadores (que no extraen) se quedan sin combustible.
trade_amount: 1.5,
// Threshold de reproducción más alto: filtra para que sólo
// agentes con energía sustancial puedan tener hijos. Combinado
// con `abundance_threshold` alto (ver abajo), el sistema
// converge a un N* finito en lugar de crecer monotónicamente.
replicate_threshold: 25.0,
child_energy_frac: 0.50,
fight_damage: 4.0,
absorb_frac: 0.55,
desperation_threshold: 4.0,
// Atractor de abundancia: cualquier agente con E > 60 se vuelve
// Replicador. Calibrado para que pase con frecuencia moderada
// dado el flujo neto de energía típico (~0.5/tick por Extractor).
// Threshold de abundancia alto: sólo agentes con MUCHA energía
// (mucha más que la del equilibrio E* ≈ 27) replican como
// bonus. Esto frena el crecimiento poblacional y mantiene
// N* en el rango ~500-2000 en una grilla 80×80 con regrowth
// moderado.
abundance_threshold: 80.0,
trade_target: TradeTarget::Poorest,
// Vida larga + sin cliff: la cohorte inicial llega a max_edad
// al mismo tiempo y la mortalidad sincronizada extingue al
// sistema. Con max_edad alto, las cohortes se desincronizan
// por la natalidad estocástica vía Replicar y la mortalidad
// queda repartida.
max_edad: 6000,
// Costo metabólico basal: 0.05 E/tick. Calibrado para que el
// punto fijo N* quede en ~500-1500 (manejable para perf O(N²)
// de nearest/poorest), no en decenas de miles.
metabolic_cost: 0.05,
diffusion_rate: 0.10,
// Entropía a la mitad: la pérdida por tick era demasiado agresiva
// para el ciclo materia→energía→muerte→materia.
entropy_rate: 0.005,
// Default: el relieve físico sigue a materia, igual que el
// ZWeights del render por defecto. Las montañas de "biomasa"
// son las que se sienten al caminar.
relieve: [1.0, 0.0, 0.0, 0.0, 0.0],
climb_cost: 0.05,
// Sin estaciones por default — el motor sigue siendo el de antes
// a menos que el usuario las prenda explícitamente.
season_period: 0,
season_amplitude: 0.0,
// Regrowth lento + capacidad chica: la materia es escasa.
// Esto cierra la capacidad del territorio en N* manejable.
// Si subís estos, N* explota (validate empíricamente).
regrowth_rate: 0.015,
carrying_capacity: 18.0,
// Default: psi NO modula efectos → bit-exacto al motor histórico.
// Subir lentamente (0.3..0.7) para que la psicología empiece a
// sentirse sin reventar las calibraciones del Default.
psi_effect_modulation: 0.0,
// Default: política fija → la acción no se reelige por psi. Esto
// preserva tests existentes y todos los packs históricos.
action_policy: ActionPolicy::Fixed,
action_weights: default_action_weights(),
// Failsafe: con período 0, ni siquiera `PsiArgmax` reelige.
policy_reeval_period: 0,
// Fase B: contagio social desactivado por default. El motor
// histórico no recorre vecinos sociales, mantiene perf O(N).
social_radius: 0.0,
contagion_rate: 0.0,
// Fase B.2: sin filtro de homofilia → contagio universal cuando
// se enciende (semántica de B.1).
homophily_threshold: 0.0,
// Big Five off por default — el motor mantiene los 4 ejes
// históricos bit-exacto.
big_five: false,
action_weights_ext: default_action_weights_ext(),
}
}
}
@@ -0,0 +1,751 @@
//! Métricas psicológicas sobre la población — lectura pura, no muta nada.
//!
//! Complemento de `metrics::WorldStats`: aquellos eran agregados macro
//! (Gini de energía, conteo por acción, varianza global de psi). Estos son
//! métricas *psicológicas* en sentido estricto:
//!
//! 1. **Polarización Esteban-Ray** sobre cada componente del `vector_psi`.
//! Detecta distribuciones bimodales/multimodales — la población se está
//! rompiendo en tribus psicológicas. Cero cuando todos son iguales o la
//! distribución es unimodal centrada; sube cuando se forman polos.
//!
//! 2. **Correlación punto-biserial `psi[k] ↔ accion == a`**: una matriz
//! `4×6` que mide cuánto predice cada componente del psi cada acción.
//! Con `ActionPolicy::Fixed` y `psi_effect_modulation == 0` (motor
//! histórico), los valores fluctúan cerca de 0 porque la acción no
//! depende del psi. Con `PsiArgmax` se concentran en celdas donde
//! `action_weights[a][k]` es alto — exactamente el efecto que Fase A
//! instaló y que necesitamos *medir*.
//!
//! Determinismo bit-exacto: iteración lineal, sumas en `f64`, `libm::sqrt`/
//! `powf` para constantes precomputadas en orden fijo. No hay paralelismo,
//! ni hashing, ni ordenamiento sensible a empates.
use crate::lemmings::Lemmings;
use crate::world::World;
/// Cantidad de bins usados por la polarización Esteban-Ray. Pocos bins
/// son robustos a poblaciones chicas; con K=8 podemos detectar hasta 4
/// modos sin que el ruido domine.
pub const POLARIZATION_BINS: usize = 8;
/// Exponente de Esteban-Ray (`α`). `α=1` es el valor canónico que enfatiza
/// la concentración de masa en pocos polos sin desplomar el aporte de
/// distancia. `α=0` colapsaría a Gini; `α=1.6` (otro canónico) penaliza
/// más los polos chicos.
pub const POLARIZATION_ALPHA: f32 = 1.0;
/// Radio de vecindad (en unidades de celda) usado por el `from_world`
/// default para Moran's I. Pares de agentes con distancia ≤ este radio
/// son considerados vecinos espaciales con peso 1; el resto, peso 0
/// (vecindad binaria). Valor calibrado para grids 3080: detecta
/// autocorrelación local sin colapsar al promedio global.
pub const MORANS_RADIUS_DEFAULT: f32 = 6.0;
/// Cantidad de clusters fija para `kmeans_psi`. Tres es el mínimo que
/// detecta "centro + dos polos" — el patrón típico cuando emerge
/// polarización en la población.
pub const KMEANS_K: usize = 3;
/// Iteraciones máximas del k-means. 20 alcanza para 4 dimensiones y
/// poblaciones <10k; con convergencia temprana cuando `Δinertia < EPS`.
pub const KMEANS_MAX_ITER: u32 = 20;
/// Tolerancia de convergencia para `kmeans_psi` — cuando la inercia entre
/// iteraciones consecutivas cambia menos que esto, asume convergencia.
pub const KMEANS_EPS: f32 = 1e-4;
/// Snapshot psicológico instantáneo. Foto, no historia.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct PsiMetrics {
/// Polarización Esteban-Ray por componente del `vector_psi`
/// (`[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`). Cero cuando todos
/// los agentes tienen el mismo valor del componente o la varianza es
/// despreciable.
pub polarization: [f32; 4],
/// Correlación punto-biserial `r[k][a]` entre el componente `k` del
/// `vector_psi` (continuo) y el indicador `1[accion == a]` (binario).
/// Rango teórico `[-1, 1]`. Cero por convención cuando no hay agentes
/// con la acción `a` (o todos la tienen) o cuando `var(psi[k]) ≈ 0`.
pub psi_action_corr: [[f32; 6]; 4],
/// Índice de Moran I por componente del `vector_psi`. Mide
/// autocorrelación espacial: cuán parecido es el psi de un agente al
/// de sus vecinos en radio `MORANS_RADIUS_DEFAULT`. Rango teórico
/// aprox. `[-1, +1]`:
/// - `+1`: vecinos muy parecidos → segregación residencial (Schelling).
/// - `0`: psi distribuido al azar espacialmente.
/// - `-1`: vecinos opuestos (patrón "tablero de ajedrez").
/// Cero por convención cuando `n < 2`, `var(psi[k]) ≈ 0`, o ningún
/// par está dentro del radio.
pub moran_i: [f32; 4],
/// Polarización Esteban-Ray de la 5ª dimensión `psi5` (Big Five
/// Extraversion). `0.0` cuando el motor corre en Big Four o cuando la
/// 5ª dimensión es uniforme.
pub polarization_ext: f32,
/// Índice de Moran I de la 5ª dimensión `psi5`. `0.0` en Big Four o
/// distribución uniforme/azarosa.
pub moran_i_ext: f32,
}
impl PsiMetrics {
/// Computa todas las métricas con el radio de Moran default
/// (`MORANS_RADIUS_DEFAULT`). Vacío o N<2 → ceros (no hay señal).
pub fn from_world(w: &World) -> Self {
Self::from_world_with_moran_radius(w, MORANS_RADIUS_DEFAULT)
}
/// Como `from_world`, pero el caller decide el radio de vecindad
/// espacial usado por Moran's I. Útil cuando el grid es muy chico o
/// muy grande y el default no aplica.
pub fn from_world_with_moran_radius(w: &World, moran_radius: f32) -> Self {
let l = &w.lemmings;
let n = l.len();
if n < 2 {
return Self::default();
}
let mut polarization = [0.0f32; 4];
let mut moran_i = [0.0f32; 4];
for k in 0..4 {
let mut buf = Vec::with_capacity(n);
for i in 0..n {
buf.push(l.vector_psi[i][k]);
}
polarization[k] = polarization_esteban_ray(&buf);
moran_i[k] = morans_i_for(&buf, &l.pos_x, &l.pos_y, moran_radius);
}
// Big Five: si la columna psi5 está poblada (len == n), computa
// polarización y Moran sobre ella. En motor Big Four la columna está
// vacía o uniforme y los valores quedan en cero por convención.
let (polarization_ext, moran_i_ext) = if l.psi5.len() == n {
let buf: &[f32] = &l.psi5;
(
polarization_esteban_ray(buf),
morans_i_for(buf, &l.pos_x, &l.pos_y, moran_radius),
)
} else {
(0.0, 0.0)
};
let psi_action_corr = psi_action_corr_all(l);
Self {
polarization,
psi_action_corr,
moran_i,
polarization_ext,
moran_i_ext,
}
}
}
/// Índice de Moran I clásico con vecindad binaria por radio:
///
/// ```text
/// I = (n / S₀) · Σᵢ Σⱼ wᵢⱼ · (xᵢ − μ) · (xⱼ − μ) / Σᵢ (xᵢ − μ)²
/// ```
///
/// `wᵢⱼ = 1` si `|posᵢ posⱼ| ≤ radius` y `i ≠ j`, sino `0`.
/// `S₀ = Σᵢⱼ wᵢⱼ` (el número total de pares vecinos).
///
/// Devuelve `0.0` para casos patológicos (n<2, varianza ~0, S₀==0).
/// Acumulador en `f64` para estabilidad numérica en grids grandes.
pub fn morans_i_for(values: &[f32], xs: &[f32], ys: &[f32], radius: f32) -> f32 {
let n = values.len();
if n < 2 {
return 0.0;
}
if radius <= 0.0 {
return 0.0;
}
let r2 = radius * radius;
let nf = n as f64;
let mut mean: f64 = 0.0;
for &v in values {
mean += v as f64;
}
mean /= nf;
let mut variance: f64 = 0.0;
for &v in values {
let d = v as f64 - mean;
variance += d * d;
}
if variance < 1e-12 {
return 0.0;
}
let mut numerator: f64 = 0.0;
let mut s0: f64 = 0.0;
for i in 0..n {
let xi = xs[i];
let yi = ys[i];
let di = values[i] as f64 - mean;
for j in 0..n {
if i == j {
continue;
}
let dx = xs[j] - xi;
let dy = ys[j] - yi;
if dx * dx + dy * dy > r2 {
continue;
}
let dj = values[j] as f64 - mean;
numerator += di * dj;
s0 += 1.0;
}
}
if s0 < 1e-12 {
return 0.0;
}
((nf / s0) * (numerator / variance)) as f32
}
/// Polarización Esteban-Ray con K=`POLARIZATION_BINS` bins igualmente
/// espaciados entre `[min, max]` del slice. `α=POLARIZATION_ALPHA`.
///
/// ```text
/// P_α(p, x) = Σᵢ Σⱼ pᵢ^(1+α) · pⱼ · |xᵢ xⱼ|
/// ```
///
/// `min == max` → 0.0 (todos iguales, no hay nada que polarizar).
/// `n < 2` → 0.0.
fn polarization_esteban_ray(values: &[f32]) -> f32 {
let n = values.len();
if n < 2 {
return 0.0;
}
let mut min = values[0];
let mut max = values[0];
for &v in &values[1..] {
if v < min {
min = v;
}
if v > max {
max = v;
}
}
let span = max - min;
if span < 1e-9 {
return 0.0;
}
let bins = POLARIZATION_BINS;
let mut counts = vec![0u32; bins];
for &v in values {
// Bin = floor((v - min) / span * bins), clampeado a [0, bins-1].
let raw = ((v - min) / span) * bins as f32;
let mut bi = raw as i64;
if bi >= bins as i64 {
bi = bins as i64 - 1;
}
if bi < 0 {
bi = 0;
}
counts[bi as usize] += 1;
}
let nf = n as f64;
let bin_width = span as f64 / bins as f64;
let mut probs = [0.0f64; POLARIZATION_BINS];
for i in 0..bins {
probs[i] = counts[i] as f64 / nf;
}
// Centros de bin: min + (i + 0.5) · bin_width.
let mut centers = [0.0f64; POLARIZATION_BINS];
for i in 0..bins {
centers[i] = min as f64 + (i as f64 + 0.5) * bin_width;
}
// `α + 1` precomputado en f32 — libm::powf garantiza el mismo bit a
// bit en x86 y ARM.
let exp = (POLARIZATION_ALPHA + 1.0) as f64;
let mut acc: f64 = 0.0;
for i in 0..bins {
if probs[i] <= 0.0 {
continue;
}
let pi_alpha = libm::pow(probs[i], exp);
for j in 0..bins {
if probs[j] <= 0.0 {
continue;
}
let diff = (centers[i] - centers[j]).abs();
acc += pi_alpha * probs[j] * diff;
}
}
acc as f32
}
/// Correlación de Pearson punto-biserial entre cada componente del psi
/// (continuo) y el indicador `1[accion == a]` (binario), para cada
/// `k ∈ 0..4` y `a ∈ 0..6`. Fórmula clásica:
///
/// ```text
/// r_pb = ( μ_{X|Y=1} μ_X ) · √( p / (1p) ) / σ_X
/// ```
///
/// Devuelve ceros para entradas patológicas (varianza ~0, acción nunca
/// ejecutada o ejecutada por todos).
fn psi_action_corr_all(l: &Lemmings) -> [[f32; 6]; 4] {
let n = l.len();
if n < 2 {
return [[0.0; 6]; 4];
}
let nf = n as f64;
// Pasada 1: media de cada componente del psi.
let mut mean_psi = [0.0f64; 4];
for i in 0..n {
for k in 0..4 {
mean_psi[k] += l.vector_psi[i][k] as f64;
}
}
for k in 0..4 {
mean_psi[k] /= nf;
}
// Pasada 2: varianza de cada componente.
let mut var_psi = [0.0f64; 4];
for i in 0..n {
for k in 0..4 {
let d = l.vector_psi[i][k] as f64 - mean_psi[k];
var_psi[k] += d * d;
}
}
for k in 0..4 {
var_psi[k] /= nf;
}
// Pasada 3: conteo por acción y suma del psi condicional.
let mut count_a = [0u64; 6];
let mut sum_psi_when_a = [[0.0f64; 6]; 4];
for i in 0..n {
let a = l.accion[i] as usize;
if a < 6 {
count_a[a] += 1;
for k in 0..4 {
sum_psi_when_a[k][a] += l.vector_psi[i][k] as f64;
}
}
}
let mut out = [[0.0f32; 6]; 4];
for k in 0..4 {
if var_psi[k] < 1e-12 {
continue;
}
let sd = libm::sqrt(var_psi[k]);
for a in 0..6 {
let p = count_a[a] as f64 / nf;
if p < 1e-9 || p > 1.0 - 1e-9 {
continue;
}
let mean_when_a = sum_psi_when_a[k][a] / count_a[a] as f64;
let r = (mean_when_a - mean_psi[k]) * libm::sqrt(p / (1.0 - p)) / sd;
out[k][a] = r as f32;
}
}
out
}
/// Resultado del k-means determinista sobre `vector_psi`.
#[derive(Debug, Clone, PartialEq)]
pub struct KMeansResult {
/// Centroides finales en el espacio psi 4D. `[cluster][componente]`.
pub centroids: [[f32; 4]; KMEANS_K],
/// Cantidad de agentes asignados a cada cluster.
pub sizes: [u32; KMEANS_K],
/// Asignación por agente: byte `0..KMEANS_K`. Largo = `world.lemmings.len()`.
pub assignments: Vec<u8>,
/// Suma de distancias cuadradas de cada agente a su centroide. Métrica
/// agregada de "compactness" de los clusters. Cero = clusters perfectos
/// (todos los agentes están en su centroide); valores grandes = clusters
/// difusos.
pub inertia: f32,
/// Iteraciones efectivamente corridas hasta la convergencia.
pub iterations: u32,
}
/// k-means determinista sobre `vector_psi` con `k = KMEANS_K = 3`. Cero
/// RNG: inicialización por buckets `i % k`. Convergencia cuando la inercia
/// entre iteraciones consecutivas cambia menos que `KMEANS_EPS`. Asignación
/// tie-break por menor índice de cluster.
///
/// Devuelve `None` cuando hay menos de `KMEANS_K` agentes.
pub fn kmeans_psi(world: &World) -> Option<KMeansResult> {
let l = &world.lemmings;
let n = l.len();
if n < KMEANS_K {
return None;
}
// Inicialización determinista: buckets por índice módulo K.
let mut centroids: [[f32; 4]; KMEANS_K] = [[0.0; 4]; KMEANS_K];
{
let mut sums = [[0.0f64; 4]; KMEANS_K];
let mut counts = [0u32; KMEANS_K];
for i in 0..n {
let c = i % KMEANS_K;
for d in 0..4 {
sums[c][d] += l.vector_psi[i][d] as f64;
}
counts[c] += 1;
}
for c in 0..KMEANS_K {
if counts[c] == 0 {
continue;
}
for d in 0..4 {
centroids[c][d] = (sums[c][d] / counts[c] as f64) as f32;
}
}
}
let mut assignments = vec![0u8; n];
let mut prev_inertia: f64 = f64::INFINITY;
let mut iterations: u32 = 0;
let mut last_inertia: f64 = 0.0;
for it in 0..KMEANS_MAX_ITER {
iterations = it + 1;
// Step 1: asignar cada agente al centroide más cercano.
let mut inertia: f64 = 0.0;
for i in 0..n {
let mut best_c: u8 = 0;
let mut best_d2: f32 = f32::MAX;
for c in 0..KMEANS_K {
let mut d2: f32 = 0.0;
for d in 0..4 {
let diff = l.vector_psi[i][d] - centroids[c][d];
d2 += diff * diff;
}
if d2 < best_d2 {
best_d2 = d2;
best_c = c as u8;
}
}
assignments[i] = best_c;
inertia += best_d2 as f64;
}
last_inertia = inertia;
// Convergencia: si la inercia no se mueve, paramos.
if (prev_inertia - inertia).abs() < KMEANS_EPS as f64 {
break;
}
prev_inertia = inertia;
// Step 2: recomputar centroides como medias del cluster. Clusters
// vacíos preservan su centroide del paso anterior (no se actualizan).
let mut new_sums = [[0.0f64; 4]; KMEANS_K];
let mut new_counts = [0u32; KMEANS_K];
for i in 0..n {
let c = assignments[i] as usize;
for d in 0..4 {
new_sums[c][d] += l.vector_psi[i][d] as f64;
}
new_counts[c] += 1;
}
for c in 0..KMEANS_K {
if new_counts[c] == 0 {
continue;
}
for d in 0..4 {
centroids[c][d] = (new_sums[c][d] / new_counts[c] as f64) as f32;
}
}
}
let mut sizes = [0u32; KMEANS_K];
for &a in &assignments {
sizes[a as usize] += 1;
}
Some(KMeansResult {
centroids,
sizes,
assignments,
inertia: last_inertia as f32,
iterations,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::SimParams;
use crate::world::World;
#[test]
fn empty_or_singleton_yields_zeros() {
let w = World::new(4, 4);
let m = PsiMetrics::from_world(&w);
assert_eq!(m.polarization, [0.0; 4]);
assert_eq!(m.psi_action_corr, [[0.0; 6]; 4]);
let mut w = World::new(4, 4);
w.lemmings.spawn(1.0, 1.0, 10.0, [0.5; 4]);
let m = PsiMetrics::from_world(&w);
assert_eq!(m.polarization, [0.0; 4]);
}
#[test]
fn uniform_population_has_zero_polarization() {
let mut w = World::new(4, 4);
for _ in 0..50 {
w.lemmings.spawn(1.0, 1.0, 10.0, [0.5; 4]);
}
let m = PsiMetrics::from_world(&w);
for k in 0..4 {
assert!(m.polarization[k].abs() < 1e-5, "comp {k}: {}", m.polarization[k]);
}
}
#[test]
fn bimodal_population_has_high_polarization() {
let mut w = World::new(4, 4);
// Mitad psi[0]=0, mitad psi[0]=1: distribución perfectamente bimodal.
for k in 0..50 {
let val = if k < 25 { 0.0 } else { 1.0 };
w.lemmings.spawn(1.0, 1.0, 10.0, [val, 0.5, 0.5, 0.5]);
}
let m = PsiMetrics::from_world(&w);
// El componente bimodal debe ser claramente más polarizado que los
// unimodales centrados.
assert!(
m.polarization[0] > 0.1,
"comp 0 bimodal debe polarizar: {}",
m.polarization[0]
);
for k in 1..4 {
assert!(
m.polarization[k] < 1e-4,
"comp {k} uniforme no debe polarizar: {}",
m.polarization[k]
);
}
// Y el bimodal debe ser mayor que el uniforme por un margen amplio.
assert!(m.polarization[0] > m.polarization[1] * 100.0);
}
#[test]
fn psi_action_correlation_emerges_when_psi_predicts_action() {
// Construcción a mano: los lemmings con CORRUPTIBILIDAD alta están
// todos en accion=Degradar (5). Los honestos están en accion=Mover (0).
let mut w = World::new(4, 4);
for _ in 0..30 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 0.0, 0.0, 1.0]);
w.lemmings.accion[i] = 5; // Degradar
}
for _ in 0..30 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 0.0, 0.0, 0.0]);
w.lemmings.accion[i] = 0; // Mover
}
let m = PsiMetrics::from_world(&w);
// corr(CORRUPTIBILIDAD, Degradar) debe ser ~+1: alta CORR → Degradar.
assert!(
m.psi_action_corr[3][5] > 0.8,
"corr CORR↔Degradar: {}",
m.psi_action_corr[3][5]
);
// corr(CORRUPTIBILIDAD, Mover) debe ser ~-1: alta CORR → NO Mover.
assert!(
m.psi_action_corr[3][0] < -0.8,
"corr CORR↔Mover: {}",
m.psi_action_corr[3][0]
);
// Componentes irrelevantes (ORDEN, MIEDO, CURIOSIDAD) varianza 0
// → correlación 0 por convención.
for k in 0..3 {
for a in 0..6 {
assert!(
m.psi_action_corr[k][a].abs() < 1e-5,
"comp {k} action {a}: {}",
m.psi_action_corr[k][a]
);
}
}
}
#[test]
fn psi_action_correlation_zero_when_action_random_vs_psi() {
// psi alternados con accion fija (todos hacen lo mismo). p(accion)=1
// → fórmula devuelve 0 por convención (no se puede correlacionar
// con un evento que siempre ocurre).
let mut w = World::new(4, 4);
for k in 0..40 {
let val = if k % 2 == 0 { 0.0 } else { 1.0 };
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [val; 4]);
w.lemmings.accion[i] = 1; // todos Extraer
}
let m = PsiMetrics::from_world(&w);
// Todos hacen Extraer → p=1 → corr = 0 en todas las columnas.
for k in 0..4 {
for a in 0..6 {
assert!(
m.psi_action_corr[k][a].abs() < 1e-5,
"comp {k} action {a} debe ser 0: {}",
m.psi_action_corr[k][a]
);
}
}
}
#[test]
fn morans_i_is_high_when_neighbors_are_alike() {
// Dos clusters físicos+psi distintos: izquierda con psi[0]=1,
// derecha con psi[0]=0. Como cada agente está rodeado de iguales,
// Moran's I debe ser cercano a +1.
let mut w = World::new(40, 40);
for k in 0..6 {
w.lemmings
.spawn(5.0 + (k % 3) as f32, 5.0 + (k / 3) as f32, 30.0, [1.0, 0.0, 0.0, 0.0]);
}
for k in 0..6 {
w.lemmings
.spawn(30.0 + (k % 3) as f32, 30.0 + (k / 3) as f32, 30.0, [0.0, 0.0, 0.0, 0.0]);
}
let m = PsiMetrics::from_world(&w);
// En psi[ORDEN], la segregación física espeja la variación → Moran alto.
assert!(
m.moran_i[0] > 0.5,
"Moran's I bajo aunque hay clustering espacial claro: {}",
m.moran_i[0]
);
}
#[test]
fn morans_i_is_zero_when_psi_is_spatially_random() {
// Mismas posiciones que el test anterior pero alternando psi:
// patrón A B A B A B en ambas zonas → autocorrelación ≈ 0.
let mut w = World::new(40, 40);
for k in 0..12 {
let psi_val = if k % 2 == 0 { 1.0 } else { 0.0 };
let x = 5.0 + (k % 4) as f32 * 2.0;
let y = 5.0 + (k / 4) as f32 * 2.0;
w.lemmings.spawn(x, y, 30.0, [psi_val, 0.0, 0.0, 0.0]);
}
let m = PsiMetrics::from_world(&w);
// Patrón tipo ajedrez: Moran's I tiende a ser negativo (vecinos
// distintos). Aceptamos un rango amplio: muy lejos de +1.
assert!(
m.moran_i[0] < 0.5,
"Moran's I alto en distribución alternante: {}",
m.moran_i[0]
);
}
#[test]
fn morans_i_zero_when_uniform_population() {
let mut w = World::new(40, 40);
for _ in 0..20 {
w.lemmings.spawn(10.0, 10.0, 30.0, [0.5; 4]);
}
let m = PsiMetrics::from_world(&w);
for k in 0..4 {
assert!(
m.moran_i[k].abs() < 1e-5,
"Moran[{k}] no es cero en pop uniforme: {}",
m.moran_i[k]
);
}
}
#[test]
fn kmeans_returns_none_when_too_few_agents() {
let w = World::new(8, 8);
assert!(kmeans_psi(&w).is_none());
let mut w = World::new(8, 8);
w.lemmings.spawn(1.0, 1.0, 10.0, [0.5; 4]);
w.lemmings.spawn(2.0, 2.0, 10.0, [0.5; 4]);
assert!(kmeans_psi(&w).is_none()); // sólo 2 agentes, K=3
}
#[test]
fn kmeans_finds_three_distinct_clusters() {
// Tres grupos en zonas opuestas del espacio psi: [1,0,0,0],
// [0,1,0,0], [0,0,1,0]. 10 agentes por grupo.
let mut w = World::new(8, 8);
for _ in 0..10 {
w.lemmings.spawn(1.0, 1.0, 10.0, [1.0, 0.0, 0.0, 0.0]);
}
for _ in 0..10 {
w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 1.0, 0.0, 0.0]);
}
for _ in 0..10 {
w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 0.0, 1.0, 0.0]);
}
let r = kmeans_psi(&w).expect("k-means corre");
// Los 3 clusters deben quedar de tamaño ~10 cada uno.
let mut sizes = r.sizes.to_vec();
sizes.sort();
assert_eq!(sizes, vec![10, 10, 10]);
// Inertia muy chica porque los clusters son compactos.
assert!(r.inertia < 0.1, "inertia alta: {}", r.inertia);
}
#[test]
fn kmeans_is_deterministic_under_same_input() {
// Dos mundos idénticos deben producir k-means idéntico.
let build = || {
let mut w = World::new(8, 8);
for k in 0..18 {
let val = (k as f32 * 0.37).fract();
w.lemmings.spawn(1.0, 1.0, 10.0, [val, 1.0 - val, val * val, 0.5]);
}
w
};
let a = kmeans_psi(&build()).expect("a");
let b = kmeans_psi(&build()).expect("b");
assert_eq!(a.centroids, b.centroids);
assert_eq!(a.sizes, b.sizes);
assert_eq!(a.assignments, b.assignments);
assert_eq!(a.inertia, b.inertia);
assert_eq!(a.iterations, b.iterations);
}
#[test]
fn psi_metrics_calcula_ext_cuando_psi5_esta_poblado() {
// Población bimodal sólo en la 5ª dimensión: mitad psi5=0,
// mitad psi5=1. Las 4 primeras componentes uniformes.
let mut w = World::new(8, 8);
for k in 0..40 {
let v5 = if k < 20 { 0.0 } else { 1.0 };
w.lemmings.spawn_big5(1.0, 1.0, 10.0, [0.5; 4], v5);
}
let m = PsiMetrics::from_world(&w);
// polarization_ext debe ser alta porque la distribución es bimodal.
assert!(m.polarization_ext > 0.1, "polar_ext bimodal: {}", m.polarization_ext);
// Las 4 primeras componentes uniformes → polarization ~0.
for k in 0..4 {
assert!(m.polarization[k].abs() < 1e-4, "comp {k}: {}", m.polarization[k]);
}
}
#[test]
fn psi_metrics_ext_es_cero_sin_columna_psi5() {
// Build manual de un Lemmings con psi5 vacío (motor Big Four
// serializado antes del cambio).
use crate::lemmings::Lemmings;
let mut w = World::new(8, 8);
// Llenamos los vectores básicos a mano para simular un deserialize
// viejo que no traía psi5.
w.lemmings = Lemmings {
pos_x: vec![1.0, 2.0],
pos_y: vec![1.0, 2.0],
edad: vec![0; 2],
energia: vec![10.0; 2],
vector_psi: vec![[0.5; 4]; 2],
accion: vec![0; 2],
hack_lock: vec![0; 2],
psi5: Vec::new(),
};
let m = PsiMetrics::from_world(&w);
assert_eq!(m.polarization_ext, 0.0);
assert_eq!(m.moran_i_ext, 0.0);
}
#[test]
fn metrics_run_on_typical_world_without_panicking() {
let mut w = World::new(16, 16);
for k in 0..40 {
let x = (k % 8) as f32 + 2.0;
let y = (k / 8) as f32 + 2.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(),
];
let i = w.lemmings.spawn(x, y, 30.0, psi);
w.lemmings.accion[i] = (k % 6) as u8;
}
let m = PsiMetrics::from_world(&w);
// No deben aparecer NaN/inf.
for k in 0..4 {
assert!(m.polarization[k].is_finite());
for a in 0..6 {
assert!(m.psi_action_corr[k][a].is_finite());
}
}
// Sanidad: con SimParams default el tipo se usa sin problemas.
let _ = SimParams::default();
}
}
@@ -0,0 +1,661 @@
//! El mundo: grilla + lemmings, y las 6 acciones atómicas fijas.
//!
//! Cualquier "profesión" o "rol" del macro es sólo un Lemming ejecutando
//! una de estas 6 acciones en un entorno específico.
use crate::conceptos::Conceptos;
use crate::grid::Grid;
use crate::lemmings::{Lemmings, PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_MIEDO, PSI_ORDEN};
use crate::params::{SimParams, TradeTarget};
use serde::{Deserialize, Serialize};
/// Selecciona el byte de acción que maximiza `action_weights · psi`. Tie-break
/// determinista por menor índice. Devuelve el byte en `0..=5`.
///
/// Esta función es la mecánica matemática de [`ActionPolicy::PsiArgmax`]: sin
/// RNG, sin softmax, sin libm. Cualquier sintonía de pesos produce el mismo
/// resultado en x86 y ARM porque sólo hay multiplicaciones y sumas `f32` en
/// orden fijo.
pub fn select_action_argmax(psi: &[f32; 4], weights: &[[f32; 4]; 6]) -> u8 {
let mut best_idx: u8 = 0;
let mut best_score: f32 = f32::MIN;
for (a, w) in weights.iter().enumerate() {
let s = w[0] * psi[0] + w[1] * psi[1] + w[2] * psi[2] + w[3] * psi[3];
if s > best_score {
best_score = s;
best_idx = a as u8;
}
}
best_idx
}
/// Variante Big Five de [`select_action_argmax`]. Suma al score la
/// contribución de la 5ª dimensión `psi5` ponderada por `weights_ext`.
/// Tie-break determinista por menor índice — idéntico al motor Big Four
/// cuando `psi5 == 0` y `weights_ext == [0; 6]`.
pub fn select_action_argmax_big5(
psi: &[f32; 4],
psi5: f32,
weights: &[[f32; 4]; 6],
weights_ext: &[f32; 6],
) -> u8 {
let mut best_idx: u8 = 0;
let mut best_score: f32 = f32::MIN;
for a in 0..6 {
let w = &weights[a];
let s = w[0] * psi[0] + w[1] * psi[1] + w[2] * psi[2] + w[3] * psi[3]
+ weights_ext[a] * psi5;
if s > best_score {
best_score = s;
best_idx = a as u8;
}
}
best_idx
}
/// Las 6 acciones atómicas. El byte `accion` del Lemming es uno de estos.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum Action {
/// Lee gradientes vecinos, se mueve hacia el óptimo, gasta energía.
Mover = 0,
/// Resta de la celda actual, suma a su energía, degrada el suelo.
Extraer = 1,
/// Acerca su `vector_psi` a los campos de la celda actual.
Sincronizar = 2,
/// Transfiere energía al vecino más cercano.
Intercambiar = 3,
/// Gasta energía para instanciar un Lemming hijo (edad 0).
Replicar = 4,
/// Resta energía al vecino más cercano y absorbe una fracción.
Degradar = 5,
}
impl Action {
/// Convierte el byte discriminador. `None` si está fuera de rango.
pub fn from_u8(b: u8) -> Option<Action> {
match b {
0 => Some(Action::Mover),
1 => Some(Action::Extraer),
2 => Some(Action::Sincronizar),
3 => Some(Action::Intercambiar),
4 => Some(Action::Replicar),
5 => Some(Action::Degradar),
_ => None,
}
}
}
/// El estado completo de la simulación.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct World {
pub grid: Grid,
pub lemmings: Lemmings,
#[serde(default)]
pub conceptos: Conceptos,
/// Tick global del mundo — `physics::tick` lo incrementa al final de
/// cada paso. Es el reloj que alimenta la modulación estacional de
/// `SimParams::season_period`. Saves viejos sin este campo arrancan
/// en 0 vía `serde(default)`.
#[serde(default)]
pub tick_count: u64,
}
impl World {
pub fn new(width: usize, height: usize) -> Self {
Self {
grid: Grid::new(width, height),
lemmings: Lemmings::new(),
conceptos: Conceptos::new(),
tick_count: 0,
}
}
/// Celda que ocupa el Lemming `i`.
fn cell_of(&self, i: usize) -> usize {
let (cx, cy) = self.grid.clamp_cell(self.lemmings.pos_x[i], self.lemmings.pos_y[i]);
self.grid.idx(cx, cy)
}
/// Relieve físico de la celda `idx` — combinación lineal de las 5
/// capas pesada por `p.relieve`. Es la altura que **siente** un
/// lemming, no la que se renderiza (esa la define `ZWeights`).
fn relieve_at(&self, idx: usize, p: &SimParams) -> f32 {
let g = &self.grid;
p.relieve[0] * g.materia[idx]
+ p.relieve[1] * g.psique[idx]
+ p.relieve[2] * g.poder[idx]
+ p.relieve[3] * g.oro[idx]
+ p.relieve[4] * g.degradacion[idx]
}
/// 0 · Mover — gravedad mental hacia el vecino más afín al `vector_psi`,
/// penalizado por el costo de pendiente. Las "montañas" emergentes de
/// alta `materia` o de alta `psique` (según `p.relieve`) se vuelven
/// barreras físicas: cuesta más score subir y se paga energía extra
/// proporcional a la altura efectivamente subida.
pub fn act_mover(&mut self, i: usize, p: &SimParams) {
let (cx, cy) =
self.grid.clamp_cell(self.lemmings.pos_x[i], self.lemmings.pos_y[i]);
let psi = self.lemmings.vector_psi[i];
let cur_idx = self.grid.idx(cx, cy);
let z_cur = self.relieve_at(cur_idx, p);
let mut best_dir = (0.0f32, 0.0f32);
let mut best_z = z_cur;
let mut best_score = f32::MIN;
for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {
let (nx, ny) = (cx as i64 + dx, cy as i64 + dy);
if !self.grid.in_bounds(nx, ny) {
continue;
}
let idx = self.grid.idx(nx as usize, ny as usize);
// Orden busca materia, Miedo evita poder, Curiosidad busca
// psique, Corruptibilidad busca oro.
let mut score = psi[PSI_ORDEN] * self.grid.materia[idx]
- psi[PSI_MIEDO] * self.grid.poder[idx]
+ psi[PSI_CURIOSIDAD] * self.grid.psique[idx]
+ psi[PSI_CORRUPTIBILIDAD] * self.grid.oro[idx];
let z_n = self.relieve_at(idx, p);
let climb = (z_n - z_cur).max(0.0);
score -= p.climb_cost * climb;
if score > best_score {
best_score = score;
best_dir = (dx as f32, dy as f32);
best_z = z_n;
}
}
let w = self.grid.width as f32 - 1.0;
let h = self.grid.height as f32 - 1.0;
self.lemmings.pos_x[i] =
(self.lemmings.pos_x[i] + best_dir.0 * p.move_speed).clamp(0.0, w);
self.lemmings.pos_y[i] =
(self.lemmings.pos_y[i] + best_dir.1 * p.move_speed).clamp(0.0, h);
// Costo base + costo de pendiente realmente subida.
let climb_paid = (best_z - z_cur).max(0.0) * p.climb_cost;
// Psi-modulación: el miedoso se cansa más al moverse (chequea el
// entorno, vuelve, duda). Factor 1.0 cuando modulation == 0.
let move_cost_eff =
p.move_cost * (1.0 + p.psi_effect_modulation * 0.5 * psi[PSI_MIEDO]).max(0.0);
self.lemmings.energia[i] -= move_cost_eff + climb_paid;
}
/// 1 · Extraer — vacía materia de la celda hacia la energía del agente.
///
/// Psi-modulación: el agente con `psi[CORRUPTIBILIDAD]` alto saca más
/// de la celda (y deja proporcionalmente más cicatriz). Sin modulación
/// (factor 1.0) el comportamiento es idéntico al motor histórico.
pub fn act_extraer(&mut self, i: usize, p: &SimParams) {
let idx = self.cell_of(i);
let psi = self.lemmings.vector_psi[i];
let factor = (1.0 + p.psi_effect_modulation * psi[PSI_CORRUPTIBILIDAD]).max(0.0);
let rate_eff = p.extract_rate * factor;
let taken = self.grid.materia[idx].min(rate_eff).max(0.0);
self.grid.materia[idx] -= taken;
self.lemmings.energia[i] += taken;
self.grid.degradacion[idx] += p.degr_per_extract * factor;
}
/// 2 · Sincronizar — el `vector_psi` deriva hacia los campos de la celda.
/// Mapeo coherente con `act_mover`: ORDEN↔materia, MIEDO↔poder,
/// CURIOSIDAD↔psique, CORRUPTIBILIDAD↔oro.
pub fn act_sincronizar(&mut self, i: usize, p: &SimParams) {
let idx = self.cell_of(i);
let mut targets = [0.0f32; 4];
targets[PSI_ORDEN] = self.grid.materia[idx];
targets[PSI_MIEDO] = self.grid.poder[idx];
targets[PSI_CURIOSIDAD] = self.grid.psique[idx];
targets[PSI_CORRUPTIBILIDAD] = self.grid.oro[idx];
for k in 0..4 {
let v = self.lemmings.vector_psi[i][k];
self.lemmings.vector_psi[i][k] = v + (targets[k] - v) * p.sync_rate;
}
}
/// 3 · Intercambiar — transfiere energía a otro agente. El destinatario
/// depende de `p.trade_target`: `Nearest` mantiene la semántica original
/// (vecino físico más cercano), `Poorest` redistribuye al más necesitado
/// del mundo. La elección controla si el sistema alcanza un punto fijo
/// `N* > 0` o se extingue por desigualdad creciente.
pub fn act_intercambiar(&mut self, i: usize, p: &SimParams) {
let target = match p.trade_target {
TradeTarget::Nearest => self.lemmings.nearest(i),
TradeTarget::Poorest => self.lemmings.poorest(i),
};
let Some(j) = target else { return };
// Psi-modulación: el ordenado comparte, el corruptible retiene.
// Factor clamp ≥ 0 — un psi extremo en CORRUPTIBILIDAD puede
// anular el intercambio pero no invertirlo (eso sería robo, que
// no es la semántica de `act_intercambiar`).
let psi = self.lemmings.vector_psi[i];
let factor =
(1.0 + p.psi_effect_modulation * (psi[PSI_ORDEN] - psi[PSI_CORRUPTIBILIDAD])).max(0.0);
let amount = (p.trade_amount * factor).min(self.lemmings.energia[i]).max(0.0);
self.lemmings.energia[i] -= amount;
self.lemmings.energia[j] += amount;
}
/// 4 · Replicar — instancia un hijo con edad 0 en una celda **vecina**
/// (no la misma del padre). El hijo hereda la acción y el `vector_psi`.
///
/// Dispersión determinista: la dirección del hijo viene de
/// `(edad_padre + idx_padre) % 4`, así N hijos del mismo padre se
/// reparten en las 4 vecinas. Sin esta dispersión, los hijos saturan
/// la celda del padre, agotan la materia local y colapsan en cascada
/// — incluso con regrowth + costo metabólico activos.
///
/// La herencia + dispersión + side-effect de abundancia (ver
/// `step_lemming`) son las tres piezas que dan al sistema un punto
/// fijo `N* > 0`.
pub fn act_replicar(&mut self, i: usize, p: &SimParams) {
let psi = self.lemmings.vector_psi[i];
// Psi-modulación: el ordenado baja su umbral de reproducción
// (forma familia antes). Clamp inferior a 0.1·threshold para
// evitar reproducción explosiva con psi extremos.
let thr_factor = (1.0 - p.psi_effect_modulation * 0.3 * psi[PSI_ORDEN]).max(0.1);
let thr_eff = p.replicate_threshold * thr_factor;
if self.lemmings.energia[i] <= thr_eff {
return;
}
let cost = self.lemmings.energia[i] * p.child_energy_frac;
self.lemmings.energia[i] -= cost;
let accion = self.lemmings.accion[i];
// Dirección de dispersión: 0=E, 1=O, 2=S, 3=N. Determinista por
// (edad + i) — distribuye los hijos sucesivos en las 4 vecinas.
let dir = (self.lemmings.edad[i].wrapping_add(i as u32) & 0x3) as u8;
let (dx, dy) = match dir {
0 => (1.0, 0.0),
1 => (-1.0, 0.0),
2 => (0.0, 1.0),
_ => (0.0, -1.0),
};
let max_x = self.grid.width as f32 - 1.0;
let max_y = self.grid.height as f32 - 1.0;
let x = (self.lemmings.pos_x[i] + dx).clamp(0.0, max_x);
let y = (self.lemmings.pos_y[i] + dy).clamp(0.0, max_y);
// El hijo hereda el psi5 del padre — sin esto, el linaje Big Five
// se borraría a cada generación.
let psi5 = self.lemmings.psi5_at(i);
let child = self.lemmings.spawn_big5(x, y, cost, psi, psi5);
self.lemmings.accion[child] = accion;
}
/// 5 · Degradar (Pelear) — resta energía al vecino y absorbe parte.
///
/// Psi-modulación: el atacante miedoso pega menos, el corruptible más.
/// Factor `max(0, 1 + mod · (CORR MIEDO))` — un agente cuyo MIEDO
/// domina deja de hacer daño (pero `act_degradar` sigue ejecutándose:
/// es la mecánica de "amago" / "huida").
pub fn act_degradar(&mut self, i: usize, p: &SimParams) {
let Some(j) = self.lemmings.nearest(i) else { return };
let psi = self.lemmings.vector_psi[i];
let factor =
(1.0 + p.psi_effect_modulation * (psi[PSI_CORRUPTIBILIDAD] - psi[PSI_MIEDO])).max(0.0);
let dmg_max = p.fight_damage * factor;
let dmg = dmg_max.min(self.lemmings.energia[j]).max(0.0);
self.lemmings.energia[j] -= dmg;
self.lemmings.energia[i] += dmg * p.absorb_frac;
}
/// Despacha la acción del Lemming `i` según su byte `accion`.
///
/// **Bonus de abundancia**: si `p.abundance_threshold > 0` y la
/// energía del agente supera ese umbral, ejecuta `act_replicar` como
/// *side-effect* ANTES de su acción principal. Esto cierra el ciclo
/// termodinámico: cualquier agente saciado se reproduce sin abandonar
/// su rol (un Extractor sigue extrayendo, un Trader sigue donando).
/// Si el lemming ya está en `Replicar`, el bonus no doble-cuenta —
/// `act_replicar` requiere que `energia > replicate_threshold` y le
/// resta `child_energy_frac`, así que el segundo intento dentro del
/// mismo tick muy probablemente fallará el guardia.
pub fn step_lemming(&mut self, i: usize, p: &SimParams) {
if p.abundance_threshold > 0.0
&& self.lemmings.hack_lock[i] == 0
&& self.lemmings.energia[i] > p.abundance_threshold
{
self.act_replicar(i, p);
}
match Action::from_u8(self.lemmings.accion[i]) {
Some(Action::Mover) => self.act_mover(i, p),
Some(Action::Extraer) => self.act_extraer(i, p),
Some(Action::Sincronizar) => self.act_sincronizar(i, p),
Some(Action::Intercambiar) => self.act_intercambiar(i, p),
Some(Action::Replicar) => self.act_replicar(i, p),
Some(Action::Degradar) => self.act_degradar(i, p),
None => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn world_1x_lemming() -> (World, SimParams) {
let mut w = World::new(16, 16);
w.lemmings.spawn(8.0, 8.0, 100.0, [1.0, 0.0, 0.0, 0.0]);
(w, SimParams::default())
}
#[test]
fn action_from_u8_covers_0_to_5() {
for b in 0..=5u8 {
assert!(Action::from_u8(b).is_some());
}
assert!(Action::from_u8(6).is_none());
}
#[test]
fn mover_heads_toward_higher_materia() {
let (mut w, p) = world_1x_lemming();
// Materia alta a la derecha de (8,8).
let right = w.grid.idx(9, 8);
w.grid.materia[right] = 100.0;
let x0 = w.lemmings.pos_x[0];
w.act_mover(0, &p);
assert!(w.lemmings.pos_x[0] > x0, "se movió hacia la materia");
assert!(w.lemmings.energia[0] < 100.0, "Mover cuesta energía");
}
#[test]
fn extraer_drains_cell_into_agent_and_degrades() {
let (mut w, p) = world_1x_lemming();
let idx = w.grid.idx(8, 8);
w.grid.materia[idx] = 10.0;
w.act_extraer(0, &p);
assert!(w.grid.materia[idx] < 10.0);
assert!(w.lemmings.energia[0] > 100.0);
assert!(w.grid.degradacion[idx] > 0.0);
}
#[test]
fn replicar_spawns_child_and_costs_energy() {
let (mut w, p) = world_1x_lemming(); // energía 100 > umbral 50
w.act_replicar(0, &p);
assert_eq!(w.lemmings.len(), 2);
assert_eq!(w.lemmings.edad[1], 0);
assert!(w.lemmings.energia[0] < 100.0);
}
#[test]
fn replicar_passes_action_to_child() {
// Sin herencia, el subgrupo "Replicador" se pierde en una generación
// y dN/dt < 0 estructuralmente. La herencia es el fix matemático
// que cierra el ciclo.
let mut w = World::new(8, 8);
let i = w.lemmings.spawn(4.0, 4.0, 100.0, [0.5, 0.5, 0.5, 0.5]);
w.lemmings.accion[i] = 4; // Replicar
let p = SimParams::default();
w.act_replicar(i, &p);
assert_eq!(w.lemmings.len(), 2);
// El hijo (índice 1) hereda la acción 4 del padre, no la acción 0
// que el spawn pone por default.
assert_eq!(w.lemmings.accion[1], 4, "hijo hereda accion del padre");
// El psi también se hereda — eso ya funcionaba.
assert_eq!(w.lemmings.vector_psi[1], w.lemmings.vector_psi[0]);
}
#[test]
fn degradar_drains_nearest_and_absorbs() {
let mut w = World::new(16, 16);
w.lemmings.spawn(8.0, 8.0, 50.0, [0.0; 4]);
w.lemmings.spawn(9.0, 8.0, 50.0, [0.0; 4]);
let p = SimParams::default();
w.act_degradar(0, &p);
assert!(w.lemmings.energia[1] < 50.0, "la víctima pierde energía");
assert!(w.lemmings.energia[0] > 50.0, "el atacante absorbe");
}
#[test]
fn mover_prefiere_camino_llano_sobre_subir_pendiente() {
// Dos vecinos atractivos por materia, uno además requiere subir
// una montaña (relieve = materia, climb_cost alto). El lemming
// debe elegir el llano.
let mut w = World::new(16, 16);
let i = w.lemmings.spawn(8.0, 8.0, 100.0, [1.0, 0.0, 0.0, 0.0]);
// Materia idéntica a ambos lados de la celda actual.
let right = w.grid.idx(9, 8);
let left = w.grid.idx(7, 8);
w.grid.materia[right] = 50.0;
w.grid.materia[left] = 50.0;
// Pero al subir a la derecha estamos sobre un pico alto.
// Como `relieve = materia`, el right_idx tiene z=50; el left_idx tiene
// z=50 también. Para forzar pendiente asimétrica subimos sólo la
// derecha:
w.grid.materia[right] = 200.0; // pico mucho mayor
let mut p = SimParams::default();
p.climb_cost = 10.0; // pendiente brutalmente cara
let x0 = w.lemmings.pos_x[i];
w.act_mover(i, &p);
// El pico está a la derecha; con climb_cost = 10 cuesta demasiado.
// Cualquier movimiento que NO sea hacia +x está bien (izq, arriba
// o abajo son todos llanos).
assert!(w.lemmings.pos_x[i] <= x0, "no fue hacia el pico de la derecha");
}
#[test]
fn mover_cobra_energia_extra_por_subir() {
let mut w = World::new(8, 8);
let i = w.lemmings.spawn(4.0, 4.0, 100.0, [1.0, 0.0, 0.0, 0.0]);
// Pico a la derecha.
let right = w.grid.idx(5, 4);
w.grid.materia[right] = 100.0;
// Caso A: climb_cost = 0 (sin penalty). Energy gastada = move_cost.
let mut p = SimParams::default();
p.climb_cost = 0.0;
w.act_mover(i, &p);
let after_flat = w.lemmings.energia[i];
let lost_flat = 100.0 - after_flat;
// Reset y repetir con climb_cost > 0.
let mut w2 = World::new(8, 8);
let j = w2.lemmings.spawn(4.0, 4.0, 100.0, [1.0, 0.0, 0.0, 0.0]);
let right2 = w2.grid.idx(5, 4);
w2.grid.materia[right2] = 100.0;
let mut p2 = SimParams::default();
p2.climb_cost = 0.5;
w2.act_mover(j, &p2);
let lost_climb = 100.0 - w2.lemmings.energia[j];
// Sin climb_cost, el agente puede ir igual al pico (porque la
// materia lo atrae mucho), pero pierde más energía cuando climb_cost
// > 0 porque paga la altura subida.
assert!(lost_climb > lost_flat, "subir con climb_cost > 0 cuesta más");
}
#[test]
fn intercambiar_conserves_total_energy() {
let mut w = World::new(16, 16);
w.lemmings.spawn(8.0, 8.0, 30.0, [0.0; 4]);
w.lemmings.spawn(9.0, 8.0, 30.0, [0.0; 4]);
let p = SimParams::default();
w.act_intercambiar(0, &p);
let total = w.lemmings.energia[0] + w.lemmings.energia[1];
assert!((total - 60.0).abs() < 1e-4, "la energía se conserva");
}
#[test]
fn intercambiar_poorest_donates_to_the_neediest() {
// Default: TradeTarget::Poorest. El trader (i=0) tiene E=50.
// El más cercano (i=1) está al lado pero tiene E=49. El más pobre
// (i=2) está lejos pero tiene E=5. Debe donar al pobre, no al
// cercano.
let mut w = World::new(20, 20);
w.lemmings.spawn(2.0, 2.0, 50.0, [0.0; 4]); // 0: trader
w.lemmings.spawn(3.0, 2.0, 49.0, [0.0; 4]); // 1: cercano, rico
w.lemmings.spawn(18.0, 18.0, 5.0, [0.0; 4]); // 2: lejos, pobre
let p = SimParams::default();
let before_close = w.lemmings.energia[1];
let before_poor = w.lemmings.energia[2];
w.act_intercambiar(0, &p);
assert_eq!(w.lemmings.energia[1], before_close, "no le tocó al cercano");
assert!(w.lemmings.energia[2] > before_poor, "le donó al pobre");
}
#[test]
fn intercambiar_nearest_preserves_legacy_behavior() {
// Con TradeTarget::Nearest, el comportamiento histórico se mantiene.
let mut w = World::new(20, 20);
w.lemmings.spawn(2.0, 2.0, 50.0, [0.0; 4]);
w.lemmings.spawn(3.0, 2.0, 49.0, [0.0; 4]); // cercano
w.lemmings.spawn(18.0, 18.0, 5.0, [0.0; 4]); // pobre lejos
let mut p = SimParams::default();
p.trade_target = TradeTarget::Nearest;
let before_close = w.lemmings.energia[1];
let before_poor = w.lemmings.energia[2];
w.act_intercambiar(0, &p);
assert!(w.lemmings.energia[1] > before_close, "le donó al cercano");
assert_eq!(w.lemmings.energia[2], before_poor, "no le tocó al pobre");
}
// ───────────────────────── Fase A: psi modula efectos ─────────────────
#[test]
fn psi_modulation_zero_preserves_legacy_act_extraer() {
// Con psi_effect_modulation = 0.0 el resultado es bit-exacto al motor
// histórico, sin importar qué psi tenga el agente. Esta es la
// garantía de retrocompat para todo el corpus de tests preexistentes.
let mut a = World::new(8, 8);
let mut b = World::new(8, 8);
let i = a.lemmings.spawn(4.0, 4.0, 100.0, [0.9, 0.0, 0.0, 0.9]);
let j = b.lemmings.spawn(4.0, 4.0, 100.0, [0.0, 0.0, 0.0, 0.0]);
let idx = a.grid.idx(4, 4);
a.grid.materia[idx] = 50.0;
b.grid.materia[idx] = 50.0;
let p = SimParams::default(); // psi_effect_modulation == 0
a.act_extraer(i, &p);
b.act_extraer(j, &p);
assert_eq!(a.lemmings.energia[i], b.lemmings.energia[j]);
assert_eq!(a.grid.materia[idx], b.grid.materia[idx]);
assert_eq!(a.grid.degradacion[idx], b.grid.degradacion[idx]);
}
#[test]
fn corruptible_extrae_mas_y_degrada_mas() {
// Dos agentes idénticos salvo CORRUPTIBILIDAD: el corrupto saca más
// materia (más energía propia) y deja más cicatriz en el suelo.
// Es la modulación canónica de Extraer.
let mut a = World::new(8, 8); // corrupto
let mut b = World::new(8, 8); // honesto
let i = a.lemmings.spawn(4.0, 4.0, 0.0, [0.0, 0.0, 0.0, 1.0]);
let j = b.lemmings.spawn(4.0, 4.0, 0.0, [0.0, 0.0, 0.0, 0.0]);
let idx = a.grid.idx(4, 4);
a.grid.materia[idx] = 100.0;
b.grid.materia[idx] = 100.0;
let mut p = SimParams::default();
p.psi_effect_modulation = 0.8;
a.act_extraer(i, &p);
b.act_extraer(j, &p);
assert!(
a.lemmings.energia[i] > b.lemmings.energia[j],
"corrupto sacó más: {} vs {}",
a.lemmings.energia[i], b.lemmings.energia[j]
);
assert!(
a.grid.degradacion[idx] > b.grid.degradacion[idx],
"corrupto dejó más cicatriz"
);
}
#[test]
fn miedoso_pega_menos_en_degradar() {
let mut a = World::new(8, 8); // miedoso
let mut b = World::new(8, 8); // valiente
a.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 1.0, 0.0, 0.0]); // MIEDO=1
a.lemmings.spawn(5.0, 4.0, 50.0, [0.0; 4]); // víctima
b.lemmings.spawn(4.0, 4.0, 50.0, [0.0; 4]); // valiente
b.lemmings.spawn(5.0, 4.0, 50.0, [0.0; 4]); // víctima
let mut p = SimParams::default();
p.psi_effect_modulation = 0.8;
let e_victima_pre = a.lemmings.energia[1];
a.act_degradar(0, &p);
b.act_degradar(0, &p);
let dmg_miedoso = e_victima_pre - a.lemmings.energia[1];
let dmg_valiente = e_victima_pre - b.lemmings.energia[1];
assert!(
dmg_miedoso < dmg_valiente,
"miedoso pega menos: {dmg_miedoso} < {dmg_valiente}"
);
}
#[test]
fn ordenado_comparte_mas_en_intercambiar() {
let mut a = World::new(8, 8); // ordenado
let mut b = World::new(8, 8); // neutral
a.lemmings.spawn(4.0, 4.0, 50.0, [1.0, 0.0, 0.0, 0.0]); // ORDEN=1
a.lemmings.spawn(5.0, 4.0, 1.0, [0.0; 4]); // pobre cercano
b.lemmings.spawn(4.0, 4.0, 50.0, [0.0; 4]);
b.lemmings.spawn(5.0, 4.0, 1.0, [0.0; 4]);
let mut p = SimParams::default();
p.psi_effect_modulation = 0.8;
p.trade_target = TradeTarget::Nearest; // forzamos al cercano para test reproducible
a.act_intercambiar(0, &p);
b.act_intercambiar(0, &p);
let donado_orden = a.lemmings.energia[1] - 1.0;
let donado_base = b.lemmings.energia[1] - 1.0;
assert!(
donado_orden > donado_base,
"ordenado donó más: {donado_orden} > {donado_base}"
);
}
#[test]
fn argmax_big5_se_reduce_a_big4_con_pesos_ext_cero() {
// Sanity: con `action_weights_ext = [0; 6]` y cualquier psi5,
// `select_action_argmax_big5` debe coincidir con la versión Big Four.
let weights = crate::params::SimParams::default().action_weights;
let weights_ext = [0.0f32; 6];
let psis = [
[0.0, 0.0, 0.0, 1.0],
[1.0, 0.0, 0.0, 0.0],
[0.5, 0.5, 0.5, 0.5],
];
for psi in &psis {
let a4 = select_action_argmax(psi, &weights);
for psi5 in [0.0, 0.5, 1.0] {
let a5 = select_action_argmax_big5(psi, psi5, &weights, &weights_ext);
assert_eq!(a4, a5, "psi {:?} psi5 {}", psi, psi5);
}
}
}
#[test]
fn argmax_big5_cambia_decision_cuando_extra_pesa() {
// Con un peso ext alto en Intercambiar (3) y psi5 = 1.0, un agente
// que en Big Four iría a Degradar (5) — psi=[0,0,0,1] — debería
// saltar a Intercambiar porque la 5ª columna lo empuja.
let mut weights_ext = [0.0f32; 6];
weights_ext[3] = 5.0; // empujamos fuerte a Intercambiar
let weights = crate::params::SimParams::default().action_weights;
let psi = [0.0, 0.0, 0.0, 1.0];
let psi5 = 1.0;
let a = select_action_argmax_big5(&psi, psi5, &weights, &weights_ext);
assert_eq!(a, 3, "el 5º peso debe ganarle a Degradar");
}
#[test]
fn replicar_hereda_psi5_del_padre() {
let mut w = World::new(8, 8);
let i = w.lemmings.spawn_big5(4.0, 4.0, 100.0, [0.5; 4], 0.73);
w.lemmings.accion[i] = 4;
let p = SimParams::default();
w.act_replicar(i, &p);
assert_eq!(w.lemmings.len(), 2);
assert!((w.lemmings.psi5_at(1) - 0.73).abs() < 1e-5, "hijo {} != 0.73", w.lemmings.psi5_at(1));
}
#[test]
fn argmax_picks_action_with_highest_psi_dot_weights() {
// psi puro en CORRUPTIBILIDAD → con los pesos por default, la
// acción ganadora es Extraer (peso 0.8) o Degradar (peso 1.0). Como
// Degradar tiene mayor peso para CORRUPTIBILIDAD, gana.
let weights = crate::params::SimParams::default().action_weights;
let psi = [0.0, 0.0, 0.0, 1.0];
assert_eq!(select_action_argmax(&psi, &weights), 5);
// psi puro en CURIOSIDAD → Mover (1.0) y Sincronizar (1.0) empatan
// → gana el menor índice = Mover (0).
let psi = [0.0, 0.0, 1.0, 0.0];
assert_eq!(select_action_argmax(&psi, &weights), 0);
// psi puro en ORDEN → Intercambiar (1.0) y Replicar (1.0) empatan
// → gana el menor índice = Intercambiar (3).
let psi = [1.0, 0.0, 0.0, 0.0];
assert_eq!(select_action_argmax(&psi, &weights), 3);
}
}
@@ -0,0 +1,394 @@
//! Generación procedural del mundo: PRNG, ruido fbm, ríos y el [`seed`] que
//! esculpe biomas y reparte Lemmings sobre tierra firme.
//!
//! Motor agnóstico de GUI (regla #2): extraído de `dominium-app-llimphi`,
//! que ahora sólo envuelve [`seed`] pasándole sus dimensiones de grilla, su
//! población de Lemmings y el pack de [`Conceptos`] (default o del usuario).
//! No conoce frontends ni paletas de render.
use crate::{Conceptos, World};
// ---------------------------------------------------------------------
// PRNG mínimo (LCG 64) — siembra reproducible sin dependencias.
// ---------------------------------------------------------------------
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);
// Shift por 32 (no 33): los 32 bits altos del LCG son los de mejor
// calidad, y `as u32` los toma sin perder el bit 31. La versión
// anterior usaba `>> 33`, dejando un resultado en `[0, 2^31)` →
// `next_f32()` retornaba `[0, 0.5)` y todo el mundo era mar.
(self.0 >> 32) as u32
}
fn next_f32(&mut self) -> f32 {
(self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
}
}
/// Esculpe un río senoidal entre `(x0, y0)` y el borde opuesto, pintando
/// `psique` alta y limpiando `materia` a lo largo del trazo. El río tiene
/// ancho `width` celdas y serpentea con amplitud `wiggle` perpendicular al
/// rumbo. La curva se muestrea a paso unitario.
fn carve_river(w: &mut World, rng: &mut Lcg, vertical: bool, length: usize, width: f32, wiggle: f32) {
let g_w = w.grid.width as f32;
let g_h = w.grid.height as f32;
let start = rng.next_f32() * if vertical { g_w } else { g_h };
let phase = rng.next_f32() * core::f32::consts::TAU;
let freq = 0.06 + rng.next_f32() * 0.05;
for s in 0..length {
let t = s as f32;
let bend = libm::sinf(t * freq + phase) * wiggle;
let (cx_f, cy_f) = if vertical {
(start + bend, t * g_h / length as f32)
} else {
(t * g_w / length as f32, start + bend)
};
let r = width.ceil() as i64;
for dy in -r..=r {
for dx in -r..=r {
let x = cx_f + dx as f32;
let y = cy_f + dy as f32;
if x < 0.0 || y < 0.0 || x >= g_w || y >= g_h {
continue;
}
let d = libm::sqrtf((dx as f32).powi(2) + (dy as f32).powi(2));
if d > width {
continue;
}
let intensity = 1.0 - d / width;
let idx = w.grid.idx(x as usize, y as usize);
// Río = mucha psique (agua azul), nada de materia, sin oro.
w.grid.psique[idx] = (w.grid.psique[idx] + 130.0 * intensity).min(180.0);
w.grid.materia[idx] *= 1.0 - intensity * 0.95;
w.grid.oro[idx] *= 1.0 - intensity * 0.8;
w.grid.poder[idx] *= 1.0 - intensity * 0.8;
w.grid.degradacion[idx] *= 1.0 - intensity * 0.9;
}
}
}
}
/// Value noise multioctava determinista. Devuelve `Vec<f32>` de tamaño
/// `w*h` con valores aproximadamente en `[-1, 1]`. Las octavas suben en
/// frecuencia y bajan en amplitud — la primera define continentes, las
/// últimas, granulado. Smoothstep `s(t) = t²(3-2t)` entre celdas coarse.
fn fbm_noise(seed: u64, w: usize, h: usize) -> Vec<f32> {
let mut rng = Lcg::new(seed);
let mut field = vec![0.0_f32; w * h];
// (frecuencia, amplitud). 4 octavas: 6×6 continentes → 96×96 ruido fino.
let octaves: [(usize, f32); 4] = [(6, 1.0), (12, 0.55), (24, 0.30), (96, 0.18)];
let mut amp_norm = 0.0_f32;
for (_, a) in &octaves {
amp_norm += a;
}
for (n, amp) in octaves {
// Grilla coarse (n+1)×(n+1) de valores aleatorios en [-1, 1].
let coarse_w = n + 1;
let mut coarse = vec![0.0_f32; coarse_w * coarse_w];
for v in coarse.iter_mut() {
*v = rng.next_f32() * 2.0 - 1.0;
}
let sx = n as f32 / w as f32;
let sy = n as f32 / h as f32;
for y in 0..h {
for x in 0..w {
let fx = x as f32 * sx;
let fy = y as f32 * sy;
let cx = (fx.floor() as usize).min(n - 1);
let cy = (fy.floor() as usize).min(n - 1);
let tx = (fx - cx as f32).clamp(0.0, 1.0);
let ty = (fy - cy as f32).clamp(0.0, 1.0);
let smooth = |a: f32| a * a * (3.0 - 2.0 * a);
let u = smooth(tx);
let v = smooth(ty);
let a = coarse[cy * coarse_w + cx];
let b = coarse[cy * coarse_w + cx + 1];
let c = coarse[(cy + 1) * coarse_w + cx];
let d = coarse[(cy + 1) * coarse_w + cx + 1];
let p = a * (1.0 - u) + b * u;
let q = c * (1.0 - u) + d * u;
field[y * w + x] += amp * (p * (1.0 - v) + q * v);
}
}
}
for v in field.iter_mut() {
*v /= amp_norm;
}
field
}
/// Siembra un mundo cuadrado `grid × grid`: continentes de materia, vetas de
/// oro, niebla de psique y una población de `lemmings` Lemmings con sesgos y
/// acciones variadas. Los `conceptos` (default embebido o pack del usuario)
/// se asignan al mundo resultante — el caller decide cuáles.
pub fn seed(seed: u64, grid: usize, lemmings: usize, conceptos: Conceptos) -> World {
let mut w = World::new(grid, grid);
let mut rng = Lcg::new(seed);
// --- Capas iniciales basadas en dos campos fbm independientes ---
// `elev` ∈ ~[-1, 1] decide bioma; `humid` ∈ ~[-1, 1] modula fertilidad.
let elev = fbm_noise(seed ^ 0xE1E_7A57, grid, grid);
let humid = fbm_noise(seed ^ 0x4D015_7CE, grid, grid);
for cy in 0..grid {
for cx in 0..grid {
let idx = w.grid.idx(cx, cy);
let e_raw = elev[idx];
let h = humid[idx];
// Forma del continente:
// - bias +0.25 → tierra domina globalmente.
// - edge_drop · 0.30 → costas/bordes en mar.
// E[edge_drop] = 2/3 en una grilla uniforme → mean(e) ≈ 0.05,
// con FBM std ≈ 0.24 da ~35% mar, ~65% tierra.
let nx = (cx as f32 / grid as f32) * 2.0 - 1.0;
let ny = (cy as f32 / grid as f32) * 2.0 - 1.0;
let edge_drop = nx.abs().max(ny.abs());
let e = e_raw + 0.30 - edge_drop * 0.28;
if e < -0.18 {
// Mar profundo: psique alta para que el azul aguante la
// difusión lenta (entropy=0.005, diffusion=0.02 → unos cientos
// de ticks antes de notarse erosión visual). Pintar también
// `degradacion` baja persistente refuerza el tono frío y
// ancla la celda como "no fértil" para los lemmings que la
// crucen.
w.grid.psique[idx] = 180.0 + rng.next_f32() * 30.0;
w.grid.degradacion[idx] = 2.0;
} else if e < -0.05 {
// Mar somero / lagunas: agua más clara, mínima vida acuática.
w.grid.psique[idx] = 110.0 + rng.next_f32() * 20.0;
w.grid.materia[idx] = rng.next_f32() * 4.0;
w.grid.degradacion[idx] = 1.0;
} else if e < 0.08 {
// Costa / pantano fértil: alta materia + algo de agua.
w.grid.materia[idx] = 45.0 + (h.max(0.0)) * 30.0 + rng.next_f32() * 6.0;
w.grid.psique[idx] = 18.0 + rng.next_f32() * 8.0;
if rng.next_f32() > 0.94 {
w.grid.oro[idx] = rng.next_f32() * 18.0;
}
} else if e < 0.30 {
// Llanura: el granero del mundo. Materia muy alta cuando
// hay humedad; menos donde el clima es seco.
let fertility = (h * 0.5 + 0.5).clamp(0.2, 1.0);
w.grid.materia[idx] = 50.0 + fertility * 50.0 + rng.next_f32() * 5.0;
if rng.next_f32() > 0.92 {
w.grid.oro[idx] = rng.next_f32() * 24.0;
}
} else if e < 0.42 {
// Colinas: materia decreciente, asoma el poder (vetas).
let alpha = (e - 0.30) / 0.12;
w.grid.materia[idx] = (1.0 - alpha) * 35.0 + rng.next_f32() * 4.0;
w.grid.poder[idx] = alpha * 9.0;
if rng.next_f32() > 0.82 {
w.grid.oro[idx] = rng.next_f32() * 30.0; // minas en colinas
}
} else {
// Montañas / picos: poco material vivo, mucha estructura
// bruta (poder) y, en los más altos, cicatriz rocosa. Umbral
// bajado a 0.42 (en la cola del FBM con mean ≈ +0.08) para
// que ~10% del mapa sea cordillera visible.
let alpha = ((e - 0.42) / 0.40).clamp(0.0, 1.0);
w.grid.poder[idx] = 6.0 + alpha * 18.0;
w.grid.degradacion[idx] = 1.5 + alpha * alpha * 14.0;
if rng.next_f32() > 0.97 {
w.grid.oro[idx] = rng.next_f32() * 35.0;
}
}
}
}
// --- Ríos: 2 cruces. Uno vertical, uno horizontal. Sin erosión real
// — los ríos se pintan encima del bioma sobrescribiendo. ---
carve_river(&mut w, &mut rng, true, grid, 2.4, grid as f32 * 0.18);
carve_river(&mut w, &mut rng, false, grid, 1.8, grid as f32 * 0.14);
// --- Lemmings: distribuidos solo en tierra firme (e ∈ [-0.05, 0.45]).
// Rechaza candidatos en mar o pico. Si tras 32 intentos no encuentra
// un punto válido, suelta donde caiga (failsafe para no congelar el
// seed). ---
let pick_land = |rng: &mut Lcg, elev: &[f32]| -> (f32, f32) {
for _ in 0..64 {
let x = rng.next_f32() * (grid as f32 - 1.0);
let y = rng.next_f32() * (grid as f32 - 1.0);
let nx = (x / grid as f32) * 2.0 - 1.0;
let ny = (y / grid as f32) * 2.0 - 1.0;
let edge_drop = nx.abs().max(ny.abs());
// Misma transformación que el biomeing arriba, así los
// lemmings caen en celdas-tierra coherentes.
let e = elev[(y as usize) * grid + (x as usize)] + 0.30 - edge_drop * 0.28;
if e > -0.05 && e < 0.45 {
return (x, y);
}
}
(
rng.next_f32() * (grid as f32 - 1.0),
rng.next_f32() * (grid as f32 - 1.0),
)
};
for k in 0..lemmings {
let (x, y) = pick_land(&mut rng, &elev);
let psi = [
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
];
let i = w.lemmings.spawn(x, y, 40.0 + rng.next_f32() * 40.0, psi);
// Distribución calibrada al punto fijo del sistema con herencia
// de acción + intercambio fuerte (trade_amount = 1.5):
// α_e = 0.30 (Extraer · cosecha — fuente principal de E)
// α_t = 0.30 (Intercambiar · redistribución — evita concentración)
// α_m = 0.20 (Mover · exploración)
// α_r = 0.15 (Replicar · natalidad)
// α_s = 0.05 (Sincronizar · convergencia cultural)
//
// Balance energético por capita en equilibrio:
// dE/dt = α_e · e_r - α_m · c_m - α_r · f · E_r · 1[E_r>T]
// = 0.30·2.5 - 0.20·0.06 - 0.15·0.45·E_r
// = 0.738 - 0.0675·E_r
// E* = 0.738 / 0.0675 ≈ 11 (cerca del threshold T=12)
// El sistema oscila alrededor de ese E*, replicando a baja
// frecuencia pero sostenidamente.
w.lemmings.accion[i] = match k % 20 {
0..=5 => 1, // 6/20 = 0.30 Extraer
6..=11 => 3, // 6/20 = 0.30 Intercambiar
12..=15 => 0, // 4/20 = 0.20 Mover
16..=18 => 4, // 3/20 = 0.15 Replicar
_ => 2, // 1/20 = 0.05 Sincronizar
} as u8;
}
w.conceptos = conceptos;
w
}
#[cfg(test)]
mod seeding_tests {
//! Tests del seeding del mundo. No verifican la física (eso ya está en
//! `dominium-core` / `dominium-physics`), sólo que la distribución de
//! biomas tras `seed()` queda en proporciones razonables: ni todo mar
//! ni todo montaña.
use super::*;
use crate::Conceptos;
// Grilla y población de prueba (espejan los consts de la app:
// GRID = 240, LEMMINGS = 2500) para que los rangos esperados valgan.
const GRID: usize = 240;
const LEMMINGS: usize = 2500;
fn seed_demo(s: u64) -> World {
seed(s, GRID, LEMMINGS, Conceptos::default())
}
/// Clasificación de bioma a partir de las capas de una celda. Espeja
/// los thresholds de `seed()` para validar lo que efectivamente quedó
/// pintado.
fn classify_cell(g: &crate::Grid, idx: usize) -> &'static str {
// Mar profundo: mucha psique y nada de materia/poder.
if g.psique[idx] > 150.0 && g.materia[idx] < 1.0 {
"mar_profundo"
} else if g.psique[idx] > 80.0 && g.materia[idx] < 6.0 {
"mar_somero"
} else if g.psique[idx] > 15.0 && g.psique[idx] <= 80.0 && g.materia[idx] > 30.0 {
"costa"
} else if g.materia[idx] > 40.0 && g.poder[idx] < 0.5 {
"llanura"
} else if g.poder[idx] >= 0.5 && g.poder[idx] < 8.0 {
"colina"
} else if g.poder[idx] >= 8.0 || g.degradacion[idx] > 4.0 {
"pico"
} else {
"otro"
}
}
/// Sanity: el LCG genera valores uniformes en [-1, 1] (esta función
/// hubiera capturado el bug `>> 33` original donde la mean era -0.5).
#[test]
fn lcg_genera_distribucion_simetrica() {
let mut rng = Lcg::new(1234);
let mut sum = 0.0_f64;
let n = 100_000;
for _ in 0..n {
sum += (rng.next_f32() * 2.0 - 1.0) as f64;
}
let mean = sum / n as f64;
assert!(
mean.abs() < 0.02,
"LCG sesgado: mean = {mean:.4} (debe estar cerca de 0)"
);
}
#[test]
fn seed_default_balances_biomas() {
let w = seed_demo(0xD0_31_31_07);
let total = w.grid.cells();
let mut hist = std::collections::HashMap::<&'static str, usize>::new();
for i in 0..total {
*hist.entry(classify_cell(&w.grid, i)).or_default() += 1;
}
let pct = |k: &str| -> f32 {
*hist.get(k).unwrap_or(&0) as f32 / total as f32 * 100.0
};
let mar = pct("mar_profundo") + pct("mar_somero");
// El mar no debe dominar el mapa visualmente (versión anterior daba
// ~50% mar y al usuario "todo se ve azul al inicio").
assert!(
mar < 40.0,
"mar < 40% del mapa, fue {:.1}% — el bias continental no está empujando suficiente tierra",
mar
);
// Y al menos hay mar — sin mar no hay distinción agua/tierra.
assert!(mar > 10.0, "mar > 10%, fue {:.1}% — el mapa quedó casi sin agua", mar);
// La tierra incluye llanura (la mayoría de los lemmings vive ahí).
assert!(
pct("llanura") > 18.0,
"llanura > 18%, fue {:.1}% — sin granero el motor se ahoga",
pct("llanura")
);
// Picos visibles pero no dominantes (la versión anterior daba el
// mapa "casi plano").
let pico = pct("pico");
assert!(
(5.0..28.0).contains(&pico),
"pico ∈ [5, 28]%, fue {:.1}% — cordillera fuera de rango",
pico
);
}
#[test]
fn lemmings_no_se_acumulan_en_un_cuadrante() {
let w = seed_demo(0xD0_31_31_07);
// Reparto por cuadrante.
let mut q = [0_u32; 4];
for i in 0..w.lemmings.len() {
let x = w.lemmings.pos_x[i];
let y = w.lemmings.pos_y[i];
let h = GRID as f32 / 2.0;
let qi = match (x >= h, y >= h) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
q[qi] += 1;
}
let total = w.lemmings.len() as u32;
// Ningún cuadrante > 75% de la población (versión anterior tenía
// seeds donde el continente caía en un solo cuadrante y todos los
// lemmings se apilaban ahí).
for (i, &n) in q.iter().enumerate() {
let pct = n as f32 / total as f32 * 100.0;
assert!(
pct < 75.0,
"cuadrante {i} concentra {pct:.1}% de los lemmings — el bias continental + center_lift no está dispersando bien"
);
}
}
}