1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
|
module Program
open System
open System.IO
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
let ticksPerSecond = 60L
let secondsPerDay = 86400L
let ticksPerDay = secondsPerDay * ticksPerSecond
let segmentTicks = 900L
let summaryInterval = 5000L
let zeroInput = { MoveX = 0.0f; MoveY = 0.0f }
type Driver =
{ Rng: RngState
Until: int64
Input: Input }
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 actionName (k: NpcActionKind) : string =
match k with
| Eat -> "eat"
| Sleep -> "sleep"
| Wander -> "wander"
| Work -> "work"
| Chat -> "chat"
let npcIndex (id: NpcId) : int =
match id with NpcId i -> i
let actionDistribution (npcs: Npc[]) : int * int * int * int * int =
let counts =
npcs
|> Array.fold (fun (e, s, w, k, c) npc ->
match npc.Mind.Action with
| Eat -> (e + 1, s, w, k, c)
| Sleep -> (e, s + 1, w, k, c)
| Wander -> (e, s, w + 1, k, c)
| Work -> (e, s, w, k + 1, c)
| Chat -> (e, s, w, k, c + 1)) (0, 0, 0, 0, 0)
counts
type RunStats =
{ ChatsPerNpc: int64[]
NonFinite: int64
OutOfBounds: int64
World: World }
let runSimulation (verbose: bool) (days: int64) (seed: uint64) (npcCount: int) : RunStats =
let totalTicks = days * ticksPerDay
if verbose then
printfn "living-village headless days=%d seed=%d npcs=%d ticks=%d tickrate=%d" days seed npcCount totalTicks ticksPerSecond
let mutable world = Sim.initialWorldN seed npcCount
let mutable driver = { Rng = Rng.ofSeed seed; Until = segmentTicks; Input = zeroInput }
let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels)
let maxY = float32 (Sim.mapHeightTiles * Sim.tilePixels - Sim.tilePixels)
let mutable nonFinite = 0L
let mutable outOfBounds = 0L
let mutable t = 0L
let mutable lastAction = (world.Npcs.[0]).Mind.Action
let mutable lastStart = 0L
let mutable switchCount = 0L
let mutable switchDurSum = 0L
let mutable sleepNight = 0L
let mutable sleepDay = 0L
let mutable nightTicks = 0L
let mutable dayTicks = 0L
let npcTotal = Array.create npcCount 0L
let lastActions = Array.create npcCount (world.Npcs.[0]).Mind.Action
let mutable chatsWindowStart = 0L
while t < totalTicks do
let d, input =
if t < 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
t <- t + 1L
let nightNow = Sim.isNightTick t
if nightNow then nightTicks <- nightTicks + 1L else dayTicks <- dayTicks + 1L
for npc in world.Npcs do
let idx = npcIndex npc.Id
if npc.Mind.Action = Chat && lastActions.[idx] <> Chat then
npcTotal.[idx] <- npcTotal.[idx] + 1L
lastActions.[idx] <- npc.Mind.Action
let head = world.Npcs.[0]
if head.Mind.Action = Sleep then
if nightNow then sleepNight <- sleepNight + 1L else sleepDay <- sleepDay + 1L
if head.Mind.Action <> lastAction then
switchCount <- switchCount + 1L
switchDurSum <- switchDurSum + (t - lastStart)
lastStart <- t
lastAction <- head.Mind.Action
let p = world.Avatar.Pos
if Single.IsNaN p.X || Single.IsNaN p.Y || Single.IsInfinity p.X || Single.IsInfinity p.Y then
nonFinite <- nonFinite + 1L
if p.X < 0.0f || p.X > maxX || p.Y < 0.0f || p.Y > maxY then outOfBounds <- outOfBounds + 1L
if verbose && t % summaryInterval = 0L then
let eat, sleep, wander, work, chat = actionDistribution world.Npcs
let chatsTotal = Array.sum npcTotal
let day = float t / float ticksPerDay
printfn "summary tick=%d day=%.4f eat=%d sleep=%d wander=%d work=%d chat=%d chats_window=%d"
t day eat sleep wander work chat (chatsTotal - chatsWindowStart)
chatsWindowStart <- chatsTotal
if verbose then
let segments = switchCount + 1L
let avgActionTicks = float (switchDurSum + (totalTicks - lastStart)) / float segments
let nightPct = if nightTicks > 0L then 100.0 * float sleepNight / float nightTicks else 0.0
let dayPct = if dayTicks > 0L then 100.0 * float sleepDay / float dayTicks else 0.0
let npc0 = world.Npcs.[0]
let memCount = List.length npc0.Mind.Memory
let lastValence =
match List.tryHead npc0.Mind.Memory with
| Some e -> e.Valence
| None -> 0.0f
printfn "npc=%A action_switches=%d avg_action_ticks=%.1f sleep_night_ticks=%d sleep_day_ticks=%d sleep_night_pct=%.2f sleep_day_pct=%.2f mem_count=%d last_valence=%.2f"
npc0.Id switchCount avgActionTicks sleepNight sleepDay nightPct dayPct memCount lastValence
let chatsTotal = Array.sum npcTotal
let minChats = if npcCount > 0 then Array.min npcTotal else 0L
let maxChats = if npcCount > 0 then Array.max npcTotal else 0L
let avgChats = if npcCount > 0 then float chatsTotal / float npcCount else 0.0
printfn "chats_total=%d chats_min=%d chats_max=%d chats_avg=%.2f" chatsTotal minChats maxChats avgChats
printfn "done tick=%d non-finite=%d out-of-bounds=%d" world.Tick nonFinite outOfBounds
{ ChatsPerNpc = npcTotal
NonFinite = nonFinite
OutOfBounds = outOfBounds
World = world }
let dumpRelations (world: World) : unit =
let m = Sim.relationMatrix world
let n = Array2D.length1 m
let ci = System.Globalization.CultureInfo.InvariantCulture
printfn "relations tick=%d npcs=%d half_life_ticks=%d threshold=%.2f" world.Tick n Sim.relationHalfLifeTicks Sim.relationThreshold
for i in 0 .. n - 1 do
let row = Array.init n (fun j -> m.[i, j].ToString("0.000", ci))
printfn "rel[%d] %s" i (String.concat " " row)
let counts = Sim.relationCounts m
let cells = Array.mapi (fun i c -> $"{i}:{c}") counts
printfn "rel_counts %s" (String.concat " " cells)
printfn "rel_total=%d rel_max=%d rel_min=%d" (Array.sum counts) (Array.max counts) (Array.min counts)
let dumpRumors (world: World) : unit =
printfn "%s" (Sim.rumorTraceText world)
type M5SmokeResult =
{ Passed: bool
Failure: string
MenuOptions: int
ObservationCount: int
RumorPathLength: int
Chronicle: string
RumorTrace: string }
module M5Smoke =
let private atNpc (id: int) (world: World) : World =
let npc = world.Npcs.[id]
{ world with Avatar = { world.Avatar with Pos = npc.Pos } }
let private readyForChat (world: World) : World =
{ world with
Npcs =
world.Npcs
|> Array.map (fun npc ->
{ npc with
Mind =
{ npc.Mind with
Action = Wander
Target = actionTarget Wander
ActionAge = minActionTicks
EffectDone = true } }) }
let private atTick (tick: int64) (world: World) : World =
{ world with
Tick = tick
Time = float tick * dtSeconds }
let private chat (narrator: int) (receiver: int) (world: World) : World =
match Sim.chat { Narrator = NpcId narrator; Receiver = NpcId receiver } world with
| ChatSucceeded (_, next) -> next
| ChatRejected (failure, _) -> failwithf "M5 smoke chat rejected: %A" failure
let run () : M5SmokeResult =
let initial = Sim.initialWorldN 7UL 4 |> readyForChat |> atNpc 0
let afterStep = Sim.step { Input = zeroInput } initial
let needsChanged = afterStep.Avatar.Mind.Needs <> initial.Avatar.Mind.Needs
let needs = Sim.needsPanel afterStep
let bounds =
{ Min = { X = initial.Avatar.Pos.X - Sim.chatRangePx; Y = initial.Avatar.Pos.Y - Sim.chatRangePx }
Max = { X = initial.Avatar.Pos.X + Sim.chatRangePx; Y = initial.Avatar.Pos.Y + Sim.chatRangePx } }
let observations = Sim.observeVisible bounds initial
let menu = Sim.openDialogueMenu initial
let menuOptions = menu |> Option.map (fun item -> item.Options.Length) |> Option.defaultValue 0
let afterDialogue, dialogueRecorded =
match Sim.chooseDialogue (NpcId 0) SmallTalk initial with
| DialogueSucceeded (_, next) -> next, true
| DialogueRejected (_, next) -> next, false
let first = afterDialogue |> atTick ticksPerDay |> readyForChat |> chat 0 1
let second = first |> atTick (2L * ticksPerDay) |> readyForChat |> chat 1 2
let finalWorld = second |> atTick (3L * ticksPerDay) |> readyForChat |> chat 2 -1
let finalRumor = finalWorld.Rumors |> List.sortBy (fun rumor -> rumor.Id) |> List.last
let path = Sim.rumorPath finalWorld finalRumor.Id
let expectedChain =
[ (playerId, NpcId 0)
(playerId, NpcId 1)
(playerId, NpcId 2)
(playerId, playerId) ]
let actualChain = path |> List.map (fun rumor -> rumor.Source, rumor.Receiver)
let chronicle = Sim.annalText finalWorld
let rumorTrace = Sim.rumorTraceText finalWorld
let checks =
[ menuOptions = 6
needs.Needs = afterStep.Avatar.Mind.Needs
needsChanged
observations |> List.exists (fun observation -> observation.Id = NpcId 0)
dialogueRecorded
finalRumor.Source = playerId
finalRumor.Receiver = playerId
finalRumor.Narrator = NpcId 2
finalRumor.Depth = 3
actualChain = expectedChain
chronicle.Contains("dialogue") ]
let failure =
checks
|> List.mapi (fun index passed -> if passed then None else Some(sprintf "check_%d" (index + 1)))
|> List.choose id
|> String.concat ","
{ Passed = failure = ""
Failure = failure
MenuOptions = menuOptions
ObservationCount = observations.Length
RumorPathLength = path.Length
Chronicle = chronicle
RumorTrace = rumorTrace }
type M6aSmokeSnapshot =
{ Before: World
Restored: World
Continued: World
RestoredContinued: World }
module M6aSmoke =
let private atNpc (id: int) (world: World) : World =
let npc = world.Npcs.[id]
{ world with Avatar = { world.Avatar with Pos = npc.Pos } }
let private readyForChat (world: World) : World =
{ world with
Npcs =
world.Npcs
|> Array.map (fun npc ->
{ npc with
Mind =
{ npc.Mind with
Action = Wander
Target = actionTarget Wander
ActionAge = minActionTicks
EffectDone = true } }) }
let private atTick (tick: int64) (world: World) : World =
{ world with
Tick = tick
Time = float tick * dtSeconds }
let private dialogue (world: World) : World =
match Sim.chooseDialogue (NpcId 0) SmallTalk (atNpc 0 world) with
| DialogueSucceeded (_, next) -> next
| DialogueRejected (failure, _) -> failwithf "M6a smoke dialogue rejected: %A" failure
let private chat (narrator: int) (receiver: int) (world: World) : World =
match Sim.chat { Narrator = NpcId narrator; Receiver = NpcId receiver } (readyForChat world) with
| ChatSucceeded (Some _, next) -> next
| ChatSucceeded (None, _) -> failwith "M6a smoke chat did not create a rumor"
| ChatRejected (failure, _) -> failwithf "M6a smoke chat rejected: %A" failure
let private trade (world: World) : World =
match Sim.trade { Buyer = NpcId 0; Seller = NpcId 1; Item = Food; Quantity = 1 } world with
| TradeSucceeded next -> next
| TradeRejected (failure, _) -> failwithf "M6a smoke trade rejected: %A" failure
let private inputs =
[ { MoveX = 0.5f; MoveY = -0.25f }
{ MoveX = 0.0f; MoveY = 0.0f }
{ MoveX = -1.0f; MoveY = 0.75f }
{ MoveX = 0.25f; MoveY = 0.0f } ]
let private advance (inputs: Input list) (world: World) : World =
inputs |> List.fold (fun current input -> Sim.step { Input = input } current) world
let run () : Result<M6aSmokeSnapshot, string> =
let path = Path.Combine(Path.GetTempPath(), sprintf "living-village-m6a-%s.save" (Guid.NewGuid().ToString("N")))
try
let before =
Sim.initialWorldN 7UL 4
|> dialogue
|> chat 0 1
|> trade
|> atTick (22L * (ticksPerDay / 24L))
WorldSave.saveToFile path before
match WorldSave.loadFromFile path with
| Error failure -> Error failure
| Ok restored ->
Ok
{ Before = before
Restored = restored
Continued = advance inputs before
RestoredContinued = advance inputs restored }
finally
if File.Exists path then File.Delete path
let runM6aSmoke () : int =
try
match M6aSmoke.run () with
| Error failure ->
printfn "m6a_failure=%s" failure
printfn "M6A_ACCEPTANCE=FAIL"
1
| Ok snapshot ->
let roundTripSame = WorldSave.save snapshot.Before = WorldSave.save snapshot.Restored
let continuationSame = WorldSave.save snapshot.Continued = WorldSave.save snapshot.RestoredContinued
let stateExercised =
snapshot.Before.Events.Length > 0
&& snapshot.Before.Rumors.Length > 0
&& snapshot.Before.Annals.Length > 0
let failures =
[ if not roundTripSame then Some "round_trip" else None
if not continuationSame then Some "continuation" else None
if not stateExercised then Some "state_coverage" else None ]
|> List.choose id
let beforeDigest = LivingVillage.Headless.PerformanceProbe.worldDigest snapshot.Before
let restoredDigest = LivingVillage.Headless.PerformanceProbe.worldDigest snapshot.Restored
let continuedDigest = LivingVillage.Headless.PerformanceProbe.worldDigest snapshot.Continued
let restoredContinuedDigest = LivingVillage.Headless.PerformanceProbe.worldDigest snapshot.RestoredContinued
printfn "m6a_smoke before_tick=%d restored_tick=%d continued_tick=%d restored_continued_tick=%d events=%d rumors=%d annals=%d"
snapshot.Before.Tick snapshot.Restored.Tick snapshot.Continued.Tick snapshot.RestoredContinued.Tick
snapshot.Before.Events.Length snapshot.Before.Rumors.Length snapshot.Before.Annals.Length
printfn "m6a_digest before=%s restored=%s continued=%s restored_continued=%s"
beforeDigest restoredDigest continuedDigest restoredContinuedDigest
if failures.Length > 0 then printfn "m6a_failure=%s" (String.concat "," failures)
printfn "M6A_ACCEPTANCE=%s" (if failures.IsEmpty then "PASS" else "FAIL")
if failures.IsEmpty then 0 else 1
with ex ->
printfn "m6a_failure=%s" ex.Message
printfn "M6A_ACCEPTANCE=FAIL"
1
let runM5Smoke () : int =
let result = M5Smoke.run ()
printfn "m5_smoke menu_options=%d observations=%d rumor_path=%d" result.MenuOptions result.ObservationCount result.RumorPathLength
printfn "m5_chronicle=%s" (result.Chronicle.Replace('\n', '|'))
printfn "m5_rumor_trace=%s" (result.RumorTrace.Replace('\n', '|'))
printfn "M5_ACCEPTANCE=%s" (if result.Passed then "PASS" else "FAIL")
if result.Passed then 0 else 1
let replayRumors (days: int64) (seed: uint64) (npcCount: int) (world: World) : bool =
let expected = Sim.rumorTraceText world
let replayed = runSimulation false days seed npcCount |> fun stats -> Sim.rumorTraceText stats.World
let same = expected = replayed
printfn "rumor_replay=%s tick=%d count=%d" (if same then "PASS" else "FAIL") world.Tick world.Rumors.Length
same
let gini (xs: int64[]) : float =
let n = float xs.Length
let s = float (Array.sum xs)
if n <= 1.0 || s <= 0.0 then 0.0
else
let mutable num = 0.0
for xi in xs do
for xj in xs do
num <- num + abs (float xi - float xj)
num / (2.0 * n * n * (s / n))
type WorldVerdict =
{ Line: string
Ok: bool
CheckOld: bool
Ratio: float
Gini: float }
let evalWorld (days: int64) (k: int) : WorldVerdict =
let seed = 42UL + uint64 k
let stats = runSimulation false days seed Sim.npcCount
let m = Sim.relationMatrix stats.World
let n = Array2D.length1 m
let mutable hasNonFinite = stats.NonFinite > 0L
for i in 0 .. n - 1 do
for j in 0 .. n - 1 do
if Single.IsNaN m.[i, j] || Single.IsInfinity m.[i, j] then hasNonFinite <- true
let chats = stats.ChatsPerNpc
let chatsTotal = Array.sum chats
let chatsAvg = float chatsTotal / float chats.Length
let chatsMax = if chats.Length > 0 then Array.max chats else 0L
let ratio = if chatsAvg > 0.0 then float chatsMax / chatsAvg else 0.0
let g = gini chats
let counts = Sim.relationCounts m
let cntMax = if counts.Length > 0 then Array.max counts else 0
let cntMin = if counts.Length > 0 then Array.min counts else 0
let connected = Array.fold (fun acc c -> if c > 0 then acc + 1 else acc) 0 counts
let connectedNeed = int (System.Math.Ceiling(0.6 * float chats.Length))
let checkA = ratio > 2.0
let checkB = connected >= connectedNeed
let checkC = not hasNonFinite
let checkOld = cntMax > 2 * cntMin
let ok = checkA && checkB && checkC
let line =
sprintf "world=%d seed=%d chats_max=%d chats_avg=%.2f ratio=%.3f gini=%.3f relcnt_max=%d relcnt_min=%d relconn=%d/%d nonfinite=%d check_a=%s check_b=%s check_c=%s old_crit=%s %s"
k seed chatsMax chatsAvg ratio g cntMax cntMin connected connectedNeed stats.NonFinite
(if checkA then "PASS" else "FAIL")
(if checkB then "PASS" else "FAIL")
(if checkC then "PASS" else "FAIL")
(if checkOld then "PASS" else "FAIL")
(if ok then "OK" else "BAD")
{ Line = line; Ok = ok; CheckOld = checkOld; Ratio = ratio; Gini = g }
let runBatch (worlds: int) (days: int64) : int =
printfn "batch worlds=%d days=%d half_life_ticks=%d rel_threshold=%.2f" worlds days Sim.relationHalfLifeTicks Sim.relationThreshold
// 判据语义(M3c 修正):
// (a) chats max/avg > 2.0 —— 行为分布非均匀(观测值,仍为 pass 条件)
// (b) 至少有一条 |r|>阈值 关系的 NPC 人数 >= 总人数 60% —— 关系网基本连通;
// gini 与 ratio 仅作观测打印,不再作为 pass 条件(旧判据 cntMax>2*cntMin 对
// 明星集中型分布过严,relcnt_max=0 即判死,与"非均匀分布"的验收目标不符)
// (c) 无 NaN/Inf
printfn "checks: (a) chats max/avg > 2.0 (b) npcs_with_relation >= 0.6*npc_count (c) no NaN (old) relcnt_max > 2*relcnt_min (report only)"
// 世界间相互独立(seed=42+k,逐世界确定性),可安全并行;每个世界一完成即流式
// 打印该行与进度(完成序,可能非 world 升序),判据行内容与串行版本逐字节一致。
// LV_BATCH_WORKERS 可覆盖(默认 modest 4)。
let workers =
match Environment.GetEnvironmentVariable "LV_BATCH_WORKERS" with
| null | "" -> min 4 Environment.ProcessorCount
| v -> match Int32.TryParse v with true, n when n > 0 -> min n Environment.ProcessorCount | _ -> min 4 Environment.ProcessorCount
printfn "workers=%d" workers
Console.Out.Flush()
let wall = System.Diagnostics.Stopwatch.StartNew()
// 逐世界流式:每个世界一完成立刻打印该行(行尾追加 elapsed_s),再打印一行
// [done k/N] 进度,并 flush,使长批次不再整轮零进度。结果仍按 world 序号落数组。
let results =
LivingVillage.Headless.BatchOutput.runStreaming worlds workers
(fun k ->
let sw = System.Diagnostics.Stopwatch.StartNew()
let verdict = evalWorld days k
sw.Stop()
(verdict, sw.Elapsed.TotalSeconds))
(fun k (verdict, seconds) doneCount ->
printfn "%s" (LivingVillage.Headless.BatchOutput.worldLine verdict.Line seconds)
printfn "%s" (LivingVillage.Headless.BatchOutput.doneLine doneCount worlds seconds k)
Console.Out.Flush())
wall.Stop()
let mutable passed = 0
let mutable failed = 0
let mutable oldPassed = 0
let ratios = ResizeArray<float> ()
let ginis = ResizeArray<float> ()
for verdict, _ in results do
if verdict.Ok then passed <- passed + 1 else failed <- failed + 1
if verdict.CheckOld then oldPassed <- oldPassed + 1
ratios.Add verdict.Ratio
ginis.Add verdict.Gini
let allPass = failed = 0
printfn "%s"
(LivingVillage.Headless.BatchOutput.summaryLine
worlds passed failed oldPassed (Seq.min ratios) (Seq.max ratios) (Seq.min ginis) (Seq.max ginis) workers wall.Elapsed.TotalSeconds)
printfn "M3_ACCEPTANCE=%s" (if allPass then "PASS" else "FAIL")
Console.Out.Flush()
if allPass then 0 else 1
/// `--cost-probe`:单世界按天数阶梯逐段真实测量(墙钟、ticks/s、分配与 GC),
/// 只读、不改任何模拟语义。天数阶梯可用 `--cost-probe D1,D2,...` 覆盖。
let runCostProbe (ladder: int64 list) : int =
printfn "cost_probe ladder=%s npcs=%d" (ladder |> List.map string |> String.concat ",") Sim.npcCount
Console.Out.Flush()
for days in ladder do
let ticks = days * ticksPerDay
System.GC.Collect()
let allocBefore = System.GC.GetTotalAllocatedBytes(false)
let gen0Before = System.GC.CollectionCount 0
let gen1Before = System.GC.CollectionCount 1
let gen2Before = System.GC.CollectionCount 2
let sw = System.Diagnostics.Stopwatch.StartNew()
let stats = runSimulation false days 42UL Sim.npcCount
sw.Stop()
let allocated = System.GC.GetTotalAllocatedBytes(false) - allocBefore
let line =
LivingVillage.Headless.BatchOutput.costLine
days ticks sw.Elapsed.TotalSeconds allocated
(int64 (System.GC.CollectionCount 0 - gen0Before))
(int64 (System.GC.CollectionCount 1 - gen1Before))
(int64 (System.GC.CollectionCount 2 - gen2Before))
printfn "%s" line
printfn "cost_probe_check days=%d nonfinite=%d out_of_bounds=%d final_tick=%d" days stats.NonFinite stats.OutOfBounds stats.World.Tick
Console.Out.Flush()
0
/// `--rumor-bench [days] [npcs] [chats_per_day]`:Kernel 层 Rumor 长跑基准
/// (默认 30 NPC × 60 模拟日 × 每日 2000 次聊天)。只读、不改模拟语义。
let runRumorBench (argv: string[]) : int =
let tryInt64 (index: int) =
if argv.Length > index then
match Int64.TryParse argv.[index] with
| true, value -> Some value
| _ -> None
else None
let days = tryInt64 1 |> Option.defaultValue RumorBench.defaultConfig.Days
let npcs = tryInt64 2 |> Option.defaultValue (int64 RumorBench.defaultConfig.NpcCount) |> int
let chatsPerDay = tryInt64 3 |> Option.defaultValue (int64 RumorBench.defaultConfig.ChatsPerDay) |> int
let config =
{ RumorBench.defaultConfig with
Days = days
NpcCount = npcs
ChatsPerDay = chatsPerDay }
let result = RumorBench.run config
printfn "%s" (RumorBench.format result)
Console.Out.Flush()
0
/// `--profile-long-run [days] [sampleEveryDays] [npcs] [seed]`:长局内存 profile(只读,不改模拟行为)。
/// 逐模拟日推进,每 `sampleEveryDays` 天采样并流式打印+flush:
/// * RSS:GC.GetTotalMemory(true)(GC live set)与 Process.WorkingSet64(OS RSS);
/// * 三类历史集合 Events / Rumors / Annals 的条目数与估算字节;
/// * NPC Mind.Memory 总条目数与估算字节;
/// * 首个与最后一个采样点附带世界 digest,证明确定性不被 profile 影响。
/// 估算字节为保守常数模型(InteractionEvent≈48B、RumorEvent≈112B、AnnalEntry≈96B+Summary*2、
/// MemoryEvent≈72B),仅作数量级参考;ground truth 是 GC live set 与 WorkingSet。
let runProfileLongRun (argv: string[]) : int =
let argInt64 index (dflt: int64) =
if argv.Length > index then
match Int64.TryParse argv.[index] with
| true, v when v > 0L -> v
| _ -> dflt
else dflt
let days = argInt64 1 40L
let sampleEvery = argInt64 2 4L
let npcs = int (argInt64 3 (int64 Sim.npcCount))
let seed = if argv.Length > 4 then (match UInt64.TryParse argv.[4] with | true, v -> v | _ -> 42UL) else 42UL
let mb (bytes: int64) = float bytes / (1024.0 * 1024.0)
let proc = System.Diagnostics.Process.GetCurrentProcess()
printfn "long_run_start days=%d sample_every_days=%d npcs=%d seed=%d ticks_per_day=%d" days sampleEvery npcs seed ticksPerDay
Console.Out.Flush()
let mutable world = Sim.initialWorldN seed npcs
let mutable driver = { Rng = Rng.ofSeed seed; Until = segmentTicks; Input = zeroInput }
let sw = System.Diagnostics.Stopwatch.StartNew()
let sample (day: int64) (withDigest: bool) =
let gcLive = System.GC.GetTotalMemory true
proc.Refresh()
let ws = proc.WorkingSet64
let events = world.Events.Length
let rumors = world.Rumors.Length
let annals = world.Annals.Length
let memEntries = world.Npcs |> Array.sumBy (fun n -> n.Mind.Memory.Length)
let annalsBytes = world.Annals |> List.sumBy (fun a -> 96 + a.Summary.Length * 2)
let digest = if withDigest then LivingVillage.Headless.PerformanceProbe.worldDigest world else "-"
printfn
"long_run_sample day=%d tick=%d gc_live_mb=%.2f working_set_mb=%.2f events=%d rumors=%d annals=%d memory_entries=%d est_events_kb=%.1f est_rumors_kb=%.1f est_annals_kb=%.1f est_memory_kb=%.1f elapsed_s=%.1f digest=%s"
day world.Tick (mb gcLive) (mb ws) events rumors annals memEntries
(float (events * 48) / 1024.0) (float (rumors * 112) / 1024.0) (float annalsBytes / 1024.0)
(float (memEntries * 72) / 1024.0) sw.Elapsed.TotalSeconds digest
Console.Out.Flush()
sample 0L true
let mutable dayNo = 0L
while dayNo < days do
dayNo <- dayNo + 1L
let dayEnd = dayNo * ticksPerDay
while world.Tick < dayEnd do
let d, input =
if world.Tick < 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
if dayNo % sampleEvery = 0L || dayNo = days then sample dayNo false
let finalDigest = LivingVillage.Headless.PerformanceProbe.worldDigest world
printfn "long_run_done days=%d tick=%d final_digest=%s elapsed_s=%.1f" dayNo world.Tick finalDigest sw.Elapsed.TotalSeconds
Console.Out.Flush()
if world.Tick = days * ticksPerDay then 0 else 1
let runExisting (argv: string[]) =
let rec parse
(i: int)
(days: int64 option)
(seed: uint64 option)
(npc: int option)
(dump: bool)
(rumorTrace: bool)
(rumorReplay: bool)
(m5Smoke: bool)
(batch: (int * int64) option)
: Result<int64 * uint64 * int * bool * bool * bool * bool * (int * int64) option, string> =
if i >= argv.Length then
match batch with
| Some(k, d) -> Ok(defaultArg days 1L, defaultArg seed 42UL, defaultArg npc 30, dump, rumorTrace, rumorReplay, m5Smoke, Some(k, d))
| None ->
match days, seed, npc with
| Some d, Some s, Some n -> Ok(d, s, n, dump, rumorTrace, rumorReplay, m5Smoke, None)
| _ when m5Smoke -> Ok(defaultArg days 1L, defaultArg seed 42UL, defaultArg npc 30, dump, rumorTrace, rumorReplay, true, None)
| _ -> Error "missing --days/--seed"
else
match argv.[i] with
| "--days" when i + 1 < argv.Length ->
(match Int64.TryParse argv.[i + 1] with
| true, d when d > 0L -> parse (i + 2) (Some d) seed npc dump rumorTrace rumorReplay m5Smoke batch
| _ -> Error $"invalid --days '{argv.[i + 1]}'")
| "--seed" when i + 1 < argv.Length ->
(match UInt64.TryParse argv.[i + 1] with
| true, s -> parse (i + 2) days (Some s) npc dump rumorTrace rumorReplay m5Smoke batch
| _ -> Error $"invalid --seed '{argv.[i + 1]}'")
| "--npc" when i + 1 < argv.Length ->
(match Int32.TryParse argv.[i + 1] with
| true, n when n > 0 && n <= 1000 -> parse (i + 2) days seed (Some n) dump rumorTrace rumorReplay m5Smoke batch
| _ -> Error $"invalid --npc '{argv.[i + 1]}'")
| "--dump-relations" -> parse (i + 1) days seed npc true rumorTrace rumorReplay m5Smoke batch
| "--dump-rumors" -> parse (i + 1) days seed npc dump true rumorReplay m5Smoke batch
| "--replay-rumors" -> parse (i + 1) days seed npc dump rumorTrace true m5Smoke batch
| "--m5-smoke" -> parse (i + 1) days seed npc dump rumorTrace rumorReplay true batch
| "--batch" when i + 2 < argv.Length ->
match Int32.TryParse argv.[i + 1], Int64.TryParse argv.[i + 2] with
| (true, k), (true, d) when k > 0 && d > 0L -> parse (i + 3) days seed npc dump rumorTrace rumorReplay m5Smoke (Some(k, d))
| _ -> Error $"invalid --batch '{argv.[i + 1]} {argv.[i + 2]}'"
| other -> Error $"unknown argument '{other}'"
match parse 0 None None (Some 30) false false false false None with
| Ok(days, seed, npc, dump, rumorTrace, rumorReplay, m5Smoke, batch) ->
match m5Smoke, batch with
| true, Some _ ->
eprintfn "headless: --m5-smoke cannot be combined with --batch"
2
| true, None -> runM5Smoke ()
| false, Some(k, d) when rumorTrace || rumorReplay ->
eprintfn "headless: rumor trace/replay cannot be combined with --batch"
2
| false, Some(k, d) -> runBatch k d
| false, None ->
let stats = runSimulation true days seed npc
if dump then dumpRelations stats.World
if rumorTrace then dumpRumors stats.World
let replayOk = if rumorReplay then replayRumors days seed npc stats.World else true
if stats.NonFinite > 0L || stats.OutOfBounds > 0L || not replayOk then 1 else 0
| Error msg ->
eprintfn $"headless: {msg}"
eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] [--dump-rumors] [--replay-rumors] | --m5-smoke | --m6a-smoke | --batch K D | --performance-baseline | --cost-probe [D1,D2,...] | --rumor-bench [days npcs chats_per_day]"
2
let private parseCostLadder (argv: string[]) : int64 list =
if argv.Length >= 2 then
let parsed =
argv.[1].Split(',')
|> Array.toList
|> List.map (fun token ->
match Int64.TryParse(token.Trim()) with
| true, days when days > 0L -> Some days
| _ -> None)
if parsed <> [] && List.forall Option.isSome parsed then
parsed |> List.map Option.get
else
LivingVillage.Headless.BatchOutput.defaultCostLadder
else
LivingVillage.Headless.BatchOutput.defaultCostLadder
[<EntryPoint>]
let main argv =
if argv.Length = 1 && argv.[0] = "--m6a-smoke" then
runM6aSmoke ()
elif argv.Length = 1 && argv.[0] = "--performance-baseline" then
LivingVillage.Headless.PerformanceProbe.runDefault ()
elif argv.Length >= 1 && argv.[0] = "--cost-probe" then
runCostProbe (parseCostLadder argv)
elif argv.Length >= 1 && argv.[0] = "--rumor-bench" then
runRumorBench argv
elif argv.Length >= 1 && argv.[0] = "--profile-long-run" then
runProfileLongRun argv
else
runExisting argv
|