ADR-013: Unified, Intent-Based Temperature Policy with Spin Ratchet¶
Metadata¶
- Status: Accepted (2026-07-02 — implemented in
victor/framework/temperature/with the 0.7→0.6 default flip and thetest_no_temperature_scatter.pyboundary guard; was Proposed) - Date: 2026-06-22
- Decision Makers: Vijaykumar Singh
- Related ADRs: 009 (rubric completion) — shares the reproducibility concern
- Related: FEP-0007 (spin/restatement), arXiv 2606.01451, 2603.21301, 2606.13982
Context¶
Sampling-temperature logic is scattered across six independent paths that drift and duplicate:
ProfileConfig.temperaturebase scalar (config/settings.py:368, default 0.7).- Per-task constants on
TaskTypeHint.temperature_override(framework/capabilities/task_hints.py:
debug 0.1 … analyze 0.6) — applied only in the buffered loop (agentic_loop.py:858-860→2313→
seamturn_execution_runtime.py:1602). - A hardcoded
+0.2analysis bump that mutates the shared scalar (agent/services/task_runtime.py:483-489). - Reactive failure escalation (
agent/recovery/temperature.pyProgressiveTemperatureAdjuster:
STUCK_LOOP +0.2,REPEATED_RESPONSE +0.25, Q-learning,MODEL_TEMPERATURE_RANGES). - Streaming ignores task-hint temperature entirely — uses
orch.temperature
(agent/services/chat_stream_helpers.py:1337), so buffered and streaming disagree. - Streaming has its own hardcoded recovery ramp ladder (
agent/streaming/handler.py:883-935).
This produces inconsistent behavior between modes, duplicated escalation, and no single place to tune
temperature by intent or per profile. Two needs prompted the change: (a) per-profile, per-task
temperatures (e.g. glm-5.2 plan=0.5 vs opus plan=0.55) with a sane fallback chain; and (b) a
spin-escape ratchet — when the loop is stuck in a repetition/plateau, raise temperature modestly to
give the model "escape velocity," reusing the FEP-0007 spin signals.
Research grounding. Full-text reading of arXiv 2606.01451 (distributional view of temperature)
shows T≈0.3–0.8 is a coherence-flat region (per-token-identical) and the degeneration cliff is past
T≈1.0 toward 1.5 (mass on the pre-temperature top-90% set drops 0.982→0.868; n95 inflates ~1→131
tokens). 2603.21301/2604.17433 use T≈0.7–0.8 as the diversity sweet spot. ⇒ a ratchet must use a
modest step and a hard cap well below 1.0.
Decision¶
Introduce one composition-based TemperatureResolver in victor/framework/temperature/ that all
call sites route through. It is built from two narrow Protocols (ISP):
TemperatureSource.resolve(request) → Optional[float]— resolves a base from static inputs,
orNoneto defer (Chain of Responsibility). Ordered precedence:
ProfilePerTask › SettingsPerTask › TaskHintConstant › ProfileBase › GlobalDefault(0.6).TemperatureModifier.adjust(value, request, context) → (value, reason)— idempotent. Applied in
orderSpinRatchet → RecoveryAdjust → ModelBounds.
The SpinRatchetModifier adds steps × 0.05 (steps advanced once per turn by
RatchetState.record_turn on SpinState.WARNING/STUCK or plateau; reset on progress), hard-capped
at 0.9. The RecoveryAdjustModifier wraps the existing ProgressiveTemperatureAdjuster (via a
framework-side ReactiveTemperatureAdjuster Protocol, so framework does not import agent), unifying
the reactive path instead of duplicating it. The ModelBoundsModifier clamps to
MODEL_TEMPERATURE_RANGES (relocated to framework/temperature/defaults.py; recovery imports it back).
Per-profile per-task temperatures come from a new optional ProfileConfig.temperatures map; a
settings-level temperature.task_defaults table provides ops tuning; the existing TaskTypeHint
constants remain the SDK-stable floor (read in place — victor-contracts unchanged).
Rationale¶
- Consolidation over proliferation: 6 scattered paths → 1 resolver.
task_runtime.py:483-489and
the streaming ramp ladders are deleted/absorbed; the recovery adjuster is wrapped, not rebuilt. - SOLID: SRP (one rule per source/modifier), OCP (extend by appending in
factory.py; resolver
untouched), LSP (uniform Protocol contracts), ISP (staticTemperatureRequestvs dynamic
TemperatureContext), DIP (resolver depends on Protocols;ReactiveTemperatureAdjusterinverts the
framework→agent dependency). - Framework-first: the resolver is provider-agnostic and consumed by both the buffered and
streaming seams, so it lives inframework/, closing the buffered/streaming inconsistency at one
seam. - Reproducibility (ties to the evaluation-centric direction): ground/audit/judge intents resolve
low; the ratchet resets on progress so it does not inflate eval variance.
Consequences¶
- Behavior change: the global default moves 0.7→0.6 for profiles without an explicit temperature.
Gated bytemperature.global_default; the buffered cutover kept 0.7 first to isolate. Flipped to
0.6 on 2026-06-22 after a multi-turn A/B (completion_ab.py/temperature_ab.py, glm-5.2, honest
turn metric via #235):enhanced@0.60was the best-combo (5.2 turns, all-complete) and 0.60
match-or-beat 0.70 on the temperature sweep (5.30 vs 6.30 turns, equal variance). Applied to
ProfileConfig.temperature, the built-in BASIC/ADVANCED/EXPERT profile presets, and the
ProviderSettings.default_temperaturedefaults. Quality was not auto-scored (efficiency gate only). - The
temperature_overrideplumbing in the loop becomes redundant and is removed once both seams
route through the resolver. - An AST boundary guard forbids raw
temperature + <float>arithmetic outside
framework/temperature/andrecovery/temperature.py, preventing a seventh scatter. - Q-learning state stays consistent because the same
ProgressiveTemperatureAdjusterinstance is DI-
shared between the recovery coordinator and theRecoveryAdjustModifier.
Rollout¶
Five focused PRs: (A) the additive framework/temperature/ package + this ADR; (B) settings +
ProfileConfig.temperatures + DI; (C) buffered cutover + delete the +0.2 bump; (D) streaming cutover
+ absorb the ramps + buffered↔streaming parity test; (E) ratchet activation + boundary guard.