summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-19 00:18:43 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-19 00:18:43 +0800
commit59458d149d7cecf73e708faa6d9e6db2bcd89dfd (patch)
treeaf342c6a01766be326857f48ebe5c65529e50882 /src/LivingVillage.Kernel.Tests/DeterminismTests.fs
parentb237a11908442a227361fae24dbdd0dbb287fac9 (diff)
downloadliving-village-59458d149d7cecf73e708faa6d9e6db2bcd89dfd.tar.gz
feat: M1 模拟器内核 — SplitMix64 RNG + 确定性 step + 对拍测试 + headless 冒烟工具 + Desktop 自动演示
[变更性质] 本 commit 为功能开发,交付设计文档 §2 模拟器内核(里程碑 M1),非缺陷修复或结构重构。 [新增功能] - Kernel:Rng.fs 提供 SplitMix64 纯函数 RNG(ofSeed / nextUInt64 / nextFloat32),World 携带种子状态。 - Kernel:Sim.fs 重写为 struct record(Vec2/Avatar/Input/TimeStep/World),step : World → TimeStep → World 纯函数,dt 固定 1/60 由内核常量给定,Time = Tick * dt 不做浮点累加;initialWorld 由无参改为带 seed。 - Tests:MSTest 确定性对拍——同 seed 轨迹逐 tick 相等、异 seed 发散、step 纯度、长跑有限/有界/时间-刻度关系、SplitMix64 已知向量、nextFloat32 值域。 - Headless:--days N --seed S 冒烟工具,每 1000 tick 输出摘要行,统计 NaN/越界计数并决定退出码。 - Desktop:LV_AUTOPLAY=1 环境变量启用正弦驱动自动演示,窗口标题与启动日志标注模式。 [实现方案] - 全部状态用 [<Struct>] record,模拟路径零堆分配,支撑 51.84 亿刻长跑。 - step 不消费 World 内 RNG,种子经由外部确定性驱动脚本生效(每 900 刻换段,8 方向查表 + 三档幅值,避开三角函数保证跨平台位精确)。 - Headless 内存口径取 GC.GetTotalMemory(true)(全收集后的稳态驻留),避免 GetTotalMemory(false) 随 Gen0 段扩张漂移误报泄漏。 [影响范围] - Kernel 保持零 MonoGame 引用,模拟与渲染彻底分离;sln 新增 Headless 与 Tests 两个项目。 - Release 构建 0 警告 0 错误,7/7 测试通过;1000 天长跑 5,184,000,000 刻退出码 0,无 NaN 无越界,内存 87,824 → 93,000 字节(1.06x < 2x)。 - 同 seed 两次运行 stdout 字节一致,seed 42/43 轨迹不同。
Diffstat (limited to 'src/LivingVillage.Kernel.Tests/DeterminismTests.fs')
-rw-r--r--src/LivingVillage.Kernel.Tests/DeterminismTests.fs135
1 files changed, 135 insertions, 0 deletions
diff --git a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
new file mode 100644
index 0000000..2e2663b
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
@@ -0,0 +1,135 @@
+namespace LivingVillage.Kernel.Tests
+
+open System
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+module private DriverHarness =
+
+ let segmentTicks = 900L
+
+ type Driver =
+ { Rng: RngState
+ Until: int64
+ Input: Input }
+
+ let zeroInput = { MoveX = 0.0f; MoveY = 0.0f }
+
+ let nextSegment (rng: RngState) (until: int64) : Driver =
+ let a, r1 = Rng.nextUInt64 rng
+ let m, r2 = Rng.nextUInt64 r1
+ let diag = 0.70710678f
+ let dirX, dirY =
+ match int (a &&& 7UL) with
+ | 0 -> 1.0f, 0.0f
+ | 1 -> diag, diag
+ | 2 -> 0.0f, 1.0f
+ | 3 -> -diag, diag
+ | 4 -> -1.0f, 0.0f
+ | 5 -> -diag, -diag
+ | 6 -> 0.0f, -1.0f
+ | _ -> diag, -diag
+ let mag =
+ match int (m % 3UL) with
+ | 0 -> 0.4f
+ | 1 -> 0.7f
+ | _ -> 1.0f
+ { Rng = r2
+ Until = until + segmentTicks
+ Input = { MoveX = dirX * mag; MoveY = dirY * mag } }
+
+ let run (seed: uint64) (ticks: int64) : World list =
+ let mutable world = Sim.initialWorld seed
+ let mutable driver = { Rng = Rng.ofSeed seed; Until = segmentTicks; Input = zeroInput }
+ let acc = ResizeArray<World> (int ticks + 1)
+ acc.Add world
+ for t in 1L .. ticks do
+ let d, input =
+ if t - 1L < driver.Until then driver, driver.Input
+ else
+ let d = nextSegment driver.Rng driver.Until
+ d, d.Input
+ driver <- d
+ world <- Sim.step { Input = input } world
+ acc.Add world
+ List.ofSeq acc
+
+[<TestClass>]
+type DeterminismTests () =
+
+ [<TestMethod>]
+ member _.SameSeedSameInputSequenceTracesAreTickByTickEqual () =
+ let a = DriverHarness.run 42UL 20000L
+ let b = DriverHarness.run 42UL 20000L
+ Assert.IsTrue(20001 = List.length a)
+ CollectionAssert.AreEqual(Array.ofList a, Array.ofList b)
+
+ [<TestMethod>]
+ member _.DifferentSeedsProduceDivergentTraces () =
+ let a = DriverHarness.run 42UL 20000L
+ let b = DriverHarness.run 43UL 20000L
+ let diverged =
+ Seq.exists2 (fun (x: World) (y: World) -> x.Avatar.Pos <> y.Avatar.Pos) a b
+ if not diverged then Assert.Fail("expected avatar positions to diverge across seeds")
+ if a = b then Assert.Fail("expected whole traces to differ across seeds")
+
+ [<TestMethod>]
+ member _.StepIsPureAndDoesNotMutateItsInput () =
+ let w = Sim.initialWorld 7UL
+ let ts = { Input = { MoveX = 1.0f; MoveY = -0.5f } }
+ let w1 = Sim.step ts w
+ let w1' = Sim.step ts w
+ if w1 <> w1' then Assert.Fail("step must be deterministic for identical (world, input)")
+ Assert.IsTrue(w.Tick = 0L)
+ Assert.IsTrue(w.Time = 0.0)
+ Assert.IsTrue(w1.Tick = 1L)
+ Assert.IsTrue(abs (w1.Time - Sim.dtSeconds) < 1e-12)
+
+ [<TestMethod>]
+ member _.WorldCarriesSeededRngState () =
+ let a = Sim.initialWorld 42UL
+ let b = Sim.initialWorld 43UL
+ Assert.IsTrue(a.Rng.State = 42UL)
+ Assert.IsTrue(b.Rng.State = 43UL)
+ Assert.IsTrue(a <> b)
+
+ [<TestMethod>]
+ member _.LongRunStaysFiniteBoundedAndTimeTracksTicks () =
+ let trace = DriverHarness.run 42UL 120000L
+ let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels)
+ let maxY = float32 (Sim.mapHeightTiles * Sim.tilePixels - Sim.tilePixels)
+ let mutable checkedTicks = 0
+ for w in trace do
+ let p = w.Avatar.Pos
+ if Single.IsNaN p.X then Assert.Fail($"NaN X at tick {w.Tick}")
+ if Single.IsNaN p.Y then Assert.Fail($"NaN Y at tick {w.Tick}")
+ if Single.IsInfinity p.X then Assert.Fail($"Infinity X at tick {w.Tick}")
+ if Single.IsInfinity p.Y then Assert.Fail($"Infinity Y at tick {w.Tick}")
+ if p.X < 0.0f || p.X > maxX then Assert.Fail($"X out of bounds at tick {w.Tick}: {p.X}")
+ if p.Y < 0.0f || p.Y > maxY then Assert.Fail($"Y out of bounds at tick {w.Tick}: {p.Y}")
+ if abs (float w.Tick * Sim.dtSeconds - w.Time) >= 1e-9 then
+ Assert.Fail($"Time/Tick mismatch at tick {w.Tick}: {w.Time}")
+ checkedTicks <- checkedTicks + 1
+ Assert.IsTrue(120001 = checkedTicks)
+
+ [<TestMethod>]
+ member _.SplitMix64MatchesKnownVectors () =
+ let expected : uint64 list =
+ [ 0xbdd732262feb6e95UL
+ 0x28efe333b266f103UL
+ 0x47526757130f9f52UL
+ 0x581ce1ff0e4ae394UL ]
+ let mutable rng = Rng.ofSeed 42UL
+ for e in expected do
+ let v, rng' = Rng.nextUInt64 rng
+ if v <> e then Assert.Fail($"expected {e}, got {v}")
+ rng <- rng'
+
+ [<TestMethod>]
+ member _.NextFloat32StaysInUnitInterval () =
+ let mutable rng = Rng.ofSeed 1UL
+ for _ in 1 .. 1000 do
+ let f, rng' = Rng.nextFloat32 rng
+ if f < 0.0f || f >= 1.0f then Assert.Fail($"out of range: {f}")
+ rng <- rng'