feat(dominium): dominium-core — núcleo del simulador de campo medio

- grid — el Sustrato Plano: grilla SoA de 5 capas f32 (materia, psique,
  poder, oro, degradación), indexada y*width+x.
- lemmings — Agentes Vectoriales en SoA: pos_x/y, edad, energia,
  vector_psi [Orden,Miedo,Curiosidad,Corruptibilidad], accion u8.
  spawn / swap_remove / nearest (determinista, empate por menor índice).
- world — World + las 6 acciones atómicas fijas: Mover (gravedad mental
  hacia el vecino más afín al psi), Extraer, Sincronizar, Intercambiar,
  Replicar, Degradar. step_lemming despacha por el byte accion.
- params — SimParams (las constantes que los sliders del panel ajustan).

Cero deps gráficas — sólo serde (regla inviolable de la spec).
11 tests verdes (acciones verificadas: Mover sigue la materia, Extraer
degrada, Replicar engendra, Intercambiar conserva energía, etc.).
cargo check --workspace verde.

Pendiente dominium: physics (difusión/entropía/cinemática), iso,
render-plan, canvas/panel GPUI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 16:01:42 +00:00
parent 191e6b06e1
commit d1727b1374
8 changed files with 544 additions and 0 deletions
@@ -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,119 @@
//! 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;
/// 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>,
}
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.
pub fn spawn(&mut self, x: f32, y: f32, energia: f32, psi: [f32; 4]) -> 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);
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);
}
/// 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)
}
}
#[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 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,27 @@
//! `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.
//!
//! 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 grid;
pub mod lemmings;
pub mod params;
pub mod world;
pub use grid::Grid;
pub use lemmings::Lemmings;
pub use params::SimParams;
pub use world::{Action, World};
@@ -0,0 +1,54 @@
//! 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};
/// 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,
/// Edad máxima; al superarla el agente muere.
pub max_edad: u32,
}
impl Default for SimParams {
fn default() -> Self {
Self {
move_speed: 1.0,
move_cost: 0.10,
extract_rate: 1.0,
degr_per_extract: 0.05,
sync_rate: 0.10,
trade_amount: 0.50,
replicate_threshold: 50.0,
child_energy_frac: 0.30,
fight_damage: 5.0,
absorb_frac: 0.50,
desperation_threshold: 5.0,
max_edad: 1000,
}
}
}
@@ -0,0 +1,232 @@
//! 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::grid::Grid;
use crate::lemmings::{Lemmings, PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_MIEDO, PSI_ORDEN};
use crate::params::SimParams;
use serde::{Deserialize, Serialize};
/// 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,
}
impl World {
pub fn new(width: usize, height: usize) -> Self {
Self { grid: Grid::new(width, height), lemmings: Lemmings::new() }
}
/// 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)
}
/// 0 · Mover — gravedad mental hacia el vecino más afín al `vector_psi`.
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 mut best_dir = (0.0f32, 0.0f32);
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 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];
if score > best_score {
best_score = score;
best_dir = (dx as f32, dy as f32);
}
}
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);
self.lemmings.energia[i] -= p.move_cost;
}
/// 1 · Extraer — vacía materia de la celda hacia la energía del agente.
pub fn act_extraer(&mut self, i: usize, p: &SimParams) {
let idx = self.cell_of(i);
let taken = self.grid.materia[idx].min(p.extract_rate).max(0.0);
self.grid.materia[idx] -= taken;
self.lemmings.energia[i] += taken;
self.grid.degradacion[idx] += p.degr_per_extract;
}
/// 2 · Sincronizar — el `vector_psi` deriva hacia los campos de la celda.
pub fn act_sincronizar(&mut self, i: usize, p: &SimParams) {
let idx = self.cell_of(i);
let targets = [
self.grid.psique[idx],
self.grid.poder[idx],
self.grid.psique[idx],
self.grid.poder[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 al vecino más cercano.
pub fn act_intercambiar(&mut self, i: usize, p: &SimParams) {
let Some(j) = self.lemmings.nearest(i) else { return };
let amount = p.trade_amount.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 las mismas coordenadas.
pub fn act_replicar(&mut self, i: usize, p: &SimParams) {
if self.lemmings.energia[i] <= p.replicate_threshold {
return;
}
let cost = self.lemmings.energia[i] * p.child_energy_frac;
self.lemmings.energia[i] -= cost;
let (x, y) = (self.lemmings.pos_x[i], self.lemmings.pos_y[i]);
let psi = self.lemmings.vector_psi[i];
self.lemmings.spawn(x, y, cost, psi);
}
/// 5 · Degradar (Pelear) — resta energía al vecino y absorbe parte.
pub fn act_degradar(&mut self, i: usize, p: &SimParams) {
let Some(j) = self.lemmings.nearest(i) else { return };
let dmg = p.fight_damage.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`.
pub fn step_lemming(&mut self, i: usize, p: &SimParams) {
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 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 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");
}
}