package aioe import ( "fmt" "github.com/shopspring/decimal" ) // Params bounds every update cycle. All decimal so the math is bit stable. type Params struct { // Eta scales how strongly window evidence pulls a weight (the learning rate). Eta decimal.Decimal // Beta is the exponential moving average smoothing toward the evidence target. Beta decimal.Decimal // MaxStep is the hard cap on the evidence driven change of one dimension in one cycle. MaxStep decimal.Decimal // Floor and Ceiling are the hard clamps every dimension weight must stay inside. Floor decimal.Decimal Ceiling decimal.Decimal // MinEvents is the minimum outcome count in the window before any update happens. MinEvents int // WindowDays is the trailing outcome window the updater consumes. WindowDays int } // DefaultParams returns the shipped bounds: gentle steps, wide clamps, a quarter of outcomes // before the first move. func DefaultParams() Params { return Params{ Eta: decimal.RequireFromString("0.10"), Beta: decimal.RequireFromString("0.30"), MaxStep: decimal.RequireFromString("0.02"), Floor: decimal.RequireFromString("0.05"), Ceiling: decimal.RequireFromString("0.40"), MinEvents: 25, WindowDays: 90, } } // Event is one outcome the updater consumes: the normalized signal in [-1, 1] and the served per // dimension raw scores (0 to 100) that the app denormalized into outcome_events.metadata at // capture time. The updater reads outcome_events ONLY; this struct is the whole input. type Event struct { Signal decimal.Decimal DimScores map[string]decimal.Decimal } // DimChange is one audited weight change: the dimension, both values, and the human rationale. type DimChange struct { Dimension string Old decimal.Decimal New decimal.Decimal Rationale string } var ( one = decimal.NewFromInt(1) negOne = decimal.NewFromInt(-1) fifty = decimal.NewFromInt(50) epsilon = decimal.New(1, -4) // 0.0001, the 4dp storage grain ) // ComputeUpdate is the pure, deterministic weight update: bounded exponential moving average // steps on each dimension driven by window evidence, hard clamped to [Floor, Ceiling] and MaxStep, // then the exact sum of 1.0000 restored. Same inputs always produce the same outputs: iteration // follows the canonical Dimensions order, all math is decimal, and no clock or randomness enters. // It returns the new weights, the per dimension changes (empty when nothing moved), and the number // of outcomes consumed. // // Evidence per dimension is the mean over events of signal * position, where position normalizes // the served dimension score to [-1, 1] around the 50 midpoint. A positive outcome on a decision // where a dimension scored high is evidence the dimension helps; a positive outcome where it // scored low is evidence it matters less. The EMA target is old * (1 + Eta * evidence) and the // applied step is Beta of the way there, so one wild window cannot yank a weight. func ComputeUpdate(old map[string]decimal.Decimal, events []Event, p Params) (map[string]decimal.Decimal, []DimChange, int) { used := len(events) out := make(map[string]decimal.Decimal, len(Dimensions)) for _, d := range Dimensions { out[d] = old[d] } if used < p.MinEvents { return out, nil, used } evidence := make(map[string]decimal.Decimal, len(Dimensions)) for _, dim := range Dimensions { sum := decimal.Zero n := 0 for _, ev := range events { score, ok := ev.DimScores[dim] if !ok { continue } pos := score.Sub(fifty).Div(fifty) if pos.GreaterThan(one) { pos = one } if pos.LessThan(negOne) { pos = negOne } sum = sum.Add(ev.Signal.Mul(pos)) n++ } if n > 0 { evidence[dim] = sum.Div(decimal.NewFromInt(int64(n))) } else { evidence[dim] = decimal.Zero } } // Bounded EMA step per dimension, clamped to MaxStep and [Floor, Ceiling]. for _, dim := range Dimensions { step := p.Beta.Mul(p.Eta).Mul(evidence[dim]).Mul(old[dim]) if step.GreaterThan(p.MaxStep) { step = p.MaxStep } if step.LessThan(p.MaxStep.Neg()) { step = p.MaxStep.Neg() } v := old[dim].Add(step) if v.LessThan(p.Floor) { v = p.Floor } if v.GreaterThan(p.Ceiling) { v = p.Ceiling } out[dim] = v } restoreExactSum(out, p) // Round to the 4dp storage grain and repair any rounding residue deterministically. for _, dim := range Dimensions { out[dim] = out[dim].Round(4) } repairRounding(out, p) var changes []DimChange for _, dim := range Dimensions { if out[dim].Equal(old[dim].Round(4)) { continue } changes = append(changes, DimChange{ Dimension: dim, Old: old[dim].Round(4), New: out[dim], Rationale: fmt.Sprintf( "Mean outcome evidence %s over %d outcomes moved %s from %s to %s under bounds: max step %s, floor %s, ceiling %s, exact sum restored.", evidence[dim].Round(4).String(), used, dim, old[dim].Round(4).String(), out[dim].String(), p.MaxStep.String(), p.Floor.String(), p.Ceiling.String()), }) } if len(changes) == 0 { // Nothing moved after clamping and rounding: report no update at all. for _, d := range Dimensions { out[d] = old[d] } } return out, changes, used } // restoreExactSum spreads the residual (1 minus the clamped sum) across dimensions that still have // slack toward the needed direction, walking the canonical order, bounded passes. The evidence // step is what MaxStep bounds; this restore is the allocation identity, kept inside the clamps. func restoreExactSum(w map[string]decimal.Decimal, p Params) { for pass := 0; pass < 8; pass++ { sum := decimal.Zero for _, d := range Dimensions { sum = sum.Add(w[d]) } diff := one.Sub(sum) if diff.IsZero() { return } var slack []string for _, d := range Dimensions { if diff.IsPositive() && w[d].LessThan(p.Ceiling) { slack = append(slack, d) } if diff.IsNegative() && w[d].GreaterThan(p.Floor) { slack = append(slack, d) } } if len(slack) == 0 { return } share := diff.Div(decimal.NewFromInt(int64(len(slack)))).Round(6) remaining := diff for i, d := range slack { take := share if i == len(slack)-1 { take = remaining } v := w[d].Add(take) if v.GreaterThan(p.Ceiling) { v = p.Ceiling } if v.LessThan(p.Floor) { v = p.Floor } remaining = remaining.Sub(v.Sub(w[d])) w[d] = v } } } // repairRounding fixes the residue 4dp rounding leaves so the stored profile sums to exactly // 1.0000. The residue lands on dimensions in canonical order, one 0.0001 grain at a time, always // inside the clamps. func repairRounding(w map[string]decimal.Decimal, p Params) { for guard := 0; guard < 1000; guard++ { sum := decimal.Zero for _, d := range Dimensions { sum = sum.Add(w[d]) } diff := one.Sub(sum) if diff.IsZero() { return } grain := epsilon if diff.IsNegative() { grain = epsilon.Neg() } moved := false for _, d := range Dimensions { v := w[d].Add(grain) if v.GreaterThan(p.Ceiling) || v.LessThan(p.Floor) { continue } w[d] = v moved = true break } if !moved { return } } }