feat: dominium standalone — simulador de campo medio sobre Llimphi
Front-door publicable de dominium: los 9 crates propios como path members; Llimphi, app-bus, rimay-localize, wawa-config y pluma-notebook por git-dep al monorepo tawasuyu.git (branch=main). cargo check --workspace --all-targets pasa exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 }
|
||||
@@ -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 30–80: 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 / (1−p) ) / σ_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user