summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/维护说明.md27
-rw-r--r--src/LivingVillage.Kernel.Tests/RumorTests.fs87
-rw-r--r--src/LivingVillage.Kernel/Sim.fs106
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs1
4 files changed, 194 insertions, 27 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 19ca201..0761aef 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -117,6 +117,33 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
全表扫描;③把 `latestRumorFor`/`rumorIsDuplicate` 限制到最近窗口。以上都需单开一单、
重跑 performance-baseline 与全部 digest 后由 Hermes 走冻结流程。
+## M6a 批验:谣言工作集容量上界与按天淘汰
+
+- 实施 M3 的三条修复方向:
+ ①`RumorEvent` 新增派生字段 `DayIndex = Tick / ticksPerDay`,不参与存档序列化,读档由 `Tick` 纯函数
+ 重建,故 v1/v2/v3 存档文本逐字节不变;
+ ②新增可变参数 `rumorCapacity`(默认 16384)与 `rumorRetentionDays`(默认 3,>= 新鲜窗口 3 天),
+ 纯函数 `trimRumors` 只保留最新 N 条并丢弃早于 `nowDay - rumorRetentionDays` 的条目;容量内且新鲜时
+ 零拷贝原样返回;两个前插点 `chooseDialogue`/`chat` 统一走 `consRumor`;
+ ③列表最新在前,`latestRumorFor`/`rumorIsDuplicate` 改早停递归(越窗即停、无中间列表),
+ `nextRumorId` 改读表头 O(1)。
+- 等价性:保留窗口(>= 3 天)覆盖两个查询的新鲜判定,窗口外条目本就会因 `rumorStrengthAt <
+ rumorMinimumStrength` 被过滤,故裁剪不改变任何查询结果;「表头为最大 Id」不变量在裁剪后保持。
+- 实测(本机单 worker,npcs=30,seed=42,`--cost-probe`;同机 M3 未裁剪基线见上节):
+
+ | days | 未裁剪 elapsed_s | M6a elapsed_s | 未裁剪 allocated_bytes | M6a allocated_bytes |
+ |---|---|---|---|---|
+ | 1 | 23.4(×3) | 23.4(×3) | 19,001,341,888 | 18,898,357,944 |
+ | 10 | 240.6 / 268.6 | 236.2 / 274.0 | 202,373,638,848 | 191,916,294,064 |
+ | 20 | ≈900(M3 记录) | 503.1 | — | 384,468,393,040 |
+
+ 10 天分配/gen0 稳定下降约 5%;超线性项改善更大:M3 记录 20 天 ≈45 s/天,M6a 后 20 天 ≈25 s/天
+ (≈1.8×),单日成本回归平稳。
+- 工作集实测(`evidence/` 探针,seed=42,npcs=30):第 5 天 `Rumors` 7796(收敛)vs 未裁剪 9821,
+ 两者表头 `Id` 同为 9820,证明创建序列不变、仅淘汰已不可用的旧条目。
+- 门槛:`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 三次一致,
+ `performance_determinism=PASS`;Kernel 89 / Desktop 94 全绿。
+
## 已验证命令
```bash
diff --git a/src/LivingVillage.Kernel.Tests/RumorTests.fs b/src/LivingVillage.Kernel.Tests/RumorTests.fs
index f52fca9..2cbb189 100644
--- a/src/LivingVillage.Kernel.Tests/RumorTests.fs
+++ b/src/LivingVillage.Kernel.Tests/RumorTests.fs
@@ -91,6 +91,29 @@ module private RumorHarness =
rumor.Depth)
|> String.concat "\n"
+ let rumor id tick dayIndex =
+ { Id = RumorId id
+ Tick = tick
+ DayIndex = dayIndex
+ OriginTick = tick
+ Source = NpcId 0
+ Narrator = NpcId 0
+ Receiver = NpcId 1
+ Parent = None
+ Depth = 0
+ Strength = 1.0f }
+
+ let withRumorBudget capacity retentionDays action =
+ let previousCapacity = rumorCapacity
+ let previousRetention = rumorRetentionDays
+ rumorCapacity <- capacity
+ rumorRetentionDays <- retentionDays
+ try
+ action ()
+ finally
+ rumorCapacity <- previousCapacity
+ rumorRetentionDays <- previousRetention
+
[<TestClass>]
type RumorTests () =
@@ -188,3 +211,67 @@ type RumorTests () =
sprintf "rumor id=2 tick=%d origin_tick=0 source=0 narrator=2 receiver=3 parent=1 depth=2 strength=0.250000 path=0,1,2" (2L * ticksPerDay) ]
Assert.AreEqual<string>(expected, Sim.rumorTraceText third)
+
+ [<TestMethod>]
+ member _.TrimWithinCapacityAndFreshWindowIsZeroCopy () =
+ RumorHarness.withRumorBudget 4 3L (fun () ->
+ let rumors =
+ [ RumorHarness.rumor 3L (3L * ticksPerDay) 3L
+ RumorHarness.rumor 2L (2L * ticksPerDay) 2L
+ RumorHarness.rumor 1L (1L * ticksPerDay) 1L ]
+ let trimmed = Sim.trimRumors (3L * ticksPerDay) rumors
+ Assert.IsTrue(System.Object.ReferenceEquals(rumors, trimmed), "within budget the list must be returned as-is")
+ Assert.AreEqual<RumorEvent list>(rumors, trimmed))
+
+ [<TestMethod>]
+ member _.TrimCapsToNewestRumors () =
+ RumorHarness.withRumorBudget 4 30L (fun () ->
+ let rumors = List.init 6 (fun index -> RumorHarness.rumor (int64 (5 - index)) (10L * ticksPerDay) 10L)
+ let trimmed = Sim.trimRumors (10L * ticksPerDay) rumors
+ Assert.AreEqual<int>(4, trimmed.Length)
+ let keptIds = trimmed |> List.map (fun rumor -> RumorHarness.rumorId rumor.Id)
+ CollectionAssert.AreEqual([| 5L; 4L; 3L; 2L |], List.toArray keptIds))
+
+ [<TestMethod>]
+ member _.TrimDropsRumorsOlderThanRetentionWindow () =
+ RumorHarness.withRumorBudget 100 3L (fun () ->
+ let rumors =
+ [ RumorHarness.rumor 3L (10L * ticksPerDay) 10L
+ RumorHarness.rumor 2L (9L * ticksPerDay) 9L
+ RumorHarness.rumor 1L (6L * ticksPerDay) 6L
+ RumorHarness.rumor 0L (5L * ticksPerDay) 5L ]
+ let trimmed = Sim.trimRumors (10L * ticksPerDay) rumors
+ let keptIds = trimmed |> List.map (fun rumor -> RumorHarness.rumorId rumor.Id)
+ CollectionAssert.AreEqual([| 3L; 2L |], List.toArray keptIds)
+ Assert.AreEqual<RumorEvent list>(trimmed, Sim.trimRumors (10L * ticksPerDay) trimmed))
+
+ [<TestMethod>]
+ member _.ChatKeepsWorkingSetWithinCapacityAndIdsStayMonotonic () =
+ RumorHarness.withRumorBudget 1 30L (fun () ->
+ let first = RumorHarness.chat 0 1 (RumorHarness.world 0L 4)
+ let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat first)
+ Assert.AreEqual<int>(1, first.Rumors.Length)
+ Assert.AreEqual<int>(1, second.Rumors.Length)
+ let head = second.Rumors |> List.head
+ Assert.AreEqual<int64>(1L, RumorHarness.rumorId head.Id)
+ Assert.AreEqual<RumorId option>(Some(RumorId 0L), head.Parent))
+
+ [<TestMethod>]
+ member _.CappedRumorsWithinCapacitySaveByteIdenticallyToUncapped () =
+ let run capacity =
+ RumorHarness.withRumorBudget capacity 30L (fun () ->
+ let first = RumorHarness.chat 0 1 (RumorHarness.world 0L 4)
+ let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat { first with Tick = ticksPerDay })
+ WorldSave.save second)
+ Assert.AreEqual<string>(run 8, run System.Int32.MaxValue)
+
+ [<TestMethod>]
+ member _.RumorDayIndexMirrorsTickAndSurvivesSaveRoundTrip () =
+ let world = RumorHarness.chat 0 1 (RumorHarness.world 0L 3)
+ let rumor = world.Rumors |> List.head
+ Assert.AreEqual<int64>(rumor.Tick / ticksPerDay, rumor.DayIndex)
+ match WorldSave.load (WorldSave.save world) with
+ | Error failure -> Assert.Fail($"round trip should load: {failure}")
+ | Ok restored ->
+ Assert.AreEqual<RumorEvent list>(world.Rumors, restored.Rumors)
+ Assert.AreEqual<int64>(rumor.DayIndex, (restored.Rumors |> List.head).DayIndex)
diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs
index 5445e7d..cedadfa 100644
--- a/src/LivingVillage.Kernel/Sim.fs
+++ b/src/LivingVillage.Kernel/Sim.fs
@@ -95,6 +95,9 @@ module Sim =
type RumorEvent =
{ Id: RumorId
Tick: int64
+ /// 该谣言所属模拟日(= Tick / ticksPerDay)。派生镜像字段,不参与存档序列化,
+ /// 以便按天确定性淘汰工作集;读档时由 Tick 纯函数重建,故 v1/v2/v3 存档逐字节不变。
+ DayIndex: int64
OriginTick: int64
Source: NpcId
Narrator: NpcId
@@ -269,6 +272,17 @@ module Sim =
let rumorHalfLifeTicks = ticksPerDay
let rumorMinimumStrength = 0.125f
+ /// 谣言工作集容量上界(参数化常量;测试可临时调小)。默认值远大于任何既有测试
+ /// 路径产生的谣言数,因此 append-only 前 N 条内与未裁剪版本逐字节等价;超界只保留最新 N 条。
+ let mutable rumorCapacity = 16384
+
+ /// 按天淘汰的保留天数。>= 新鲜窗口(rumorFreshnessTicks / ticksPerDay = 3 天),
+ /// 保证任何仍可能被 latestRumorFor/rumorIsDuplicate 使用的新鲜谣言都不会被裁掉。
+ let mutable rumorRetentionDays = 3L
+
+ /// 谣言所属模拟日(纯函数)。用于确定性的按天淘汰,读档时与 Tick 保持一致。
+ let rumorDayIndex (tick: int64) : int64 = tick / ticksPerDay
+
// 关系网派生(纯函数;仅在 dump/视图打开时调用,不进 step 热路径)
let relationHalfLifeTicks = ticksPerDay // HALF_LIFE = 1 模拟日(修复单位错误:原 86400L 实为 24 模拟分钟;1 模拟日 = ticksPerDay = 5,184,000 tick)
let relationThreshold = 0.5f // |rel| > 0.5 视为有关系
@@ -643,25 +657,55 @@ module Sim =
let rumorStrengthAt (nowTick: int64) (rumor: RumorEvent) : float32 =
rumor.Strength * rumorDecayWeight nowTick rumor.Tick
- let private latestRumorFor (receiver: NpcId) (world: World) : RumorEvent option =
- world.Rumors
- |> List.filter (fun rumor ->
- rumor.Receiver = receiver
- && rumor.Tick <= world.Tick
- && world.Tick - rumor.Tick <= rumorFreshnessTicks
- && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength)
- |> List.sortByDescending (fun rumor ->
- let (RumorId id) = rumor.Id
- rumor.Tick, id)
- |> List.tryHead
+ /// 判断工作集是否需要裁剪:超过容量或存在早于保留窗口的条目。
+ /// 列表约定为最新在前,故只需从前缀扫描 min(len, capacity+1) 条,无分配。
+ let private rumorsNeedTrim (cutoffDay: int64) (rumors: RumorEvent list) : bool =
+ let rec loop seen rest =
+ if seen > rumorCapacity then true
+ else
+ match rest with
+ | [] -> false
+ | rumor :: tail ->
+ if rumor.DayIndex < cutoffDay then true else loop (seen + 1) tail
+ loop 1 rumors
+
+ /// 纯函数裁剪:保留最新 rumorCapacity 条,并丢弃早于 (nowDay - rumorRetentionDays)
+ /// 的条目。容量内且全部新鲜时原样返回(零拷贝,保证既有路径逐字节不变)。
+ let trimRumors (nowTick: int64) (rumors: RumorEvent list) : RumorEvent list =
+ let cutoffDay = max 0L (rumorDayIndex nowTick - rumorRetentionDays)
+ if rumorsNeedTrim cutoffDay rumors then
+ rumors
+ |> List.takeWhile (fun rumor -> rumor.DayIndex >= cutoffDay)
+ |> List.truncate rumorCapacity
+ else
+ rumors
+
+ /// 追加一条谣言并顺手裁剪工作集;容量内且新鲜时等价于 `rumor :: rumors`。
+ let private consRumor (nowTick: int64) (rumor: RumorEvent) (rumors: RumorEvent list) : RumorEvent list =
+ trimRumors nowTick (rumor :: rumors)
+ let private latestRumorFor (receiver: NpcId) (world: World) : RumorEvent option =
+ // 工作集最新在前且 Tick 非递增:第一条命中的即“Tick 最大、Id 最大”,与原实现
+ // (filter 后 sortByDescending 取头)等价;窗口外无分配提前返回。
+ let rec loop (rest: RumorEvent list) : RumorEvent option =
+ match rest with
+ | [] -> None
+ | rumor :: tail ->
+ if world.Tick - rumor.Tick > rumorFreshnessTicks then None
+ elif rumor.Receiver = receiver
+ && rumor.Tick <= world.Tick
+ && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength then Some rumor
+ else loop tail
+ loop world.Rumors
+
+ /// 工作集最新在前,头节点恒为最大 Id(每次追加 `max+1`),故 O(1) 取下一个 Id。
+ /// 裁剪只丢尾部旧条目,不会丢头,因此该不变量在裁剪后仍成立。
let private nextRumorId (world: World) : RumorId =
- let maxId =
- world.Rumors
- |> List.fold (fun current rumor ->
- let (RumorId id) = rumor.Id
- max current id) -1L
- RumorId(maxId + 1L)
+ match world.Rumors with
+ | [] -> RumorId 0L
+ | head :: _ ->
+ let (RumorId id) = head.Id
+ RumorId(id + 1L)
let private dialogueAvailable (npc: Npc) : bool =
npc.Mind.Action <> Sleep && npc.Mind.Action <> Chat
@@ -723,6 +767,7 @@ module Sim =
let rumor =
{ Id = nextRumorId world
Tick = world.Tick
+ DayIndex = rumorDayIndex world.Tick
OriginTick = world.Tick
Source = playerId
Narrator = playerId
@@ -742,7 +787,7 @@ module Sim =
Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = avatarMemory } }
Npcs = newNpcs
Events = world.Events @ [ event ]
- Rumors = rumor :: world.Rumors }
+ Rumors = consRumor world.Tick rumor world.Rumors }
let withRumorAnnal =
appendAnnal
{ Tick = world.Tick
@@ -782,14 +827,19 @@ module Sim =
let private rumorIsDuplicate (request: ChatRequest) (parent: RumorEvent option) (world: World) : bool =
let parentId = parent |> Option.map (fun rumor -> rumor.Id)
- world.Rumors
- |> List.exists (fun rumor ->
- rumor.Narrator = request.Narrator
- && rumor.Receiver = request.Receiver
- && rumor.Parent = parentId
- && rumor.Tick <= world.Tick
- && world.Tick - rumor.Tick <= rumorFreshnessTicks
- && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength)
+ // 窗口外条目强度必 < rumorMinimumStrength,与原全表 exists 等价;命中或越窗即无分配返回。
+ let rec loop (rest: RumorEvent list) : bool =
+ match rest with
+ | [] -> false
+ | rumor :: tail ->
+ if world.Tick - rumor.Tick > rumorFreshnessTicks then false
+ elif rumor.Narrator = request.Narrator
+ && rumor.Receiver = request.Receiver
+ && rumor.Parent = parentId
+ && rumor.Tick <= world.Tick
+ && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength then true
+ else loop tail
+ loop world.Rumors
type private ChatParticipant =
| AvatarParticipant
@@ -855,6 +905,7 @@ module Sim =
| None ->
{ Id = nextRumorId world
Tick = world.Tick
+ DayIndex = rumorDayIndex world.Tick
OriginTick = world.Tick
Source = request.Narrator
Narrator = request.Narrator
@@ -865,6 +916,7 @@ module Sim =
| Some parent ->
{ Id = nextRumorId world
Tick = world.Tick
+ DayIndex = rumorDayIndex world.Tick
OriginTick = parent.OriginTick
Source = parent.Source
Narrator = request.Narrator
@@ -884,7 +936,7 @@ module Sim =
{ npc with
Mind = { npc.Mind with Memory = recordMemory world.Tick (Rumor rumor.Id) npc.Mind.Memory } }
{ chatted with Npcs = newNpcs }
- let updated = { withReceiverRumor with Rumors = rumor :: world.Rumors }
+ let updated = { withReceiverRumor with Rumors = consRumor world.Tick rumor world.Rumors }
ChatSucceeded(
Some rumor,
appendAnnal
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index b327c6d..f1b0be3 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -265,6 +265,7 @@ module WorldSave =
| _ -> invalid (sprintf "invalid %s parent" label)
{ Id = id
Tick = tick
+ DayIndex = rumorDayIndex tick
OriginTick = originTick
Source = source
Narrator = narrator