A close read of the Champion/Challenger promotion system in internal/runtime/ares_evolution/promotion: DefaultPromoter, the five-state machine, the 12 parameters of PromotionCriteria, 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:

StateCan promote toCan demote to
candidateshadowretired
shadowchampiondemoted, retired
champion{}demoted
demotedshadowretired
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)

  1. Performance decay: with enough SampleCount, SuccessRate < MinSuccessRate-DemotionThreshold || ErrorRate > MaxErrorRate || LatencyP95 > MaxLatencyP95 → demoted.
  2. Tenure safety valve: past MaxChampionTenure(20), if rolling < MinRollingImprovement(0.1) → back to shadow; otherwise tenure extended.
  3. Hold-period check: past ChampionHoldPeriod(5), a rolling improvement below MinRollingImprovement flags 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

MechanismKey param / triggerPurpose
candidate→shadow10 samples + 50% successlow bar to enter, encourage competition
shadow→championfive gates + ≥0.5 improvement over baselineguarantee champion quality
champion retentionrolling improvement (window 3, threshold 0.1)prevent stagnation
hard tenure capMaxChampionTenure=20prevents "safe-but-stagnant" permanent occupancy
demotion protectionCoolDownGenerations=3prevent state thrashing
graceful retirementlosing ≥10 gens → retiredfree resources
memory boundhist 64 / retired retained 24hprevent 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.