TieredScorer — A Three-Tier Scoring Pipeline with LLM Budget Management
A close look at the three-tier scoring pipeline in
internal/runtime/ares_evolution/scoring:TieredScorer(Cache → LLM → Heuristic),Budget(CAS atomic budget),ScoreCache(LRU + generation-based expiry), plus the optionalMemoryAwareScoreroverlay. Code samples come from the real source; every constant and default matches the repo.
1. Architecture overview
TieredScorer decides a strategy's fitness score on the principle "use the cheapest tier that gives a good-enough score, and only call the expensive LLM when necessary." File: internal/runtime/ares_evolution/scoring/tiered_scorer.go.
flowchart LR
S[Score(strategy)] --> H1[StrategyHash]
H1 --> C{1. cache hit?}
C -->|hit| R1[return cached value + CacheHit]
C -->|miss| B{2. llm present and budget allows?}
B -->|yes| L[tryLLMScore w/ panic recovery]
L -->|ok| R2[LLM score + write cache]
L -->|fail| H[3. heuristic fallback]
B -->|no| H
H --> R3[heuristic score + write cache]
2. Tier definitions and execution order
2.1 Enum
const (
TierCache Tier = iota + 1 // 1
TierHeuristic // 2
TierLLM // 3
)
There's a real mismatch between naming and execution order: the numeric enum order is Cache(1) → Heuristic(2) → LLM(3), but inside Score() the LLM actually runs before the heuristic:
func (ts *TieredScorer) Score(ctx context.Context, s *mutation.Strategy) (float64, Tier, error) {
hash, err := StrategyHash(s) // FNV-1a 64-bit hash
if err != nil { ... }
// Tier 1: cache hit returns at zero cost
if entry, ok := ts.cache.Get(hash); ok {
ts.budget.RecordCacheHit()
ts.cacheHits.Add(1)
ts.totalScored.Add(1)
return entry.Score, TierCache, nil
}
// Tier 2: LLM enabled and within budget
if ts.llm != nil && ts.budget.TryRecordLLMCall() {
score, scored := ts.tryLLMScore(ctx, s, hash)
if scored {
return score, TierLLM, nil
}
// LLM failed/panicked -> fall to heuristic
}
// Tier 3: heuristic fallback (always available)
score := ts.heuristic(s)
entry := MakeEntry(hash, score, ScorerTypeHeuristic, 1, 0.5)
ts.cache.Put(hash, entry)
ts.heuristicCalls.Add(1)
ts.totalScored.Add(1)
return score, TierHeuristic, nil
}
Rationale: LLM is expensive but more accurate than a heuristic, so the pipeline tries the more accurate tier first and degrades only when the budget is exhausted or the LLM fails.
2.2 LLM panic-safe recovery
func (ts *TieredScorer) tryLLMScore(ctx context.Context, s *mutation.Strategy, hash uint64) (float64, bool) {
var score float64
var success bool
func() {
defer func() {
if r := recover(); r != nil {
log.Warn("tiered_scorer: LLM scorer panicked", "hash", hash, "recovery", r)
ts.budget.RecordFallback()
ts.fallbacks.Add(1)
success = false
}
}()
score = ts.llm(s)
success = true
}()
if !success {
return 0, false
}
entry := MakeEntry(hash, score, ScorerTypeLLM, 1, 1.0)
ts.cache.Put(hash, entry)
ts.llmCalls.Add(1)
ts.totalScored.Add(1)
return score, true
}
The LLM call is wrapped in a closure with defer recover(): even if a scorer panics, the evolution loop doesn't crash — it degrades gracefully to the heuristic.
Stats() exposes cache_hits / llm_calls / heuristic_calls / fallbacks / total_scored; ResetForGeneration() resets the budget and counters at the start of each generation.
3. Budget: CAS atomic budget
internal/runtime/ares_evolution/scoring/budget.go. Caps LLM calls per generation.
type Budget struct {
MaxLLMCalls int64 // immutable
UsedLLMCalls atomic.Int64
CacheHits atomic.Int64
FallbackCount atomic.Int64
}
The core CAS spin:
func (b *Budget) TryRecordLLMCall() bool {
used := b.UsedLLMCalls.Load()
for used < b.MaxLLMCalls {
if b.UsedLLMCalls.CompareAndSwap(used, used+1) {
return true
}
used = b.UsedLLMCalls.Load()
}
return false
}
Lock-free concurrency: read used → if within limit, try a CAS increment → on contention retry → success returns true → at capacity returns false. Also CanCallLLM(), RecordCacheHit() (doesn't consume budget), RecordFallback(); Reset() zeroes the counters.
4. ScoreCache: LRU + generation-based expiry
internal/runtime/ares_evolution/scoring/cache.go. An LRU keyed by strategy hash, built on container/list.
type CacheEntry struct {
Hash uint64
Score float64
ScorerType string // "llm" / "heuristic"
Timestamp int64 // Unix nanos
SampleCount int
Confidence float64
}
type ScoreCache struct {
mu sync.RWMutex
entries map[uint64]*list.Element
lru list.List // front = most recently used
maxSize int // 0 = unlimited
maxCacheAge int // 0 = unlimited (no expiry)
generation uint64
hits, misses, evictions int64
}
Two real design points:
Get()takes the write lock — it callsMoveToFront, mutating LRU order; the RWMutex write path is simpler.- Generation expiry is opt-in:
maxCacheAgedefaults to0(unlimited — entries don't expire on their own). To enable it, callSetMaxCacheAge(n)and advance the counter viaNewGeneration(); entries older than n generations are treated as misses and evicted.
Put() evicts the least-recently-used entry from the tail when at capacity; an existing hash is updated in place with its generation refreshed.
5. MemoryAwareScorer: an experience-driven overlay
internal/runtime/ares_evolution/scoring/memory_aware_scorer.go layers an experience-memory dimension on top of TieredScorer. Defaults are real constants:
func DefaultMemoryAwareScoringConfig() MemoryAwareScoringConfig {
return MemoryAwareScoringConfig{
Enabled: false, // off by default
MemoryWeight: 0.2,
CostWeight: 0.1,
LatencyWeight: 0.05,
RegressionWeight: 0.1,
MinEvidenceBonus: 0.0,
MaxEvidenceBonus: 20.0,
ExperienceLookupLimit: 10,
SuccessRateBonusScale: 10.0, // bonus = success_rate * confidence * scale
LatencyPenaltyScale: 1.0, // penalty = (latency_p50 / 10000) * scale
ErrorRatePenaltyScale: 1.0,
}
}
Its intent is quality + memory_evidence_bonus − cost − latency − regression, with the success-bonus and latency/error-penalty formulas documented on the config fields. The ScoreDetail struct decomposes each component for reporting.
6. Summary
The "cost–quality trade-off" lands in real code as a three-tier pipeline:
- Cache absorbs redundant scoring via O(1) hash lookup;
Get/Putmaintain the LRU. - Budget uses a CAS spin to cap LLM calls per generation under concurrency.
- heuristic is the always-on fallback so a score always exists.
- MemoryAwareScorer is a switchable evidence overlay (off by default with
Enabled:false).
Anywhere a score costs LLM money, this layering is the first and most important cost gate on the scoring side.