forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMemoryAtlasPlayback.swift
More file actions
203 lines (184 loc) · 7.96 KB
/
Copy pathMemoryAtlasPlayback.swift
File metadata and controls
203 lines (184 loc) · 7.96 KB
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
import Foundation
/// How the Brain Map grows over time.
///
/// Calendar time is a poor playback axis: years of silence between clusters
/// make replay pause on an almost-empty map. Missing `created_at` values also
/// decode as the Unix epoch, which used to pin the left of the scrubber at
/// 1969. Playback therefore compresses long quiet stretches, ignores epoch
/// placeholders, and still spends extra time inside dense imports so they do
/// not appear as one burst.
enum MemoryAtlasPlayback {
/// Accounts with more than this many entities get a four-second replay.
/// Smaller maps finish sooner so a handful of memories is not stretched.
static let fewEntityCeiling = 12
static let manyReplaySeconds: TimeInterval = 4
static let fewReplaySeconds: TimeInterval = 2
/// A calendar gap longer than this is treated as one short beat instead of
/// occupying a proportional slice of the four-second replay.
static let maxUncompressedGap: TimeInterval = 12 * 60 * 60
/// Unix-epoch sentinels from missing timestamps. Anything earlier is not a
/// real memory date and must not own the chronological axis.
static let earliestCredibleDate = Date(timeIntervalSince1970: 1_000_000_000)
/// Chronology after gap-compression. Density still gets the majority so a
/// same-day import can bloom one entity at a time.
static let chronologicalWeight = 0.18
static func duration(entityCount: Int) -> TimeInterval {
entityCount > fewEntityCeiling ? manyReplaySeconds : fewReplaySeconds
}
static func isCredible(_ date: Date) -> Bool {
date >= earliestCredibleDate
}
/// Dates that may stretch the axis. Epoch placeholders collapse onto the
/// first real timestamp so they still appear, just not in 1969. A graph with
/// no credible dates keeps the placeholders instead of inventing today.
static func effectiveDates(from dates: [Date]) -> [Date] {
guard let first = dates.first else { return [] }
let floor = dates.lazy.filter(isCredible).min() ?? first
return dates.map { isCredible($0) ? $0 : floor }
}
/// Running compressed time for a sorted date list. Each inter-event gap is
/// capped so a silent year cannot steal a third of the replay.
static func compressedOffsets(for dates: [Date]) -> [TimeInterval] {
guard let first = dates.first else { return [] }
var offsets: [TimeInterval] = [0]
offsets.reserveCapacity(dates.count)
var elapsed: TimeInterval = 0
var previous = first
for date in dates.dropFirst() {
elapsed += min(max(date.timeIntervalSince(previous), 0), maxUncompressedGap)
offsets.append(elapsed)
previous = date
}
return offsets
}
}
/// The time axis behind the atlas. Built from entity `createdAt` timestamps so
/// the user can scrub — or watch — their memory come into being. Playback is
/// density-aware: a tight imported/backfilled cluster is expanded into a short,
/// deterministic sequence instead of appearing as one unreadable burst. Dates
/// shown to the user always remain the original dates from memory data.
struct MemoryAtlasTimeline: Equatable {
struct Entry: Equatable {
let nodeID: String
let createdAt: Date
let playbackFraction: Double
}
let start: Date
let end: Date
/// Entity-birth counts across the density-expanded playback axis, for the
/// histogram. This describes animation pacing, not rewritten history.
let buckets: [Int]
let entries: [Entry]
let playbackFractionByNodeID: [String: Double]
var hasChronologicalRange: Bool { end > start }
var span: TimeInterval { max(end.timeIntervalSince(start), 1) }
func date(atFraction fraction: Double) -> Date {
let clamped = min(max(fraction, 0), 1)
guard let first = entries.first else {
return start.addingTimeInterval(span * clamped)
}
guard clamped > first.playbackFraction else { return first.createdAt }
// This deliberately steps to the last real creation date rather than
// interpolating an invented timestamp between two memories.
return entries[max(firstPlaybackIndex(after: clamped) - 1, 0)].createdAt
}
func fraction(for date: Date) -> Double {
guard let first = entries.first, let last = entries.last else { return 1 }
guard date > first.createdAt else { return first.playbackFraction }
guard date < last.createdAt else { return last.playbackFraction }
return entries[max(firstDateIndex(after: date) - 1, 0)].playbackFraction
}
func isVisible(nodeID: String, at fraction: Double) -> Bool {
(playbackFractionByNodeID[nodeID] ?? 1) <= min(max(fraction, 0), 1)
}
func visibleNodeCount(at fraction: Double) -> Int {
let clamped = min(max(fraction, 0), 1)
return firstPlaybackIndex(after: clamped)
}
func spawnProgress(nodeID: String, at fraction: Double) -> Double {
guard let bornAt = playbackFractionByNodeID[nodeID] else { return 0 }
let age = fraction - bornAt
let window = max(0.012, min(0.05, 5 / Double(max(entries.count, 1))))
guard age >= 0, age < window else { return 0 }
return 1 - age / window
}
/// Retained for the legacy Date-based preview path. Live replay uses
/// `spawnProgress(nodeID:at:)` so dense imports bloom one-at-a-time.
var spawnWindow: TimeInterval { span / 26 }
private func firstPlaybackIndex(after fraction: Double) -> Int {
var lower = 0
var upper = entries.count
while lower < upper {
let middle = lower + (upper - lower) / 2
if entries[middle].playbackFraction > fraction {
upper = middle
} else {
lower = middle + 1
}
}
return lower
}
private func firstDateIndex(after date: Date) -> Int {
var lower = 0
var upper = entries.count
while lower < upper {
let middle = lower + (upper - lower) / 2
if entries[middle].createdAt > date {
upper = middle
} else {
lower = middle + 1
}
}
return lower
}
static func make(from nodes: [KnowledgeGraphNode], bucketCount: Int = 40) -> MemoryAtlasTimeline? {
let orderedNodes = nodes.sorted {
if $0.createdAt == $1.createdAt { return $0.id < $1.id }
return $0.createdAt < $1.createdAt
}
guard orderedNodes.count > 1, let start = orderedNodes.first?.createdAt, let end = orderedNodes.last?.createdAt
else {
return nil
}
let effectiveDates = MemoryAtlasPlayback.effectiveDates(from: orderedNodes.map(\.createdAt))
let axisStart = effectiveDates.min() ?? start
let axisEnd = effectiveDates.max() ?? end
let hasChronologicalRange = axisEnd > axisStart
let compressedOffsets = MemoryAtlasPlayback.compressedOffsets(for: effectiveDates)
let compressedSpan = max(compressedOffsets.last ?? 0, 1)
// Chronology remains a minority signal for naturally distributed
// memories, while rank gives dense imports enough playback room to be
// comprehensible. Both inputs are monotonic, so this cannot reorder data
// or fabricate a date.
let chronologicalWeight = hasChronologicalRange ? MemoryAtlasPlayback.chronologicalWeight : 0
let densityWeight = 1 - chronologicalWeight
let denominator = Double(max(orderedNodes.count - 1, 1))
let entries = orderedNodes.enumerated().map { index, node in
let chronologicalFraction =
hasChronologicalRange
? compressedOffsets[index] / compressedSpan
: 0
let densityFraction = Double(index) / denominator
return Entry(
nodeID: node.id,
createdAt: effectiveDates[index],
playbackFraction: chronologicalWeight * chronologicalFraction + densityWeight * densityFraction
)
}
var buckets = Array(repeating: 0, count: max(bucketCount, 1))
for entry in entries {
let index = min(
buckets.count - 1,
max(0, Int(entry.playbackFraction * Double(buckets.count)))
)
buckets[index] += 1
}
return MemoryAtlasTimeline(
start: axisStart,
end: axisEnd,
buckets: buckets,
entries: entries,
playbackFractionByNodeID: Dictionary(lastWriteWins: entries.map { ($0.nodeID, $0.playbackFraction) })
)
}
}