The Promoter — A Five-State Strategy Lifecycle with Promote/Demote
A close read of the Champion/Challenger promotion system in
internal/runtime/ares_evolution/promotion:DefaultPromoter, the five-state machine, the 12 parameters ofPromotionCriteria,MeetsPromotionCriteria/CalculateEvidenceScore, rolling-improvement and cool-down. Every type and method signature below comes from the real source; defaults are repo constants.
1. Overview
The Promoter decides who gets promoted from candidate to champion, in the spirit of Champion/Challenger. There are five real states (types.go):
const (
StrategyStateCandidate StrategyState = "candidate"
StrategyStateShadow StrategyState = "shadow"
StrategyStateChampion StrategyState = "champion"
StrategyStateDemoted StrategyState = "demoted"
StrategyStateRetired StrategyState = "retired"
)
Note: StrategyState is a string, not an int — the promotion package models it as a string.
2. State transitions are an explicit acyclic graph
CanPromoteTo / CanDemoteTo use explicit transition tables, and the graph is acyclic:
| State | Can promote to | Can demote to |
|---|---|---|
| candidate | shadow | retired |
| shadow | champion | demoted, retired |
| champion | {} | demoted |
| demoted | shadow | retired |
| retired | {} | {} |
Each method inlines a map[StrategyState][]StrategyState and checks membership. Note this is independent of the PromotionLogic interface's Promote/Demote: the interface methods perform an actual transition, CanPromoteTo performs validation.
3. PromotionCriteria and defaults
All 12 parameters (types.go):
type PromotionCriteria struct {
MinSampleCount int
MinSuccessRate float64
MaxErrorRate float64
MaxLatencyP95 int64
MinConfidence float64
ChampionHoldPeriod int
DemotionThreshold float64
CoolDownGenerations int
MinAbsoluteImprovement float64
MinRollingImprovement float64
ImprovementWindow int
MaxChampionTenure int
}
func DefaultPromotionCriteria() *PromotionCriteria {
return &PromotionCriteria{
MinSampleCount: 100,
MinSuccessRate: 0.85,
MaxErrorRate: 0.15,
MaxLatencyP95: 5000,
MinConfidence: 0.7,
ChampionHoldPeriod: 5,
DemotionThreshold: 0.3,
CoolDownGenerations: 3,
MinAbsoluteImprovement: 0.5,
MinRollingImprovement: 0.1,
ImprovementWindow: 3,
MaxChampionTenure: 20,
}
}
The promotion gate (all five must pass):
func MeetsPromotionCriteria(e experience.Evidence, criteria *PromotionCriteria) bool {
// SampleCount >= MinSampleCount
// SuccessRate >= MinSuccessRate
// ErrorRate <= MaxErrorRate
// LatencyP95 <= MaxLatencyP95
// Confidence >= MinConfidence
}
Weighted evidence score (CalculateEvidenceScore), real weights 0.4 / 0.3 / 0.2 / 0.1:
const (
successRateWeight = 0.4
errorRateWeight = 0.3
confidenceWeight = 0.2
latencyWeight = 0.1
)
// latency normalized: 1.0 - LatencyP95/10000.0, floored at 0
score = SuccessRate*0.4 + (1-ErrorRate)*0.3 + Confidence*0.2 + normalizedLatency*0.1
4. DefaultPromoter (real fields)
promoter.go:
type DefaultPromoter struct {
mu sync.RWMutex
criteria *PromotionCriteria
strategies map[string]*StrategyInfo // strategyID -> info
history map[string][]StrategyPromotionRecord
champions map[string][]string // taskType -> champion IDs (a map, not a slice)
currentGeneration int
}
Note champions is a map[string][]string bucketed by taskType — the global []string champion list in the older draft is wrong. StrategyInfo tracks CurrentState, GenerationCount, ScoreHistory []ScoreSnapshot, BaselineScore, etc.
5. The core flow: Evaluate
The PromotionLogic interface signatures are real:
type PromotionLogic interface {
Evaluate(ctx context.Context, strategyID string, evidence experience.Evidence) (StrategyState, string, error)
Promote(ctx context.Context, strategyID string) error
Demote(ctx context.Context, strategyID string, reason string) error
GetHistory(ctx context.Context, strategyID string) ([]StrategyPromotionRecord, error)
GetCurrentState(ctx context.Context, strategyID string) (StrategyState, error)
GetChampions(ctx context.Context, taskType string) ([]StrategyInfo, error)
GetStrategyInfo(ctx context.Context, strategyID string) (*StrategyInfo, error)
}
(Evaluate returns (recommended state, reason, error) — there is no Action type as the older draft imagined. Actual transitions happen in Promote/Demote; Evaluate only recommends.)
Each Evaluate call: score it → create StrategyInfo on first sight → append a ScoreSnapshot (capped at 20) → periodically sweep long-retired strategies (Section 7) → capture the baseline on first shadow entry → check the cool-down → dispatch by info.CurrentState.
5.1 candidate → shadow (low bar to get in)
if evidence.SampleCount >= 10 && evidence.SuccessRate >= 0.5 {
return StrategyStateShadow, "promoted to shadow: ..."
}
10 samples + 50% success rate, deliberately low so enough candidates enter the shadow pool to compete before they're filtered out.
5.2 shadow → champion (strictest)
if MeetsPromotionCriteria(evidence, p.criteria) {
if info.BaselineScore > 0 {
scoreDelta := score - info.BaselineScore
if scoreDelta < p.criteria.MinAbsoluteImprovement {
return StrategyStateShadow, "blocked: score_delta below min_improvement"
}
return StrategyStateChampion, "promoted to champion: ..."
}
return StrategyStateChampion, "promoted to champion: ..."
}
// and the reverse: if success rate drops below MinSuccessRate-DemotionThreshold -> demoted
Meets all five gates and improves ≥0.5 over baseline? Champion. Meanwhile a clear performance drop → demoted.
5.3 champion (three exits)
- Performance decay: with enough
SampleCount,SuccessRate < MinSuccessRate-DemotionThreshold || ErrorRate > MaxErrorRate || LatencyP95 > MaxLatencyP95→ demoted. - Tenure safety valve: past
MaxChampionTenure(20), ifrolling < MinRollingImprovement(0.1)→ back to shadow; otherwise tenure extended. - Hold-period check: past
ChampionHoldPeriod(5), a rolling improvement belowMinRollingImprovementflags the champion (champion flagged) but keeps champion by default — real degradation goes through exits 1 and 2; exit 3 is largely a warning.
The two thresholds do different jobs: MinRollingImprovement is 0.1 and only the tenure path (MaxChampionTenure) hard-demotes; DemotionThreshold=0.3 is used for the shadow→demoted success-rate regression.
5.4 demoted
- Re-
MeetsPromotionCriteria→ re-promoted to shadow. - Losing for ≥
ChampionHoldPeriod*2(10 gens) → retired.
Rolling improvement (calculateRollingImprovement)
func (p *DefaultPromoter) calculateRollingImprovement(info *StrategyInfo) float64 {
// fewer than 2 data points -> returns 0
// mean of adjacent score deltas over the last ImprovementWindow (default 3)
}
6. Promote / Demote and the cool-down
Promote picks the target (candidate→shadow, shadow→champion, demoted→shadow), validates CanPromoteTo and the cool-down, then transitionState; on champion it addToChampions(taskType, id). Demote mirrors it via CanDemoteTo (candidate→retired, shadow→demoted, champion→demoted, demoted→retired), calling removeFromChampions when leaving champion.
func (p *DefaultPromoter) canTransition(info *StrategyInfo) bool {
if info.GenerationCount == 0 { return true } // new strategies act immediately
return info.GenerationCount >= p.criteria.CoolDownGenerations // default 3 gens
}
7. Growth guards (#56, real)
Two measures bound unbounded growth (issue #56):
const (
maxPromotionHistoryPerStrategy = 64 // 64 transition records max per strategy
retiredRetention = 24 * time.Hour // Retired strategies retained 24h
)
transitionState trims each strategy's history; pruneRetiredLocked deletes strategies that have been Retired for over retiredRetention together with their history (otherwise both maps grow forever with every new strategy ID). SetGeneration(g) advances the generation and bumps GenerationCount for all strategies.
8. Public API snapshot
api/evolution/evolution.go exposes a simplified NewPromoter(criteria) Promoter adapter. Its Evaluate(ctx, strategyID, successRate, confidence) wraps a success-rate/confidence-only evidence (ErrorRate: 1-successRate, SampleCount: 1) and calls the internal DefaultPromoter.Evaluate.
(Side effect worth noting: with SampleCount=1 from the public path, the "10 samples" bar in evaluateCandidate basically never triggers — the public convenience path is meant for manual promote/demote/inspect, while real automated promotion runs on the internal experience.Evidence.)
9. Summary
| Mechanism | Key param / trigger | Purpose |
|---|---|---|
| candidate→shadow | 10 samples + 50% success | low bar to enter, encourage competition |
| shadow→champion | five gates + ≥0.5 improvement over baseline | guarantee champion quality |
| champion retention | rolling improvement (window 3, threshold 0.1) | prevent stagnation |
| hard tenure cap | MaxChampionTenure=20 | prevents "safe-but-stagnant" permanent occupancy |
| demotion protection | CoolDownGenerations=3 | prevent state thrashing |
| graceful retirement | losing ≥10 gens → retired | free resources |
| memory bound | hist 64 / retired retained 24h | prevent unbounded growth |
In one line: the fittest survive, but the winner doesn't live forever — good strategies get enough display time (hold 5 gens), while tenure and rolling improvement pull them down to give new strategies a chance.