Skip to content

Commit 05b2eaf

Browse files
EtanHeyclaude
andauthored
fix(brainbar): energy-threshold early-exit in KG force-sim + onAppear timerActive reset (#249)
Root cause of 3 user 'BrainBar stuck' complaints — KGCanvasView.startSimulation pegs CPU at 96.8% because tick() runs a 30fps O(n²) loop with no early-exit and .onAppear doesn't reset timerActive after disappear. Fix: - Energy-threshold early-exit in the force sim — stop timer when KE < 0.01. - Reset timerActive semantics through a restart-safe simulation controller. - Double-start guard for repeated appearances. Tests: stable-graph converges <2s; re-appear triggers restart. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e8fa626 commit 05b2eaf

4 files changed

Lines changed: 182 additions & 10 deletions

File tree

brain-bar/Sources/BrainBar/KnowledgeGraph/KGCanvasView.swift

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ struct KGCanvasView: View {
88
@State private var scale: CGFloat = 1.0
99
@State private var lastScale: CGFloat = 1.0
1010
@State private var draggedNodeId: String?
11-
@State private var timerActive = true
11+
@State private var simulation = KGSimulationController()
1212
@State private var canvasSize: CGSize = .zero
1313

1414
var body: some View {
@@ -30,7 +30,7 @@ struct KGCanvasView: View {
3030
viewModel.loadGraph()
3131
startSimulation()
3232
}
33-
.onDisappear { timerActive = false }
33+
.onDisappear { simulation.stop() }
3434
}
3535

3636
private var graphCanvas: some View {
@@ -136,12 +136,8 @@ struct KGCanvasView: View {
136136
// MARK: - Simulation timer
137137

138138
private func startSimulation() {
139-
Task { @MainActor in
140-
while timerActive {
141-
try? await Task.sleep(for: .milliseconds(33)) // ~30fps
142-
viewModel.tick()
143-
}
144-
}
139+
guard !simulation.timerActive else { return }
140+
simulation.start { viewModel.tick() }
145141
}
146142

147143
// MARK: - Stats overlay
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Foundation
2+
3+
@MainActor
4+
final class KGSimulationController {
5+
typealias TickHandler = @MainActor () -> CGFloat
6+
typealias SleepHandler = @Sendable (Duration) async -> Void
7+
8+
static let defaultFrameDuration: Duration = .milliseconds(33)
9+
static let defaultEnergyThreshold: CGFloat = 0.01
10+
11+
private(set) var timerActive = false
12+
13+
private let frameDuration: Duration
14+
private let energyThreshold: CGFloat
15+
private let sleep: SleepHandler
16+
private var simulationTask: Task<Void, Never>?
17+
18+
init(
19+
frameDuration: Duration = KGSimulationController.defaultFrameDuration,
20+
energyThreshold: CGFloat = KGSimulationController.defaultEnergyThreshold,
21+
sleep: @escaping SleepHandler = { duration in
22+
try? await Task.sleep(for: duration)
23+
}
24+
) {
25+
self.frameDuration = frameDuration
26+
self.energyThreshold = energyThreshold
27+
self.sleep = sleep
28+
}
29+
30+
func start(tick: @escaping TickHandler) {
31+
guard !timerActive else { return }
32+
33+
timerActive = true
34+
simulationTask = Task { @MainActor [weak self] in
35+
guard let self else { return }
36+
defer { self.simulationTask = nil }
37+
38+
while self.timerActive && !Task.isCancelled {
39+
await self.sleep(self.frameDuration)
40+
guard self.timerActive && !Task.isCancelled else { break }
41+
42+
if tick() < self.energyThreshold {
43+
self.timerActive = false
44+
}
45+
}
46+
}
47+
}
48+
49+
func stop() {
50+
timerActive = false
51+
simulationTask?.cancel()
52+
simulationTask = nil
53+
}
54+
55+
deinit {
56+
simulationTask?.cancel()
57+
}
58+
}

brain-bar/Sources/BrainBar/KnowledgeGraph/KGViewModel.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ final class KGViewModel: ObservableObject {
9696

9797
// MARK: - Force-Directed Layout
9898

99-
func tick() {
100-
guard nodes.count > 1 else { return }
99+
func tick() -> CGFloat {
100+
guard nodes.count > 1 else { return 0 }
101101

102102
var forces = Array(repeating: CGVector.zero, count: nodes.count)
103103
let center = canvasCenter
@@ -142,12 +142,18 @@ final class KGViewModel: ObservableObject {
142142
}
143143

144144
// Apply forces with damping
145+
var totalKineticEnergy: CGFloat = 0
145146
for i in 0..<nodes.count {
146147
nodes[i].velocity.dx = (nodes[i].velocity.dx + forces[i].dx) * damping
147148
nodes[i].velocity.dy = (nodes[i].velocity.dy + forces[i].dy) * damping
148149
nodes[i].position.x += nodes[i].velocity.dx
149150
nodes[i].position.y += nodes[i].velocity.dy
151+
152+
let speedSq = (nodes[i].velocity.dx * nodes[i].velocity.dx) + (nodes[i].velocity.dy * nodes[i].velocity.dy)
153+
totalKineticEnergy += 0.5 * speedSq
150154
}
155+
156+
return totalKineticEnergy
151157
}
152158

153159
// MARK: - Helpers

brain-bar/Tests/BrainBarTests/KnowledgeGraphTests.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,115 @@ final class KGViewModelTests: XCTestCase {
315315
XCTAssertTrue(vm.selectedEntityChunks.first?.snippet.contains("Alice") ?? false)
316316
}
317317
}
318+
319+
@MainActor
320+
final class KGCanvasSimulationTests: XCTestCase {
321+
var db: BrainDatabase!
322+
var tempDBPath: String!
323+
324+
override func setUp() {
325+
super.setUp()
326+
tempDBPath = NSTemporaryDirectory() + "brainbar-kgcanvas-test-\(UUID().uuidString).db"
327+
db = BrainDatabase(path: tempDBPath)
328+
}
329+
330+
override func tearDown() {
331+
db.close()
332+
try? FileManager.default.removeItem(atPath: tempDBPath)
333+
try? FileManager.default.removeItem(atPath: tempDBPath + "-wal")
334+
try? FileManager.default.removeItem(atPath: tempDBPath + "-shm")
335+
super.tearDown()
336+
}
337+
338+
func testStableGraphStopsWithinTwoSecondsWorthOfFrames() async {
339+
let vm = KGViewModel(database: db)
340+
vm.canvasCenter = CGPoint(x: 300, y: 250)
341+
vm.nodes = makeStableFixture(center: vm.canvasCenter)
342+
vm.edges = []
343+
344+
let controller = KGSimulationController(
345+
frameDuration: .milliseconds(33),
346+
sleep: { _ in await Task.yield() }
347+
)
348+
349+
var tickCount = 0
350+
controller.start {
351+
tickCount += 1
352+
return vm.tick()
353+
}
354+
355+
await waitForSimulationToStop(controller)
356+
357+
XCTAssertFalse(controller.timerActive, "Stable graph should auto-stop once kinetic energy is low")
358+
XCTAssertLessThanOrEqual(
359+
tickCount,
360+
60,
361+
"Stopping within 60 frames matches ~2s on the real 30fps timer"
362+
)
363+
}
364+
365+
func testRestartAfterIdleStartsSimulationAgain() async {
366+
let controller = KGSimulationController(
367+
frameDuration: .milliseconds(33),
368+
sleep: { _ in await Task.yield() }
369+
)
370+
371+
var tickCount = 0
372+
let tick: @MainActor () -> CGFloat = {
373+
tickCount += 1
374+
return 0
375+
}
376+
377+
controller.start(tick: tick)
378+
await waitForSimulationToStop(controller)
379+
380+
XCTAssertFalse(controller.timerActive, "Controller should go idle after crossing the energy threshold")
381+
382+
controller.start(tick: tick)
383+
await waitForSimulationToStop(controller)
384+
385+
XCTAssertEqual(tickCount, 2, "A previously idle simulation should restart on the next appearance")
386+
}
387+
388+
private func waitForSimulationToStop(_ controller: KGSimulationController, iterations: Int = 200) async {
389+
for _ in 0..<iterations where controller.timerActive {
390+
await Task.yield()
391+
}
392+
}
393+
394+
private func makeStableFixture(center: CGPoint) -> [KGNode] {
395+
let radius: CGFloat = 66.0
396+
return [
397+
KGNode(
398+
id: "a",
399+
name: "Alice",
400+
entityType: "person",
401+
importance: 5,
402+
position: CGPoint(x: center.x + radius, y: center.y),
403+
velocity: .zero
404+
),
405+
KGNode(
406+
id: "b",
407+
name: "BrainLayer",
408+
entityType: "project",
409+
importance: 5,
410+
position: CGPoint(
411+
x: center.x - radius / 2,
412+
y: center.y + (sqrt(3) * radius / 2)
413+
),
414+
velocity: .zero
415+
),
416+
KGNode(
417+
id: "c",
418+
name: "Codex",
419+
entityType: "agent",
420+
importance: 5,
421+
position: CGPoint(
422+
x: center.x - radius / 2,
423+
y: center.y - (sqrt(3) * radius / 2)
424+
),
425+
velocity: .zero
426+
),
427+
]
428+
}
429+
}

0 commit comments

Comments
 (0)