feat: dominium standalone — simulador de campo medio sobre Llimphi

Front-door publicable de dominium: los 9 crates propios como path
members; Llimphi, app-bus, rimay-localize, wawa-config y pluma-notebook
por git-dep al monorepo tawasuyu.git (branch=main). cargo check
--workspace --all-targets pasa exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 23:22:40 +00:00
commit 1860b51f70
70 changed files with 19902 additions and 0 deletions
@@ -0,0 +1,12 @@
[package]
name = "dominium-sim"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium — sesión de simulación agnóstica de GUI: posee el World + parámetros + reloj (tick/epoch/seed) + ring de snapshots (rewind) + trails + clusters k-means, y orquesta el avance/reseed/colapso. El frontend sólo guarda estado de vista (cámara, selección, paneles) y delega el ciclo de vida acá."
[dependencies]
dominium-core = { path = "../dominium-core" }
dominium-physics = { path = "../dominium-physics" }
+234
View File
@@ -0,0 +1,234 @@
//! Sesión de simulación de dominium — el estado de dominio y su ciclo de
//! vida, separados del frontend (regla #2).
//!
//! [`Sim`] posee el `World`, sus `SimParams`, el reloj (`tick`/`epoch`/
//! `rng_seed`), el ring de snapshots para rebobinar, los trails de posiciones
//! y las asignaciones de cluster k-means. Orquesta el avance del motor
//! (`dominium-physics`), el reseed manual y el reseed automático cuando la
//! población colapsa. El frontend (cámara, selección, render, paneles, menús)
//! sólo guarda estado de vista y delega el ciclo de vida acá — antes todo
//! esto vivía mezclado en el `Model` de `dominium-app-llimphi`.
use std::collections::VecDeque;
use dominium_core::{kmeans_psi, SimParams, World};
use dominium_physics::tick;
/// Cómo re-sembrar el mundo a partir de una semilla. El frontend la provee
/// (típicamente cargando su pack de Conceptos del disco), así `Sim` no
/// conoce ni el tamaño de grilla ni la IO de packs.
pub type Seeder = Box<dyn FnMut(u64) -> World>;
/// Sesión de simulación: estado de dominio + reloj + historia.
pub struct Sim {
pub world: World,
pub params: SimParams,
/// Si el motor avanza en cada [`Sim::step`]. El frontend lo togglea.
pub running: bool,
pub tick: u64,
pub epoch: u64,
pub rng_seed: u64,
/// Ring de snapshots del `World` — el último es el más reciente. Ver
/// [`Sim::displayed_world`] para la semántica de `rewind_offset`.
pub snapshots: VecDeque<World>,
/// Cuántos pasos atrás mira el usuario. `0` = presente (vivo).
pub rewind_offset: usize,
/// Para cada frame reciente, las posiciones `(x, y)` de los lemmings
/// vivos. `trails[k]` es el frame `tick - (len-1-k)`.
pub trails: VecDeque<Vec<(f32, f32)>>,
/// Asignación k-means → cluster por lemming (modo PsiCluster).
pub cluster_assignments: Vec<u8>,
/// Tick del último refresh de clusters (gated refresh).
pub cluster_last_refresh: u64,
snapshot_cap: usize,
trail_cap: usize,
kmeans_refresh_ticks: u64,
seeder: Seeder,
}
impl Sim {
/// Arranca una sesión con un `World` ya sembrado, sus parámetros, la
/// semilla del PRNG de reseed, las capacidades de los rings, el periodo
/// de refresh de k-means y el `seeder` que produce mundos nuevos.
#[allow(clippy::too_many_arguments)]
pub fn new(
world: World,
params: SimParams,
rng_seed: u64,
snapshot_cap: usize,
trail_cap: usize,
kmeans_refresh_ticks: u64,
running: bool,
seeder: Seeder,
) -> Self {
Self {
world,
params,
running,
tick: 0,
epoch: 0,
rng_seed,
snapshots: VecDeque::with_capacity(snapshot_cap),
rewind_offset: 0,
trails: VecDeque::with_capacity(trail_cap),
cluster_assignments: Vec::new(),
cluster_last_refresh: 0,
snapshot_cap,
trail_cap,
kmeans_refresh_ticks,
seeder,
}
}
/// Un paso de simulación; re-siembra si la población colapsa. Captura
/// también el snapshot del estado y el frame de trails (después de
/// avanzar, así el "presente" siempre coincide con `world`). Recalcula
/// los clusters sólo si `needs_clusters` y pasó el periodo de refresh —
/// el frontend pasa `true` cuando el render lo necesita (modo PsiCluster).
pub fn advance(&mut self, needs_clusters: bool) {
tick(&mut self.world, &self.params);
self.tick += 1;
if self.world.lemmings.is_empty() {
self.epoch += 1;
self.rng_seed = self.rng_seed.wrapping_mul(2862933555777941757).wrapping_add(1);
self.world = (self.seeder)(self.rng_seed);
self.tick = 0;
self.snapshots.clear();
self.trails.clear();
self.cluster_assignments.clear();
}
self.push_snapshot();
self.push_trail_frame();
if needs_clusters
&& self.tick.saturating_sub(self.cluster_last_refresh) >= self.kmeans_refresh_ticks
{
self.refresh_clusters();
}
}
/// Reseed manual: nueva semilla, mundo fresco, reloj a cero y rings
/// limpios. Vuelve al presente (`rewind_offset = 0`).
pub fn reseed(&mut self) {
self.rng_seed = self.rng_seed.wrapping_add(0x9E37_79B9);
self.world = (self.seeder)(self.rng_seed);
self.tick = 0;
self.epoch += 1;
self.snapshots.clear();
self.trails.clear();
self.rewind_offset = 0;
}
/// Recalcula `cluster_assignments` desde el `World` actual. Si
/// `kmeans_psi` devuelve `None` (pob < K), limpia las asignaciones.
pub fn refresh_clusters(&mut self) {
if let Some(km) = kmeans_psi(&self.world) {
self.cluster_assignments = km.assignments;
} else {
self.cluster_assignments.clear();
}
self.cluster_last_refresh = self.tick;
}
/// Empuja el `World` actual al ring (clone barato: SoA + Vec). Drop del
/// más viejo al exceder la capacidad.
pub fn push_snapshot(&mut self) {
if self.snapshots.len() == self.snapshot_cap {
self.snapshots.pop_front();
}
self.snapshots.push_back(self.world.clone());
}
/// Empuja el frame de posiciones de todos los lemmings vivos al ring.
pub fn push_trail_frame(&mut self) {
if self.trails.len() == self.trail_cap {
self.trails.pop_front();
}
let lem = &self.world.lemmings;
let frame: Vec<(f32, f32)> = (0..lem.len()).map(|i| (lem.pos_x[i], lem.pos_y[i])).collect();
self.trails.push_back(frame);
}
/// Devuelve el `World` que actualmente se está mostrando — el presente
/// (`world`) si no hay rewind, o el snapshot apropiado si lo hay.
pub fn displayed_world(&self) -> &World {
if self.rewind_offset == 0 || self.snapshots.is_empty() {
&self.world
} else {
let len = self.snapshots.len();
let idx = len.saturating_sub(1 + self.rewind_offset);
&self.snapshots[idx]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dominium_core::World;
// Seeder de prueba: un mundo chico determinista vacío de lemmings vivos
// sólo cuando lo pidamos. Para la mayoría de los tests no colapsa.
fn poblar(w: &mut World) {
for k in 0..8 {
w.lemmings.spawn(k as f32 % 4.0, 0.0, 50.0, [0.5; 4]);
}
}
fn sim_demo() -> Sim {
// Mundo mínimo 4×4 con algunos lemmings para que no colapse al toque.
let mut w = World::new(4, 4);
poblar(&mut w);
let seeder: Seeder = Box::new(|_s| {
let mut w = World::new(4, 4);
poblar(&mut w);
w
});
Sim::new(w, SimParams::default(), 0xC0FFEE, 4, 3, 30, true, seeder)
}
#[test]
fn advance_incrementa_tick_y_captura_historia() {
let mut s = sim_demo();
s.advance(false);
assert_eq!(s.tick, 1);
assert_eq!(s.snapshots.len(), 1);
assert_eq!(s.trails.len(), 1);
}
#[test]
fn rings_respetan_su_capacidad() {
let mut s = sim_demo();
for _ in 0..10 {
s.advance(false);
}
assert!(s.snapshots.len() <= 4, "snapshot ring capado");
assert!(s.trails.len() <= 3, "trail ring capado");
}
#[test]
fn reseed_resetea_reloj_y_vuelve_al_presente() {
let mut s = sim_demo();
s.advance(false);
s.advance(false);
s.rewind_offset = 1;
s.reseed();
assert_eq!(s.tick, 0);
assert_eq!(s.rewind_offset, 0);
assert!(s.snapshots.is_empty());
assert_eq!(s.epoch, 1);
}
#[test]
fn displayed_world_sigue_el_rewind() {
let mut s = sim_demo();
s.advance(false);
s.advance(false);
// presente
s.rewind_offset = 0;
assert!(std::ptr::eq(s.displayed_world(), &s.world));
// pasado: devuelve un snapshot, no el world vivo
s.rewind_offset = 1;
assert!(!std::ptr::eq(s.displayed_world(), &s.world));
}
}