Go's Finite-State Machine and atomic.Pointer for a Regime-State Engine
Hysteresis dwell-timers, lock-free reads, and the transition-broadcast fan-out.
Many hands read the regime each second. One hand changes it each week. Which primitive fits that shape?
A mutex protects a value many writers contend for. The published regime has the opposite profile: a crowd of readers, a lone writer, and writes that are rare. Reaching for a mutex here makes every reader queue behind a lock that is almost never held for writing. Go has a primitive built for exactly this asymmetry, and the day's Ops lesson handed us the design that needs it.
Today's Adversarial-Markets lesson specified a regime service with a strict separation: a filter that emits a twitchy posterior every bar, and a publication layer that changes the official regime label only when a Two-Threshold Dwell clears. This lesson builds the publication layer in Go. Three primitives carry it: a finite-state machine that defines the legal transitions, an atomic.Pointer that publishes the current regime for lock-free reads, and a time.Timer plus a channel that arm the dwell and broadcast the change.
§ IFrame
Bodner's pointer chapter draws the line the design rests on. A pointer is the address of a value, and two pieces of code holding the same pointer see the same value change under them (Learning Go, Ch 6, pp 144-145). That sharing is what makes atomic.Pointer work: the writer swaps the address the readers dereference, and a reader either sees the whole old regime or the whole new one, never a half-written mix. The atomic swap is the publication.
The concurrency chapter draws the second line. Bodner's rule of thumb is to reach for channels when goroutines need to coordinate or pass ownership of data, and for the sync package's primitives, including the atomic operations, when you are guarding access to a shared field that many goroutines read (Ch 10, pp 223-249). The regime engine needs both, for two different jobs. The published value is shared state that the gate and the kill-switch read constantly, so it wants an atomic. The transition event is a thing that happens once and must reach every interested goroutine, so it wants a channel. Picking the right tool per job is the lesson under the lesson.
§ IILanguage Idiom: The State Machine as a Typed Transition Table
A regime is one of a closed set, so it models cleanly as a Go integer constant generated by iota. The legal transitions form a map, and an illegal transition is a map miss, which the engine can refuse rather than perform.
type Regime int
const (
RiskOn Regime = iota
RiskOff
Choppy
HighVol
)
func (r Regime) String() string {
return [...]string{"risk-on", "risk-off", "choppy", "high-vol"}[r]
}
type Published struct {
Regime Regime
Posterior float64
EnteredAt time.Time
Sequence uint64
}
The Published struct is the unit the writer swaps and the readers dereference. It carries the regime, the posterior that was on it when it was published, the time it was entered, and a monotonically rising sequence number. The sequence matters: a reader that caches a regime can compare sequence numbers to know whether what it holds is stale without comparing the regimes themselves. Bundling the metadata into one immutable struct, published by a single pointer swap, is what lets a reader take a consistent snapshot of regime-plus-context in one atomic load.
The Regime values are deliberately a closed enum rather than strings. A string regime invites typos and silent mismatches; the typed constant makes an illegal regime a compile error, and the transition table can be exhaustive because the set is known.
§ IIICode Worked Example: The Engine
The engine holds the published pointer and the dwell machinery. Readers touch only the atomic. The single writer goroutine owns everything else.
type Engine struct {
current atomic.Pointer[Published]
seq atomic.Uint64
transitions map[Regime]map[Regime]bool
subs []chan Published
mu sync.Mutex
}
func NewEngine(initial Regime, table map[Regime]map[Regime]bool) *Engine {
e := &Engine{transitions: table}
first := &Published{Regime: initial, Posterior: 1.0, EnteredAt: time.Now(), Sequence: 0}
e.current.Store(first)
return e
}
func (e *Engine) Current() Published {
return *e.current.Load()
}
Current is the whole reader-facing surface, and it never blocks. A reader calls Load on the atomic pointer, dereferences it, and returns a copy of the struct. A thousand goroutines can call Current in the same microsecond and none of them waits on any other, because an atomic load is not a lock. This is the asymmetry paying off: the cost the design pushes onto the rare writer buys the frequent reader a wait-free read.
The writer applies a transition, but only a legal one. The transition table refuses a move the regime model never makes, which catches a bug in the caller before it corrupts the published state.
func (e *Engine) commit(to Regime, posterior float64) (Published, error) {
prev := e.current.Load()
if !e.transitions[prev.Regime][to] {
return Published{}, fmt.Errorf("illegal transition %s -> %s", prev.Regime, to)
}
next := &Published{
Regime: to,
Posterior: posterior,
EnteredAt: time.Now(),
Sequence: e.seq.Add(1),
}
e.current.Store(next)
return *next, nil
}
The swap is a single Store. Before it, every reader sees the old regime whole; after it, every reader sees the new regime whole. There is no instant where a reader observes the new regime with the old entry-time, because the struct is replaced by pointer in one operation rather than field by field. The sequence increment uses its own atomic so the number is correct even if a future design admits more than one writer.
§ IVConnection to Today's Ops Lesson: The Dwell as a Timer
The Ops lesson's Two-Threshold Dwell is the heart of the writer's logic, and Go's time.Timer renders it directly. The writer watches the filter's posterior stream. When the posterior on a candidate regime first crosses the entry threshold, the writer arms a timer for the dwell duration. If the posterior stays above the exit threshold until the timer fires, the writer commits the transition. If the posterior falls back below exit before the timer fires, the writer stops the timer and the candidate is abandoned. The wobble that the Ops lesson warned about never reaches commit.
func (e *Engine) Run(ctx context.Context, posteriors <-chan Reading, entry, exit float64, dwell time.Duration) {
var pending *time.Timer
var candidate Regime
disarm := func() {
if pending != nil {
pending.Stop()
pending = nil
}
}
for {
select {
case <-ctx.Done():
disarm()
return
case r := <-posteriors:
cur := e.current.Load().Regime
top, p := r.Argmax()
switch {
case top == cur:
disarm()
case p >= entry && pending == nil:
candidate = top
pending = time.AfterFunc(dwell, func() { e.fire(candidate, p) })
case pending != nil && (top != candidate || p < exit):
disarm()
}
}
}
}
The single select loop is the writer goroutine the whole design assumed. It reads the posterior stream, holds at most one pending timer, and cancels that timer the moment the candidate weakens or the regime returns to where it started. Bodner's concurrency chapter is explicit that a select over a cancellation context and a work channel is the idiomatic shape for a long-lived goroutine that must stop cleanly, and the ctx.Done() case is what lets a redeploy tear the engine down without leaking the writer (Ch 10, pp 240-246). The dwell logic lives entirely inside this one goroutine, so there is no lock around the candidate or the timer: only this goroutine ever touches them.
time.AfterFunc runs its callback on its own goroutine, so fire must take the lock-free publication path rather than mutate writer-local state. The callback calls commit through a small fire wrapper that logs and drops an illegal-transition error, because by the time the timer fires the world may have moved and the once-legal transition may no longer be.
§ VPrior-Lesson Reach: The Broadcast Fan-Out
The June 9 lesson published a healthy backend set with an atomic.Value swap so the reverse proxy could read it without a lock. The regime engine reuses that exact move for the published regime, upgraded to the typed atomic.Pointer that newer Go offers over the untyped atomic.Value. The continuity is the point: a value that many goroutines read and one rarely writes is published by atomic swap, whether the value is a backend set or a market regime.
What the regime engine adds is the broadcast. A backend-set change is silent; readers pick it up on their next request. A regime change is an event some consumers must act on the instant it happens, the kill-switch most of all. So commit is wrapped by a fire that, after the swap, fans the new Published out to every subscribed channel.
func (e *Engine) Subscribe() <-chan Published {
e.mu.Lock()
defer e.mu.Unlock()
ch := make(chan Published, 1)
e.subs = append(e.subs, ch)
return ch
}
func (e *Engine) fire(to Regime, posterior float64) {
pub, err := e.commit(to, posterior)
if err != nil {
log.Printf("regime: dropped transition: %v", err)
return
}
e.mu.Lock()
defer e.mu.Unlock()
for _, ch := range e.subs {
select {
case ch <- pub:
default:
}
}
}
The subscriber channels are buffered with depth one and sent to under a non-blocking select with a default. This is the discipline that keeps one slow consumer from stalling the writer: if a subscriber has not drained its last event, the new event is dropped for that subscriber rather than blocking the broadcast. A regime consumer always has the authoritative current value one lock-free Current() call away, so a dropped broadcast costs it timeliness on one event, never correctness. The mutex here guards only the subscriber slice, which the rare Subscribe call mutates, not the hot read path. The hot path is still the atomic, untouched by this lock.
§ VIClosing
The regime engine is a study in matching the primitive to the access shape. The published regime is read constantly and written rarely, so it is an atomic pointer that readers load wait-free. The dwell is a single piece of timed logic owned by one goroutine, so it is a time.Timer inside a select loop with no lock at all. The transition is an event that must reach many consumers once, so it is a buffered-channel fan-out that drops rather than blocks. Three jobs, three primitives, and the seam between them is the line Bodner draws: atomics guard shared reads, channels carry events, and the right one per job keeps the engine both correct and fast.
The Ops lesson named the discipline in the language of markets. Go names it in the language of memory and goroutines. The hysteresis that keeps a regime label from twitching is, in Go, a timer that one goroutine arms and disarms, and the read that a thousand consumers take of that label is a single atomic load. Write rarely, read freely, and broadcast the change once.
Examine well. Reflect on this.