diff options
| -rw-r--r-- | docs/维护说明.md | 27 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/RumorTests.fs | 87 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 106 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 1 |
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 |
