summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Headless/Program.fs
blob: 2a2482bc2eb3af2697f2006922c9f82a51cc56f6 (plain)
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
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
    let results = Array.zeroCreate worlds
    System.Threading.Tasks.Parallel.For
        (0, worlds,
         System.Threading.Tasks.ParallelOptions(MaxDegreeOfParallelism = workers),
         fun k -> results.[k] <- evalWorld days k)
    |> ignore
    let mutable passed = 0
    let mutable failed = 0
    let mutable oldPassed = 0
    let ratios = ResizeArray<float> ()
    let ginis = ResizeArray<float> ()
    let sw = System.Diagnostics.Stopwatch.StartNew()
    for r in results do
        printfn "%s" r.Line
        if r.Ok then passed <- passed + 1 else failed <- failed + 1
        if r.CheckOld then oldPassed <- oldPassed + 1
        ratios.Add r.Ratio
        ginis.Add r.Gini
    sw.Stop()
    let allPass = failed = 0
    printfn "batch_summary worlds=%d passed=%d failed=%d old_passed=%d/%d ratio_min=%.3f ratio_max=%.3f gini_min=%.3f gini_max=%.3f elapsed_s=%.1f"
        worlds passed failed oldPassed worlds (Seq.min ratios) (Seq.max ratios) (Seq.min ginis) (Seq.max ginis) sw.Elapsed.TotalSeconds
    printfn "M3_ACCEPTANCE=%s" (if allPass then "PASS" else "FAIL")
    if allPass 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"
        2

[<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 ()
    else
        runExisting argv