Skip to content

Drive the Brain by session activity: adaptive heartbeat, sparse-seed rest, bounded in-place growth #62

Description

@kateebonner

Important

Problem

The mounted background Brain from slice 1 still behaves like the old inline strip: it boots the full ~130-node vault skeleton, runs a fixed allegro tempo, redraws every animation frame regardless of whether amico is working, never stops for a hidden window or prefers-reduced-motion, and its camera zooms out to swallow every new node. The design (#56) wants it to breathe with the session and open nearly empty. See #56 for the full why.

Approach

Make the Brain BEHAVE, without touching its physics: (1) route the host's existing session-busy signal into a new engine setActive, and gate drawing so it runs full musical tempo while active/in-flight and throttles to ~8fps at rest; (2) hard-pause (zero animation-frame ticks) when the window is hidden or reduced motion is set — the accessibility terminal, not a perf ease; (3) replace the skeleton preload with a sparse seed (the amico core alone) that grows only from real touches; (4) hold a fixed, viewport-anchored camera and a hard node cap so growth densifies in place instead of zooming out or growing without bound. The exact heartbeat machinery already exists in the earlier branch's engine — port it, don't reinvent it.

Scope

In: setActive(active) on the engine + host wiring from the session-busy signal; a shared draw-gate (full tempo when active/in-flight/unfurling, ~8fps at rest) honored by both the rAF loop and the manual tick(); the engine-side draw-gate of the window-hidden hard-pause and the reduced-motion hard-pause (no ticks) — driving the host visibility wiring #59 ports, not re-wiring it; sparse-seed boot (core only, grafts grow from touch()); a total-population node cap with recency-window eviction; a fixed viewport-anchored camera scale (no per-frame fit-to-farthest); a small stats() extension for headless assertions.
Out: Brain-as-background mount / one-loop / session-tab swap — slice 1 owns it (this slice only adds behavior to the mounted engine). Two-tint Glass + contrast — slices 2-3 (motion is independent of Glass; this slice is blocked only by 1). The frame-time perf monitor, motion-ease, and Panel-occlusion pause — slice 5 (a different trigger; coupling the two pauses is forbidden below). Retuning the musical clock / integrate-and-fire / saltatory physics — not in this slice (only seed, cap, and heartbeat gating change).


Acceptance Criteria

  • Sparse seed at boot. A freshly created engine with no touches reports exactly one node (the amico core), zero edges, zero claimed, zero atlas, cur === "amico". The vault skeleton (its nodes, edges, and demo traces) is never preloaded.
  • Grows only from activity. Touching N distinct commit labels yields exactly N graft nodes (plus the core) and N claims; each grafts one node over one edge near the current position; re-touching an already-grafted label adds no node. No node appears that was not produced by a touch.
  • Bounded population / recency window. Touching more distinct labels than the node cap never grows the population beyond core + cap; the survivors are the most-recently-touched grafts; an evicted node's edges and adjacency entries are gone. Neither the node set nor per-frame draw count grows without limit over an arbitrarily long touch stream.
  • Densify in place. Growing the graph from a handful of nodes up to the cap leaves the camera scale (stats().scale) within a fixed tolerance of its small-graph value — the frame does not zoom out to fit new nodes.
  • No cross-session persistence. Two independently created engines share no state (a touch on one is invisible to the other); a newly created engine always boots the sparse seed regardless of any prior engine's activity, and the engine neither reads nor writes any persistent/shared store.
  • Full tempo while active. With the manual clock, an engine in the active state draws (clears the canvas) on every tick().
  • ~8fps at rest. An at-rest engine (not active, nothing in flight, unfurl complete) driven at ~60fps intervals draws at most once per rest-frame window (≈8 draws/second), not once per tick.
  • Activity flips tempo. setActive(true) on an at-rest engine restores per-tick drawing; setActive(false) returns it to the rest cadence.
  • In-flight/unfurl force full tempo. A not-active engine that has a pulse in flight (full-motion mode) draws every tick until the pulse resolves, then falls back to the rest cadence.
  • Hidden hard-pause. After the host signals the window hidden, no tick() produces any drawing (zero new context calls) and no animation-frame chain remains scheduled; resuming on visible restores drawing.
  • Reduced-motion hard-pause. With reduced motion set, the engine never breathes at idle and runs no continuous loop at rest: once the short post-event burst window elapses with nothing in flight, further ticks produce no draws. A subsequent touch re-arms one bounded burst that draws again, then settles back to still.

Testing Decisions

Reuse the engine's existing headless harness (the recording-2d-context stub + manual tick() clock; animate:false, reduceMotion overridable) — cadence is asserted by counting clearRect calls, exactly as the current lifecycle and hostile input blocks already do. Rewrite the boot block's "builds the full skeleton graph" test to the sparse-seed contract (one node, zero edges/atlas — AC "sparse seed at boot"). Rewrite the live thought block: graft ids replace bare skeleton names, so cur/claim assertions target graft nodes, and "an unknown label grafts a node" generalizes to "every commit label grafts" (AC "grows only from activity"). Extend the hostile input block's "graft population is capped" test to assert the cap is the total population (boot = 1); that survivors are the most-recently-touched — via the re-touch delta (an evicted label re-grafts one node, a survivor re-touch adds none, reusing the "grows only from activity" observable, since stats() gains no node-enumeration surface); and that eviction leaks no edges (aggregate edges count stays ≤ core + cap) (AC "bounded population"). Extend the lifecycle block's pause/resume test with the hidden hard-pause assertion (zero draws while paused via clearRect count, resume restores drawing). The "no animation-frame chain remains scheduled" clause is a browser-path property — with animate:false the engine never schedules a frame, so it cannot be a failing-first headless test; verify it in the browser dev flow alongside the visibilitychange host glue. Create a heartbeat-cadence suite (reuse the animated pipeline block's drive() helper): full-tempo-when-active, ~8fps-at-rest, setActive flips both ways, and in-flight/unfurl forcing full tempo — all via clearRect counts over a driven clock. Create a reduced-motion hard-pause suite: drive past the burst window with nothing in flight → no draws; then touch() → a bounded burst draws → settles still. Create a sparse-seed/isolation suite: distinct touches produce matching node counts; two engines stay isolated; stats().scale stays in-band across growth to the cap (densify-in-place). Host glue for the busy signal (session-busy memo → setActive) is thin adapter code verified in the browser dev flow; the visibilitychange/IntersectionObserver → pause/resume host wiring is owned by #59 — reuse it, never re-register it. The behavioral contract lives in the headless engine suite above. Preserve the existing lossless-theme and NaN-proof-clock tests unchanged.

Key Decisions

  • Engine API additions. Add setActive(active: boolean); extend BrainEngineStats with active: boolean and scale: number (the current fixed camera zoom), keeping all existing fields. No node-enumeration field is added — counts + cur + re-touch deltas are the headless observation surface. Keep the existing pause()/resume() as the hidden hard-pause primitive; keep the existing reduceMotion matchMedia handling and add the burst-then-still tick gate to it.
  • Shared draw-gate. Factor the "should this frame draw?" decision into one path that both the rAF loop and manual tick() call: draw when active || inFlight() || unfurl < 1, else only when now - lastRender ≥ REST_FRAME_MS. Port REST_FRAME_MS (~125 ms ⇒ ~8fps) and NUDGE_MS (~3000 ms reduced-motion burst) from the earlier-branch engine.
  • One timebase for both windows. The rest window (REST_FRAME_MS) and the reduced-motion burst window (NUDGE_MS) are both measured against the same frame nowMs the gate receives — the manual/rAF timebase — not performance.now(). The port source keys the burst deadline off wall-clock (nudgeUntil = performance.now() + NUDGE_MS), which coincides with the frame clock only in the browser; rebase it onto nowMs so the headless drive() clock deterministically crosses both windows.
  • Sparse-seed boot. Boot builds only the amico core node; the vault dataset's nodes, edges, and traces are not added. Grafts keep the current live-<normalized-label> id scheme and near-source placement, so repeats resolve to the same node and growth stays local.
  • Bounded growth contract. A single fixed non-core population ceiling (reuse the existing eviction's GRAFT_CAP value as the total-graph cap). When a new graft would exceed it, evict the least-recently-touched (touchedAt) node that is not the current live node and not referenced by an in-flight pulse; remove its edges and adjacency. The atlas/charting path grants a node no permanent immunity — recency eviction always wins so a marathon session cannot grow unbounded.
  • Fixed camera. The background Brain holds a constant, core-centered, viewport-anchored zoom; drop the per-frame fit-to-farthest recompute (that was the strip's need). Growth fills the frame; the recency-bounded, near-source population keeps nodes in view.

Constraints & Invariants

  • Do not preload or render the vault skeleton at rest — the rejected "breathing skeleton atlas." The at-rest seed is the amico core alone.
  • Window-hidden and reduced-motion are hard terminals: no animation-frame chain may keep running (not merely skipped draws). Do not couple this pause to any frame-time budget — that trigger and the motion-ease valve belong to slice 5 and must remain independent code paths.
  • Hard-pause ownership: the host visibilitychange/IntersectionObserver → pause()/resume() wiring is owned and ported by Mount the Brain chat-wide and absorb the inline brain strip #59's mount — this slice must not register a second listener. It owns only the engine draw-gate, reduced-motion behavior, and rest cadence, driving the existing pause()/resume() primitive.
  • The node cap is a hard ceiling; no path (replay, charting, atlas keep) may exempt a node from recency eviction.
  • One engine instance per session; never persist or share the graph across sessions, instances, or any store.
  • Do not auto-zoom-to-fit for the background camera (that visually shrinks growth instead of densifying it).
  • Leave the physics untouched: musical clock, integrate-and-fire, refractory gating, saltatory conduction, monochrome + #fff676-for-live-thought-only. This slice adds seed/cap/heartbeat gating and nothing else; keep the lossless-theme and hostile-input (NaN clock, junk resize) guarantees green.
Prior Art / Patterns
  • Current shipped engine + its headless harnesspackages/ui/src/amicode/brain-engine.ts and brain-engine.test.ts (re-exported via packages/ui/src/components/brain-engine.tsx). This is the surface to change: today it builds the full BRAIN_DATA skeleton, has a GRAFT_CAP = 300 eviction for live-* grafts, runs a fixed allegro with tick() drawing every frame (no rest-throttle, no setActive), and its camera recomputes a fit-to-farthest zoom per frame. The recording-context + manual-tick() harness is exactly what the new cadence/seed/cap tests extend.
  • Host strippackages/app/src/pages/session/brain-strip.tsx already computes busy from sync.data.session_status[...] !== "idle" and feeds session touches/charts to the engine, but busy currently only drives the strip's expand/collapse, never tempo. Route it into setActive. packages/app/src/pages/session/message-timeline.tsx places the row. brain-ref.ts (tool → touch mapping) is unchanged and simply grafts under the sparse seed.
  • Earlier-branch heartbeat raw material (port target).../opencode-brain-atmosphere-wt/packages/ui/src/amicode/brain/engine.ts already implements the whole adaptive heartbeat: setActive, REST_FRAME_MS (~8fps) and NUDGE_MS, inFlight()/fullTempo, a self-driven frame()/ensureRunning() loop with the rest throttle, suspend()/resume() hard-stop, and the reduced-motion "burst around events, otherwise still" halt. Lift these into the current engine (rebasing the burst deadline off performance.now() onto the frame nowMs, per Key Decisions).
  • Earlier-branch host wiring raw material.../opencode-brain-atmosphere-wt/packages/ui/src/amicode/brain/brain-atmosphere.tsx wires ResizeObserver, document.visibilitychange + IntersectionObserver → suspend/resume, and a prefers-reduced-motion change listener. The visibility→hard-pause host wiring ships with Mount the Brain chat-wide and absorb the inline brain strip #59's mount; this slice only hardens the engine draw-gate beneath it.

Source

Part of #56. Durable record: ADR packages/app/docs/adr/0002-brain-as-chat-background.md (see the "adaptive heartbeat," "sparse seed," and rejected "breathing skeleton atlas" passages; vocabulary in packages/app/CONTEXT.md). Blocked by #59.

Metadata

Metadata

Assignees

No one assigned

    Labels

    afkImplement + merge unattended

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions