From d94ec2dc875c6c45973c6d6d6730b4a57fadeb3e Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 23:12:20 +0800 Subject: feat(sim): M6a 谣言工作集容量上界+按天淘汰——DayIndex 派生字段/纯函数 trim/窗口化扫描/O(1) nextId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/LivingVillage.Kernel/Sim.fs | 66 ++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 10 deletions(-) (limited to 'src/LivingVillage.Kernel/Sim.fs') diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index 5445e7d..fd50fed 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,54 @@ module Sim = let rumorStrengthAt (nowTick: int64) (rumor: RumorEvent) : float32 = rumor.Strength * rumorDecayWeight nowTick rumor.Tick + /// 判断工作集是否需要裁剪:超过容量或存在早于保留窗口的条目。 + /// 列表约定为最新在前,故只需从前缀扫描 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 非递增,故只需扫描新鲜窗口;窗口外条目本就会被过滤掉。 world.Rumors + |> List.takeWhile (fun rumor -> world.Tick - rumor.Tick <= rumorFreshnessTicks) |> 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 + /// 工作集最新在前,头节点恒为最大 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 +766,7 @@ module Sim = let rumor = { Id = nextRumorId world Tick = world.Tick + DayIndex = rumorDayIndex world.Tick OriginTick = world.Tick Source = playerId Narrator = playerId @@ -742,7 +786,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 @@ -783,12 +827,12 @@ 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.takeWhile (fun rumor -> world.Tick - rumor.Tick <= rumorFreshnessTicks) |> 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) type private ChatParticipant = @@ -855,6 +899,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 +910,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 +930,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 -- cgit v1.2.3