// Package ewma implements EWMA (Exponentially Weighted Moving Average) with // oscillation detection using exact rational arithmetic. // // The oscillation detector tracks the raw/accreted ratio across generations. // When the EWMA repeatedly reverses direction — rising then falling then // rising — the lattice has exhausted its current information and needs // fresh input. package ewma import ( "encoding/json" "git.mleku.dev/mleku/dendrite/pkg/ratio" ) // EWMA tracks an exponentially weighted moving average. // Alpha = 2/(N+1) where N is the smoothing window. // All arithmetic is exact rational — no floats. type EWMA struct { Alpha ratio.Ratio `json:"alpha"` OneMinA ratio.Ratio `json:"one_min_a"` Value ratio.Ratio `json:"value"` Count int `json:"count"` } // NewEWMA creates an EWMA with the given smoothing window. // Alpha = 2 / (window + 1). func NewEWMA(window int) EWMA { alpha := ratio.New(2, int64(window+1)) oneMinA := ratio.One.Sub(alpha) return EWMA{ Alpha: alpha, OneMinA: oneMinA, Value: ratio.Zero, } } // maxDenom is the denominator cap to prevent overflow in iterated multiplication. // Values are rescaled when the denominator exceeds this. The cap preserves // about 9 decimal digits of precision — more than sufficient for EWMA tracking. // Epoch decomposition: 10^9 × 2^0 (pure decimal). A binary component would // not improve precision because rescaling is followed by GCD normalization. const maxDenom int64 = 1_000_000_000 // Update feeds a new value into the EWMA. // Formula: ewma = alpha * value + (1 - alpha) * ewma_old // On the first observation, the EWMA is set directly to the value. // The result is rescaled to prevent denominator overflow from iterated // multiplication. func (e *EWMA) Update(value ratio.Ratio) { e.Count++ if e.Count == 1 { e.Value = value return } e.Value = e.Alpha.Mul(value).Add(e.OneMinA.Mul(e.Value)) e.Value = rescale(e.Value) } // rescale reduces a ratio's denominator if it exceeds maxDenom. // This is lossy but bounded: at most 1/maxDenom precision loss per step. func rescale(r ratio.Ratio) ratio.Ratio { if r.Denom <= maxDenom && r.Denom >= -maxDenom { return r } // Scale both num and denom down proportionally. // Use the ratio of maxDenom/abs(denom) to compute new num. d := r.Denom if d < 0 { d = -d } // newNum = r.Num * maxDenom / r.Denom (integer division, rounds toward zero) newNum := r.Num * maxDenom / r.Denom return ratio.New(newNum, maxDenom) } // OscillationDetector detects when the EWMA repeatedly reverses direction. // It counts direction reversals (rising→falling or falling→rising). // When the reversal count reaches the threshold, the signal is oscillating. // // A reversal is detected when the EWMA moves by more than a minimum step // in the opposite direction from the previous movement. This filters out // noise from convergence jitter. type OscillationDetector struct { EW EWMA `json:"ew"` Prev ratio.Ratio `json:"prev"` // previous EWMA value PeakVal ratio.Ratio `json:"peak_val"` // last peak/trough value Rising bool `json:"rising"` // current direction DirKnown bool `json:"dir_known"` // direction established Reversals int `json:"reversals"` // count of direction changes Threshold int `json:"threshold"` // reversals needed for oscillation MinStep ratio.Ratio `json:"min_step"` // minimum movement to count as reversal Primed bool `json:"primed"` // have we seen enough data } // NewDetector creates an oscillation detector. // threshold is the number of direction reversals needed to declare oscillation. // A threshold of 3 means 3 reversals = roughly 1.5 full oscillation cycles. func NewDetector(ewmaWindow, _ /*bandCapacity*/, threshold int) *OscillationDetector { return &OscillationDetector{ EW: NewEWMA(ewmaWindow), Prev: ratio.Zero, PeakVal: ratio.Zero, Threshold: threshold, MinStep: ratio.New(1, 100), // 1% minimum movement } } // Observe feeds a raw/accreted pair into the detector. Returns true if // terminal oscillation is detected after this observation. func (d *OscillationDetector) Observe(rawCount, accretedCount int64) bool { value := ratio.Zero if rawCount > 0 { value = ratio.New(accretedCount, rawCount) } d.EW.Update(value) // Need at least 2 EWMA values to detect direction. if d.EW.Count < 2 { d.Prev = d.EW.Value d.PeakVal = d.EW.Value return false } // Current movement direction. nowRising := d.EW.Value.Greater(d.Prev) nowFalling := d.Prev.Greater(d.EW.Value) if !d.DirKnown { // Establish initial direction. if nowRising || nowFalling { d.Rising = nowRising d.DirKnown = true d.PeakVal = d.Prev d.Primed = true } d.Prev = d.EW.Value return false } // Track peak/trough for minimum step check. if d.Rising && d.EW.Value.Greater(d.PeakVal) { d.PeakVal = d.EW.Value } if !d.Rising && d.EW.Value.Less(d.PeakVal) { d.PeakVal = d.EW.Value } // Check for direction reversal. reversed := false if d.Rising && nowFalling { // Was rising, now falling. Check the drop from peak is significant. drop := d.PeakVal.Sub(d.EW.Value) if drop.Greater(d.MinStep) || drop.Equal(d.MinStep) { reversed = true } } else if !d.Rising && nowRising { // Was falling, now rising. Check the rise from trough is significant. rise := d.EW.Value.Sub(d.PeakVal) if rise.Greater(d.MinStep) || rise.Equal(d.MinStep) { reversed = true } } if reversed { d.Reversals++ d.Rising = !d.Rising d.PeakVal = d.EW.Value } d.Prev = d.EW.Value return d.Oscillating() } // Oscillating returns true if reversals have reached the threshold. func (d *OscillationDetector) Oscillating() bool { return d.Primed && d.Reversals >= d.Threshold } // Reset zeros the reversal counter but preserves the EWMA value. // Called when fresh data is fed after oscillation is detected. func (d *OscillationDetector) Reset() { d.Reversals = 0 d.DirKnown = false d.Primed = false d.PeakVal = d.EW.Value d.Prev = d.EW.Value } // FeedRatio returns the current accreted/raw ratio from the EWMA. func (d *OscillationDetector) FeedRatio() ratio.Ratio { return d.EW.Value } // Marshal serializes the detector state for persistence. func (d *OscillationDetector) Marshal() ([]byte, error) { return json.Marshal(d) } // UnmarshalDetector restores a detector from persisted state. func UnmarshalDetector(data []byte) (*OscillationDetector, error) { d := &OscillationDetector{} if err := json.Unmarshal(data, d); err != nil { return nil, err } return d, nil }