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,361 @@
//! Plataforma de hipótesis canónicas — el cuerpo experimental de dominium.
//!
//! Cada hipótesis es una **aserción cuantitativa** sobre el motor: "si encendés
//! X, esperás que Y suba/baje/no cambie". Acá las codificamos como tests
//! Monte Carlo: corremos N réplicas con seeds distintos, calculamos la media
//! del estadístico y comparamos contra la rama de control con tolerancia
//! holgada (el simulador es determinista por seed pero ruidoso entre seeds).
//!
//! No son tests de "no rompe" (los `--lib` ya cubren eso). Son **falsadores
//! de fenómenos emergentes**: si alguien rompe la mecánica del contagio o de
//! la homofilia, estos tests caen aunque el binario compile.
//!
//! Convención: cada hipótesis vive en un test con nombre `hipotesis_*`. El
//! nombre describe la causalidad esperada ("homofilia_sube_morans_i"). El
//! cuerpo monta dos configuraciones — control y tratamiento — corre N
//! réplicas con seeds distintos y reporta la estadística agregada con
//! `assert!(...)` sobre la diferencia de medias.
use dominium_core::{
ActionPolicy, PsiMetrics, SimParams, World, WorldStats, MORANS_RADIUS_DEFAULT,
};
use dominium_physics::tick::run;
/// LCG mínimo determinista — el mismo que usa la app, pero local al test
/// para no acoplar a sus internals.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u32(&mut self) -> u32 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.0 >> 33) as u32
}
fn next_f32(&mut self) -> f32 {
(self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
}
}
/// Cantidad de réplicas Monte Carlo. 8 es suficiente para distinguir efectos
/// macroscópicos en estos sistemas; subir si las medias quedan cerca.
const MC_REPS: usize = 8;
/// Lado de la grilla cuadrada usada en los experimentos.
const GRID: usize = 40;
/// Población inicial por experimento. Suficientemente grande para que las
/// métricas tengan señal, suficientemente chica para que 8 réplicas × 200
/// ticks no demoren más de un par de segundos.
const POP: usize = 200;
/// Pasos de simulación por réplica. 200 alcanza para que el contagio sature
/// y la polarización converja en sus regímenes característicos.
const STEPS: usize = 200;
/// Construye un mundo con `POP` lemmings dispersos uniformemente y psi
/// también uniforme en `[0, 1]`. El seed controla *todo* el ruido: misma
/// seed → misma poblacion → mismo trayectoria.
fn build_world(seed: u64) -> World {
let mut w = World::new(GRID, GRID);
let mut rng = Lcg::new(seed);
// Pequeña materia uniforme para que los Extractores no se mueran.
for c in w.grid.materia.iter_mut() {
*c = 5.0;
}
for _ in 0..POP {
let x = rng.next_f32() * (GRID as f32 - 1.0);
let y = rng.next_f32() * (GRID as f32 - 1.0);
let psi = [
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
];
let i = w.lemmings.spawn_big5(x, y, 50.0, psi, rng.next_f32());
// Asignación de acciones balanceada para que no todos hagan lo mismo.
w.lemmings.accion[i] = (rng.next_u32() % 6) as u8;
}
w
}
/// Corre `steps` ticks y devuelve las métricas finales.
fn run_and_measure(w: &mut World, p: &SimParams, steps: usize) -> (PsiMetrics, WorldStats) {
run(w, p, steps);
(PsiMetrics::from_world(w), WorldStats::from_world(w))
}
/// Promedia una métrica escalar sobre `MC_REPS` réplicas.
fn mean_over_reps<F>(mut compute: F) -> f64
where
F: FnMut(u64) -> f32,
{
let mut sum: f64 = 0.0;
for r in 0..MC_REPS {
let seed = 0xD0_31_31_07u64.wrapping_add(r as u64 * 0x9E37_79B9);
sum += compute(seed) as f64;
}
sum / MC_REPS as f64
}
// ─────────────────────── Hipótesis 1 ────────────────────────
//
// **↑ homofilia ⇒ ↑ Moran's I.**
//
// Cuando la homofilia es fuerte, los agentes sólo se influyen con los
// psicológicamente parecidos. Las tribus emergen y se vuelven espacialmente
// segregadas → la autocorrelación espacial (Moran's I) del psi sube.
// Sin homofilia, el contagio universal homogeneiza y Moran's I tiende a 0.
//
// Estadístico: promedio de `moran_i[0]` (ORDEN) sobre 8 réplicas.
#[test]
fn hipotesis_homofilia_sube_morans_i() {
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.social_radius = 5.0;
p.contagion_rate = 0.15;
p.homophily_threshold = 0.0; // sin homofilia → contagio universal
let (m, _) = run_and_measure(&mut w, &p, STEPS);
m.moran_i[0].abs()
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.social_radius = 5.0;
p.contagion_rate = 0.15;
p.homophily_threshold = 0.4; // homofilia fuerte → tribus
let (m, _) = run_and_measure(&mut w, &p, STEPS);
m.moran_i[0].abs()
});
eprintln!(
"[H1] Moran_i[ORDEN]: baseline {:.4} vs homofilia {:.4}",
mean_baseline, mean_treatment
);
// El contagio universal ya produce algo de clustering por la geografía
// (radius 5 << diagonal del grid 40×40), así que el baseline arranca
// alto. La homofilia debe seguir levantándolo de forma consistente —
// umbral mínimo 0.05 absoluto sobre el baseline.
assert!(
mean_treatment > mean_baseline + 0.05,
"homofilia no levantó Moran's I: {} ≤ {} + 0.05",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 2 ────────────────────────
//
// **↑ contagion_rate con radio que cubre toda la grilla ⇒ ↓ varianza
// poblacional del psi.**
//
// Con contagio fuerte y radio suficiente para conectar a todos los agentes,
// la población converge a su promedio global y la varianza colapsa. Usamos
// `var_psi` en vez de polarización porque Esteban-Ray normaliza por span:
// con radios chicos pueden quedar clusters locales y la polarización subir;
// la varianza es la métrica honesta de "convergencia al consenso".
#[test]
fn hipotesis_contagio_universal_reduce_varianza() {
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.social_radius = 0.0;
p.contagion_rate = 0.0;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.var_psi[0]
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
// Radio que cubre la diagonal del grid (40·√2 ≈ 57) → todos vecinos.
p.social_radius = 60.0;
p.contagion_rate = 0.30;
p.homophily_threshold = 0.0;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.var_psi[0]
});
eprintln!(
"[H2] var(psi[ORDEN]): baseline {:.6} vs contagio universal {:.6}",
mean_baseline, mean_treatment
);
// Contagio verdaderamente universal debe colapsar la varianza al menos
// a la mitad (medirla por debajo del 50% del baseline).
assert!(
mean_treatment < mean_baseline * 0.5,
"contagio no colapsó la varianza: {} ≥ 0.5 × {}",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 3 ────────────────────────
//
// **PsiArgmax + psi_modulation ⇒ |corr(psi, accion)| más alta que Fixed.**
//
// Con la política psicológica encendida, la acción del agente se vuelve
// función del psi. La correlación punto-biserial entre cada componente del
// psi y la acción mayoritaria que esa componente premia debe crecer.
// Estadístico: max sobre `(k, a)` del valor absoluto de `psi_action_corr[k][a]`.
#[test]
fn hipotesis_psi_argmax_aumenta_correlacion_psi_accion() {
fn max_abs_corr(corr: &[[f32; 6]; 4]) -> f32 {
let mut m: f32 = 0.0;
for k in 0..4 {
for a in 0..6 {
let v = corr[k][a].abs();
if v > m {
m = v;
}
}
}
m
}
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let p = SimParams::default(); // Fixed
let (m, _) = run_and_measure(&mut w, &p, STEPS);
max_abs_corr(&m.psi_action_corr)
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 5; // reelige cada 5 ticks
p.psi_effect_modulation = 0.5;
let (m, _) = run_and_measure(&mut w, &p, STEPS);
max_abs_corr(&m.psi_action_corr)
});
eprintln!(
"[H3] max |corr(psi, accion)|: baseline {:.4} vs PsiArgmax {:.4}",
mean_baseline, mean_treatment
);
assert!(
mean_treatment > mean_baseline + 0.10,
"PsiArgmax no aumentó correlación: {} no supera {} + 0.10",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 4 ────────────────────────
//
// **regrowth_rate > 0 ⇒ población sostenida vs sin regrowth.**
//
// Sin regrowth, la materia se agota por Extraer y la población colapsa.
// Con regrowth, el cierre termodinámico mantiene un punto fijo `N* > 0`.
#[test]
fn hipotesis_regrowth_sostiene_poblacion() {
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.regrowth_rate = 0.0; // sin regrowth
p.carrying_capacity = 0.0;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.n as f32
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let p = SimParams::default(); // default tiene regrowth_rate > 0
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.n as f32
});
eprintln!(
"[H4] N final: sin regrowth {:.1} vs con regrowth {:.1}",
mean_baseline, mean_treatment
);
assert!(
mean_treatment > mean_baseline + 5.0,
"regrowth no sostuvo población: {} ≤ {} + 5",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 5 ────────────────────────
//
// **Big Five con peso ext positivo ⇒ acciones sociales (Mover/Sync/Intercambiar)
// crecen vs Big Four bit-exacto.**
//
// Con `big_five=true` y `action_weights_ext` premiando Intercambiar/Sincronizar,
// la política argmax debería empujar a más agentes hacia esas acciones, sobre
// todo si `psi5` (Extraversion) es alta en promedio (lo es: nuestro builder
// muestrea uniforme en [0, 1] → media 0.5).
#[test]
fn hipotesis_big_five_levanta_acciones_sociales() {
fn share_social(s: &WorldStats) -> f32 {
let total: u32 = s.action_counts.iter().sum();
if total == 0 {
return 0.0;
}
// Mover (0) + Sincronizar (2) + Intercambiar (3)
(s.action_counts[0] + s.action_counts[2] + s.action_counts[3]) as f32
/ total as f32
}
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 5;
p.big_five = false;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
share_social(&s)
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 5;
p.big_five = true;
// action_weights_ext default: Mover 0.4, Sync 0.6, Intercambiar 0.8.
let (_, s) = run_and_measure(&mut w, &p, STEPS);
share_social(&s)
});
eprintln!(
"[H5] fracción social: Big4 {:.4} vs Big5 {:.4}",
mean_baseline, mean_treatment
);
assert!(
mean_treatment > mean_baseline + 0.05,
"Big Five no levantó fracción social: {} ≤ {} + 0.05",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 6 ────────────────────────
//
// **Determinismo bit-exacto:** misma seed → misma trayectoria, incluso
// con todas las mecánicas opt-in encendidas a la vez. Si esto cae, algún
// componente nuevo metió no-determinismo (HashMap iteration, RNG global,
// reducción paralela). Es el guardián más importante de la plataforma.
#[test]
fn hipotesis_determinismo_bit_exacto_con_todas_las_mecanicas() {
let mut p = SimParams::default();
p.social_radius = 4.0;
p.contagion_rate = 0.10;
p.homophily_threshold = 0.4;
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 7;
p.psi_effect_modulation = 0.6;
p.big_five = true;
p.season_period = 50;
p.season_amplitude = 0.3;
let mut a = build_world(0xCAFE_BABE);
let mut b = build_world(0xCAFE_BABE);
run(&mut a, &p, 150);
run(&mut b, &p, 150);
assert_eq!(a.lemmings.pos_x, b.lemmings.pos_x);
assert_eq!(a.lemmings.pos_y, b.lemmings.pos_y);
assert_eq!(a.lemmings.energia, b.lemmings.energia);
assert_eq!(a.lemmings.vector_psi, b.lemmings.vector_psi);
assert_eq!(a.lemmings.psi5, b.lemmings.psi5);
assert_eq!(a.lemmings.accion, b.lemmings.accion);
assert_eq!(a.grid.materia, b.grid.materia);
let _ = MORANS_RADIUS_DEFAULT; // chequeo que la constante sigue exportada
}