How the state changes
Implementation reference · updated September 11, 2026
Every rule the attractor follows, in one place. The state rules live in
src/model.ts and snapshot retention in src/store.ts. The reference below
describes the current implementation, followed by the direction I want to test
next.
Current implementation
| Constant | Value | Meaning |
|---|---|---|
SEED_WEIGHT | 0.5 | Starting weight for seeded basins |
NEW_BASIN_WEIGHT | 0.4 | Starting weight for basins that emerge later |
MIN_WEIGHT / MAX_WEIGHT | 0.05 / 1.0 | Weight bounds |
DECAY_TARGET | 0.3 | Value untouched basins drift toward |
DECAY_RATE | 0.05 | Fraction of the gap closed per update |
MAX_DELTA | 0.3 | Hard clamp on model-proposed deltas |
MAX_KEYWORDS | 10 | Keyword slots per basin |
MAX_TRAJECTORY | 20 | Weight history kept per basin |
ACTIVE_THRESHOLD | 0.4 | Above this, a basin is listed as active in the injected context |
Weights
A basin's weight is its activity. Basins the model mentions move by the delta it proposes; basins it doesn't mention decay on their own.
touched: w ← clamp(w + Δ, 0.05, 1.0) Δ ∈ [−0.3, +0.3]
untouched: w ← w + (0.3 − w) × 0.05
The safeguards happen in two places. parseUpdate clamps each proposed delta to ±0.3 as the model's reply is read. applyUpdate then bounds the resulting weight to [0.05, 1.0]. The update prompt asks for deltas between −0.2 and +0.2 and reserves large ones for conversations deeply about a topic, so the clamp limits numeric proposals outside the permitted range. These are separate boundaries: direct calls to applyUpdate do not clamp the delta, and malformed nonnumeric deltas can still produce NaN. Callers should validate finite numeric deltas before applying an update.
"Touched" means "listed in the model's basin_updates", not "increased". A basin listed with a delta of 0 or a negative delta does not also decay on that update. Only a positive delta counts as the basin being used: it updates lastActive and increments conversationCount.
Seeded basins start at 0.5, so nothing is favoured at seed time. Basins that emerge later start at 0.4, so they have to earn their place. The floor is 0.05, not 0: automatic updates retain dormant basins, and they can reactivate. The REST API separately allows explicit deletion.
Decay half-life
Each untouched update closes 5% of the gap to 0.3, so the remaining gap after n updates is 0.95ⁿ of the original. Solving 0.95ⁿ = 0.5 gives n ≈ 13.5 updates. This is the "drifts, doesn't swing" property, and it's the one number to change if the attractor feels too sticky or too twitchy.
Delta size depends on the model
In the small sample recorded in the development notes, Haiku proposed a mean delta of +0.126 across five basin-update entries and Opus +0.083 across four. These counts refer to basin-update entries. Each comparison leg generated its own summary, making this a small example of variation across the full pipeline. See comparing models.
What I want to test next
The current rules treat an omitted basin differently from one given a zero delta, and direct callers can bypass the parser's delta clamp. The next experiment is a single update rule: relax every basin a little toward baseline, apply a bounded conversation signal, then enforce the weight limits.
That is a proposed comparison, not the running implementation. I want to test it against repeated positive signals, alternating signals, quiet stretches, and omitted-versus-zero updates before choosing the signal strength or changing stored state.
Entropy
Normalized Shannon entropy over the weights, treated as a distribution:
pᵢ = wᵢ / Σw
H = −Σ pᵢ log₂ pᵢ
H_norm = H / log₂(n) n = basin count
0 means one basin holds everything; 1 means weight is spread evenly. Edge cases: Σw = 0 returns 1, and a single basin returns 0.
Entropy describes the spread of the stored weights. They are normalized by their sum, so the metric reflects their proportions. If every basin rises together the proportions barely move. In the reproducible manual walkthrough, entropy goes from 1.000 to 0.951 while the Systems basin rises from 0.5 to 1.0.
Untouched basins approach 0.3 rather than disappearing. Whether a decay step raises or lowers entropy depends on the whole weight distribution.
Trajectory
Computed from the most recent step of each basin, not a longer trend:
cᵢ = |trajectoryᵢ[-1] − trajectoryᵢ[-2]|
c̄ = mean(cᵢ)
converging top basin grew and c̄ < 0.10
restructuring c̄ > 0.15
diverging c̄ > 0.05
stable otherwise
The rules are checked in that order, so "converging" wins whenever the top basin grew and average movement was small.
Connections
Connections are symmetric. When the model proposes a connection from A to B, both basins record the other. Connections are only added, never removed by an update.
New basins and phases
If the model proposes a new_basin whose slugified label doesn't already exist, it is added at weight 0.4 with a conversation count of 1. The prompt asks for this to be rare. Up to 5 emerging_patterns are carried forward each update; these are how the model flags concepts that fit no basin, but the code does not require a pattern to recur before accepting new_basin. The two fields are separate.
phase_shift: true increments the phase counter. The prompt reserves it for a fundamental change of direction.
Keywords
Capped at 10 per basin and deduplicated case-insensitively. When additions push a basin past 10, the oldest keywords are dropped and the basin's capHits counter increments.
After the 2nd cap hit, the basin is eligible for consolidation. Local CLI ingestion invokes a separate model call: its keywords are rewritten as ≤5 more general ones, consolidationCount increments, and capHits resets. If the consolidation reply can't be parsed, the existing keywords are kept.
The update function supports keyword removal, but the development notes report 115 additions and no removals across 40 logged updates. That development observation motivated a separate consolidation prompt. Local ingestion runs the consolidation flow; the hosted routes apply the standard update.
Other bounds
| Trajectory history | 20 points per basin |
| Snapshot history | 10 states |
| Active threshold | weight > 0.4 appears in the injected context |
| Emerging patterns | 5 carried forward |
The injected context
buildAttractorContext renders the state for the next conversation's system prompt. Active basins (weight > 0.4) are listed with their weight, a trend arrow from their last step, their description and their connections. Dormant basins are named only. The block asks the model to use it for continuity but integrate it naturally rather than announce it. See attractor context for the exact text.
The renderer includes the current state, with text length growing as basins and descriptions accumulate. A fixed token budget is planned for the personalization study. The calling application supplies the rendered block to its next model request.