Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe.go
T
AskaEth 54e32304f2 fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**,
生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行,
而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。
修法(结构性,不是补丁):
- 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与
  **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少;
  方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。
  补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。
- tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高:
  语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109)
  的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。
  实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。
- 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、
  TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、
  TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度
  归零 ⇒ 第四条红。

r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map,
`temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核
(候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例:
TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。

r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、
「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内;
「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。

实测(生产库只读 mode=ro,active 3667):
- dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15;
  取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2
  (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。
- 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条
  逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。
- 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74%
  且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」
  10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:29:02 +08:00

442 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"sort"
"strings"
"time"
)
// 本文件是 `dedupe_group` 候选动作(2026-10-24,M34/M27)的**簇形成**与候选构造。
//
// 动因是 M27 只读体检的定量结论(`DevDocs/audit/记忆库价值体检-2026-10-24.md`):
// active 3657 条里可复用 ≈32%,而「近似冗余」≈50%;但整理层 10-14 起**一条候选都不产**
// —— 因为它的合并判据是**包含关系**(`judgeContainment`),而问题的形态是
// 「同一件事换了个说法」:`助手名为 Cyrene。` 与 `助手/人格名为 Cyrene。` 互不包含。
//
// 🔴 **为什么不放大 `containNone`**(体检报告 §9-1 的两条路里选了后一条):
// `containNone` 是一条**护栏**(防「冰美式 vs 红牛」这类同主题不同事实被合并),
// 放宽它等于把护栏拆掉。因此这里新增一个**更窄**的动作:它不改变任何一对的
// 包含判定,只在「簇」这个粒度上认两种**机械可判**的同一性:
//
// 1. **同 scope + 同 kind + 完全相同 tag 集**(tag 集非空,且每个 tag ≥2 字、
// 至少 2 个 tag —— 体检报告 §7 第 2 批的建议:排除 `('作息','工作')` 这类
// 过于通用的 tag 集,宁可少减也不误杀);
// 2. **前 12 个字相同**(`dedupeGroupPrefixRunes`,与体检报告 R2 同一口径),
// 并附加**相似度下限** `dedupeGroupPrefixSimilarity` 与**同 kind** 两条收窄:
// 前者挡住「同一开头、后半句讲了别的实体」的对(`主人喜欢用「喵」…` vs
// `主人喜欢用「汪」…` 这类同构不同事实),后者避免「合并顺手改了分类」——
// kind 的改动仍只走 reclassify 那条路(design/23 §5.1 T0-6)。
//
// 分区(persona_id + scope + project_id)是**硬门**:候选只在一个分区内形成,
// 跨 scope 的同事实(体检报告 §7 第 3 批,83 簇/421 行)**明确不做**(会动 persona
// 写权语义,塔已拍不做)。
//
// 每簇的**安全门**与既有合并路径**逐条同源**(不新造一套判据):
// - 群聊/陌生人来源 ⇒ 排除(design/23 §5.2-9、§6,纵向防御同 tidy_rules);
// - 含绝对日期/时刻 ⇒ 排除(那些走 archive,design/23 §5.1 T0-5);
// - 含有时限状态词(假期/期间/本周…)⇒ 排除(§5.2-1);
// - 置信 < tidyLowConfidenceThreshold ⇒ 排除(§5.2-6);
// - 数值 token 与存活行不一致 ⇒ 该成员不入簇(§5.2-2);
// - 否定标记与存活行不一致 ⇒ 该成员不入簇(§5.2-3「X vs 非X 是覆盖不是重复」)。
//
// 保留规则(体检报告 §7 第 2 批的同一条):**内容最长(超集) > merge_count 大 >
// created_at 早**,仍并列时按 id 字典序(确定性)。「最长」优先是因为簇里的冗余
// 大多是「把同一件事越写越全」,最长那条通常信息量最大;merge_count 大说明它已经
// 吸收过别的表述;两者都并列时留最早的那条(它是 first_seen,时间跨度落在它身上)。
const (
// TidyDedupeGroup 是「机械簇去重」的动作名(design/23 §10 的 action 枚举新增值)。
//
// 它与 `merge` 的差别只在**判据**(簇的同一性 vs 事实集合的包含),落库形态完全
// 相同:一簇一条候选,`source_ids` = 被吸收的行,`target_id` = 存活行,应用是
// **软合并**(被吸收行 → status='merged' + merged_into),因此**可逐条 revert**。
//
// 🔴 **不在自动档里**:一次点击会改多行,体检报告 §7.2 要求在环核对(先做 100 簇、
// 人读 20 簇再放开),因此它**只走人工/批量点**,不进 tidy_auto 的白名单。
TidyDedupeGroup TidyAction = "dedupe_group"
// tidyReasonDedupeGroup 是 dedupe_group 候选的理由标签(落 reason 列)。
tidyReasonDedupeGroup = "dedupe_group"
// dedupeGroupPrefixRunes 是「前 N 字相同」的 N(体检报告 R2 的实测口径)。
dedupeGroupPrefixRunes = 12
// dedupeGroupMinTagRunes / dedupeGroupMinTagCount 是 tag 集簇的门槛:
// 每个 tag 至少 2 个字、至少 2 个 tag。单字 tag(「喵」「住」)与单个 tag
// 太宽,簇会退化成「同主题就合并」——那正是体检报告 §7 第 2 批警告的形态。
dedupeGroupMinTagRunes = 2
dedupeGroupMinTagCount = 2
// dedupeGroupPrefixSimilarity 是前缀簇成员的**附加**相似度下限(词项余弦,
// 与召回的 lexicalSimilarity 同一个函数、同一个量纲)。
//
// 这条收窄是**必须的**:语料实测 871 对「前 12 字相同」的候选对里,相似度分布从
// 0.11 到 0.99 —— 最低那批是「同开头、不同事实」(`助手(persona)的说话风格…`
// vs `助手(persona)的象征花为蓝玫瑰…`,0.111),合并它们是**丢事实**。
// 按阈值看语料(同一批 871 对):
//
// ≥0.60 → 40% ≥0.70 → 14% ≥0.75 → 6% ≥0.80 → 2% ≥0.85 → 1%
//
// 0.70 取在这里:≥0.70 的 120 对里抽 25 对人读**全是同一句话的改写**,而 0.11~0.35
// 段全是不同事实 ⇒ 0.70 是「既能捞回改写、又不碰同开头不同事实」的那一档。
// 它比 tidy 的召回门(0.80)低是刻意的:召回门只是「值不值得看一眼」,而这里是
// 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性
// 由「前 12 字」给出),这条下限只是把明显不同的对排除掉。
dedupeGroupPrefixSimilarity = 0.70
// dedupeGroupTagSetSimilarity 是 **tag 集簇**成员的相似度下限(r1 复审 P1 要求
// 「tag_set 簇也要过相似度下限,不能只有 prefix12 走这道门」)。
//
// ⚠️ 它取 **0.10**,比前缀簇那档(0.70)低一个量级,而且**刻意如此** ——
// 语料实测(active 3667、已过取值门/数值门/否定门的 503 对 tag 集成员对):
//
// floor 0.10 → 保留 98% floor 0.20 → 86% floor 0.30 → 63%
// floor 0.40 → 40% floor 0.50 → 20%
//
// 而**挡住的那 60% 全是同一事实的改写**,不是「不同事实」——例如
// `用户可能需要提醒吃早饭,容易空着肚子出门。`(0.047)对
// `早餐习惯:吃半碗炒面,需搭配牛奶或鸡蛋…`,或 `叶酱有工作,白天要上班…`(0.109)
// 对 `主人是上班族,工作日上班、单位有食堂…`。原因是语料里同一事实的多种写法
// 共享的是**语义**而不是二元组,词项余弦天然偏低。
//
// ⇒ 这道门的定位被数据改了:它只挡「正文几乎没有任何共同词项」的退化对
// (同 tag 却各说各话),**不是**同一性判据。同一性由三件套决定:数值门、
// 否定门、(P1 新增的)**取值门**。抬高这道门只会删掉合法去重、并不会多挡住
// 危险对 —— 需要更准的判据时该走 T1 模型层(不在本切片)。
dedupeGroupTagSetSimilarity = 0.10
)
// dedupe_group 的证据字段(进 TidyEvidence,供面板 diff 预览与运维判读)。
const (
// tidyDedupeRuleTagSet / tidyDedupeRulePrefix 是簇的判据名(可审计)。
tidyDedupeRuleTagSet = "tag_set"
tidyDedupeRulePrefix = "prefix12"
// 保留规则名(evidence.kept_reason):存活行为什么是它。
tidyDedupeKeepLongest = "longest_content"
tidyDedupeKeepMergeCount = "merge_count"
tidyDedupeKeepOldest = "oldest_created_at"
tidyDedupeKeepID = "id_order"
)
// tidyDedupeKey 是簇键:判据 + 键值。两者一起进 evidence,便于复核「为什么它们同簇」。
type tidyDedupeKey struct {
rule string
key string
}
// tidyDedupeCluster 是一个待落候选的簇:存活行 + 被吸收行。
type tidyDedupeCluster struct {
Rule string
Key string
Survivor MemoryItem
Absorbed []MemoryItem
// KeptReason 是存活行胜出的那条规则(evidence.kept_reason 的来源)。
KeptReason string
}
// tidyDedupeStats 是分区内 dedupe 分簇的观测口径(进 TidyStats)。
type tidyDedupeStats struct {
// Groups 是**真正产出候选**的簇数。
Groups int
// Rows 是这些簇**可减少的 active 行数**(被吸收行合计)——「预计减少行数」。
Rows int
}
// dedupeInPartition 是 runPartition 调用点:读 merge_count、把**已被既有候选覆盖**的
// 条目屏蔽掉,再分簇产候选。
//
// 屏蔽(covered)是「不产生第二条待办」的落点:一对已经拿到 archive/merge 候选的
// 条目不再进簇。它是纯粹的减法(覆盖集合由前一步的**产物**给出),因此不是第二套判据。
func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items []MemoryItem,
existing []TidyCandidate) ([]TidyCandidate, tidyDedupeStats) {
covered := make(map[string]struct{}, len(existing)*2)
for _, c := range existing {
for _, id := range c.SourceIDs {
covered[id] = struct{}{}
}
if c.TargetID != "" {
covered[c.TargetID] = struct{}{}
}
}
// 元信息(merge_count + `expires_at` 是否非空)**不是可降级的旁支**:它承载的是
// 「临时层行绝不进簇」这条数据安全红线(临时行 7 天后会被后台物理删除,
// design/23 §19;让长期事实并进一个将被删除的存活行 = 信息不可达)。
//
// 因此查询失败时**这个分区这一轮不分簇**(fail-safe 的方向是「少做」而不是
// 「猜」):成本只是下一轮再来,而猜错的代价是不可逆的。
// 这是 r1 复审 P2-1 的落点 —— 旧实现降级成 nil map 后 `temporary` 恒为 false,
// 恰好把那道红线绕开了。
meta, err := t.deps.Store.dedupeItemMeta(ctx, p)
if err != nil {
t.log.Warn("memory tidy dedupe skipped: item meta unavailable",
"partition", p.label(), "error", err)
return nil, tidyDedupeStats{}
}
return t.dedupeCandidates(items, meta, covered, t.now().UTC())
}
// dedupeCandidates 在**一个分区内**把 items 分成机械簇并产候选。它是纯函数式的
// (只读入参、无 IO、无模型),因此可完全单测,也在试算轮与真跑轮**共用**
// (两条路径都从 runPartition 进来,见 tidy.go)。
func (t *Tidier) dedupeCandidates(items []MemoryItem, meta map[string]tidyDedupeMeta,
covered map[string]struct{}, now time.Time) ([]TidyCandidate, tidyDedupeStats) {
clusters := dedupeClusters(items, meta, covered)
if len(clusters) == 0 {
return nil, tidyDedupeStats{}
}
out := make([]TidyCandidate, 0, len(clusters))
stats := tidyDedupeStats{}
for _, cl := range clusters {
out = append(out, dedupeGroupCandidate(cl, now))
stats.Groups++
stats.Rows += len(cl.Absorbed)
}
return out, stats
}
// dedupeClusters 把条目分组、按确定性顺序认领成员、并为每簇选出存活行。
//
// 「每一条只进一个簇」是靠**全局认领集合**保证的(大簇优先、键字典序次之):
// 一条记忆同时出现在两个簇里会让两次应用互相打脸(第二次的 `AND status='active'`
// 条件更新如实跳过,但面板会看到一条永远 skip 的候选)。
func dedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) []tidyDedupeCluster {
groups := dedupeGroupItems(items, meta, covered)
ordered := dedupeOrderedGroups(groups)
return claimDedupeClusters(items, meta, ordered)
}
// dedupeGroupItems 把可进簇的条目按簇键归组(键 → 成员下标)。
func dedupeGroupItems(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) map[string]*tidyDedupeGroup {
groups := make(map[string]*tidyDedupeGroup)
for idx := range items {
if _, taken := covered[items[idx].ID]; taken {
continue
}
if meta[items[idx].ID].temporary {
// 临时层行不许进簇(见 tidyDedupeMeta.temporary 的说明)。
continue
}
if !dedupeGroupEligible(items[idx]) {
continue
}
for _, key := range dedupeClusterKeys(items[idx]) {
g := groups[key.key]
if g == nil {
g = &tidyDedupeGroup{rule: key.rule, key: key.key}
groups[key.key] = g
}
g.members = append(g.members, idx)
}
}
return groups
}
// dedupeOrderedGroups 只留下真正的簇(≥2 条)并按**确定性顺序**排好:大簇优先
// (可减行数最多的先落),同大小按 (key, rule) 字典序 —— 与 map 的随机遍历顺序无关,
// 因此同一批数据反复运行产出同一批候选(幂等键随之稳定)。
func dedupeOrderedGroups(groups map[string]*tidyDedupeGroup) []*tidyDedupeGroup {
ordered := make([]*tidyDedupeGroup, 0, len(groups))
for _, g := range groups {
if len(g.members) < 2 {
continue
}
ordered = append(ordered, g)
}
sort.Slice(ordered, func(i, j int) bool {
if len(ordered[i].members) != len(ordered[j].members) {
return len(ordered[i].members) > len(ordered[j].members)
}
if ordered[i].key != ordered[j].key {
return ordered[i].key < ordered[j].key
}
return ordered[i].rule < ordered[j].rule
})
return ordered
}
// claimDedupeClusters 逐簇认领成员并产簇:**每一条只进一个簇**(靠全局认领集合),
// 存活行与全部被吸收行一起认领 —— 存活行绝不能再当别簇的被吸收行。
func claimDedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta,
ordered []*tidyDedupeGroup) []tidyDedupeCluster {
claimed := make(map[int]struct{}, len(items))
out := make([]tidyDedupeCluster, 0, len(ordered))
for _, g := range ordered {
free := make([]tidyDedupeMember, 0, len(g.members))
for _, idx := range g.members {
if _, taken := claimed[idx]; taken {
continue
}
free = append(free, tidyDedupeMember{item: items[idx], mergeCount: meta[items[idx].ID].mergeCount})
}
if len(free) < 2 {
continue
}
survivor, keptReason := pickDedupeSurvivor(free)
absorbed := dedupeAbsorbable(g.rule, survivor, free)
if len(absorbed) == 0 {
continue
}
keep := make(map[string]struct{}, len(absorbed)+1)
keep[survivor.item.ID] = struct{}{}
for _, it := range absorbed {
keep[it.item.ID] = struct{}{}
}
for _, idx := range g.members {
if _, ok := keep[items[idx].ID]; ok {
claimed[idx] = struct{}{}
}
}
out = append(out, tidyDedupeCluster{
Rule: g.rule, Key: g.key, Survivor: survivor.item, Absorbed: dedupeItems(absorbed),
KeptReason: keptReason,
})
}
return out
}
// tidyDedupeGroup 是内部用的分组累加器(键 → 成员下标)。
type tidyDedupeGroup struct {
rule string
key string
members []int
}
// dedupeGroupEligible 报告一条记忆能不能进机械簇。它是既有硬门/否决规则的**并集**,
// 因此 dedupe_group 的判定面严格窄于 `judgePair`(后者还要求包含关系)。
func dedupeGroupEligible(it MemoryItem) bool {
if strings.TrimSpace(it.ID) == "" {
// 没有 id 的行不可能被候选引用(候选靠 source_ids / target_id 指向真实行),
// 把它放进簇只会产出一条永远 skip 的候选。纵深防御:这条在落库数据上不会出现。
return false
}
if nonOwnerSource(it.SourceConversation) {
// 群聊/陌生人来源只读不写(design/23 §5.2-9、§6):连簇都不进。
return false
}
if hasAbsoluteTimestamp(it.Content) {
// 一次性事件走 archive(§5.1 T0-5),不进合并。
return false
}
if hasTemporalState(it.Content) {
// 有期限状态(假期/期间/本周…)不是长期事实(§5.2-1)。
return false
}
if it.Confidence < tidyLowConfidenceThreshold {
// 低置信是**否决门**(§5.2-6):低置信条目的孪生被它挡住了 2479 对,
// 那正是体检报告 §6.2 的最大否决项。dedupe_group 不越过这道门。
return false
}
if len([]rune(it.Content)) < minTidyContentRunes {
return false
}
return true
}
// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。
//
// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含**
// `merge_count`(它只写不读),而「存活行选谁」的次要判据正是它(合并次数多的那条
// 已经吸收过别的表述,留它信息量最大)。为一个次要判据去改 13 列读面会牵动
// Get/List*/preinsert/tidy 四处 SQL,风险远大于收益;改为按分区一次轻量查询把
// id → merge_count 取出来(见 Store.dedupeMergeCounts)。
type tidyDedupeMember struct {
item MemoryItem
mergeCount int
}
// dedupeItems 把成员视图降回 MemoryItem(簇只对外暴露记忆行)。
func dedupeItems(members []tidyDedupeMember) []MemoryItem {
out := make([]MemoryItem, 0, len(members))
for _, m := range members {
out = append(out, m.item)
}
return out
}
// pickDedupeSurvivor 按「内容最长 > merge_count 大 > created_at 早 > id 字典序」选存活行,
// 并返回命中的那条规则名(进 evidence.kept_reason,回答「为什么留下的是它」)。
func pickDedupeSurvivor(members []tidyDedupeMember) (tidyDedupeMember, string) {
best := members[0]
for _, it := range members[1:] {
switch compareDedupePriority(it, best) {
case -1:
best = it
case 0:
if it.item.ID < best.item.ID {
best = it
}
}
}
return best, dedupeKeepReason(members, best)
}
// compareDedupePriority 比较两条记忆谁更该存活:-1 表示 a 优先,1 表示 b 优先,
// 0 表示按 id 决胜(调用方处理)。
func compareDedupePriority(a, b tidyDedupeMember) int {
if la, lb := len([]rune(a.item.Content)), len([]rune(b.item.Content)); la != lb {
if la > lb {
return -1
}
return 1
}
if a.mergeCount != b.mergeCount {
if a.mergeCount > b.mergeCount {
return -1
}
return 1
}
if !a.item.CreatedAt.Equal(b.item.CreatedAt) {
if a.item.CreatedAt.Before(b.item.CreatedAt) {
return -1
}
return 1
}
return 0
}
// dedupeKeepReason 报告存活行是靠哪一条胜出的(证据面)。三条判据依次判「是否唯一
// 胜出」,都并列时落到最早创建(created_at)那条 —— 与 compareDedupePriority 同序。
func dedupeKeepReason(members []tidyDedupeMember, survivor tidyDedupeMember) string {
longest := len([]rune(survivor.item.Content))
uniqueLongest := true
uniqueMergeCount := true
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if len([]rune(it.item.Content)) == longest {
uniqueLongest = false
}
if it.mergeCount == survivor.mergeCount {
uniqueMergeCount = false
}
}
switch {
case uniqueLongest:
return tidyDedupeKeepLongest
case uniqueMergeCount:
return tidyDedupeKeepMergeCount
case survivor.item.CreatedAt.Before(earliestOtherCreatedAt(members, survivor)):
return tidyDedupeKeepOldest
default:
return tidyDedupeKeepID
}
}
// earliestOtherCreatedAt 返回除存活行以外最早创建的时刻(用于区分「最早胜出」与
// 「id 决胜」这两种证据)。空簇成员只在单条簇里出现,那时调用方不会问证据。
func earliestOtherCreatedAt(members []tidyDedupeMember, survivor tidyDedupeMember) time.Time {
var earliest time.Time
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if earliest.IsZero() || it.item.CreatedAt.Before(earliest) {
earliest = it.item.CreatedAt
}
}
return earliest
}