Skip to content

Commit 60de244

Browse files
Add routing cost floor to stop the dream ratcheting balancedCeiling to its floor (#451)
* Add routing cost floor to stop the dream ratcheting balancedCeiling to its floor The tier-routing review pass walked balancedCeiling down to its 0.40 floor and kept it there, over-routing subagent traffic to the High tier. Two defects drove this: 1. The dream and the introspection summary tool never passed `tierModelMap` to `TierRoutingAnalyzer.Analyze`, so every `thresholdScan.projectedCostDelta` was null — the LLM had no cost signal to weigh against quality, and the "don't under-route" guidance won by default. 2. Even with the map, High and Balanced currently share a model (gpt-5.5), so a Balanced->High shift projects a $0 cost delta — read as "free", never "unfavorable". Lowering balancedCeiling was always free and raising it was never indicated, so it ratcheted to the floor with no restoring force. Fix: - TierRoutingAnalyzer: add a High-tier cost floor. For threshold-scan and flagged-cluster cost projections, price High at >= N x the Balanced tier's real rate (Math.Max — a genuinely premium High model keeps its real price). Real-spend reporting (ProjectedCost) is unchanged. Wire `tierModelMap` through both decision paths. - DreamService: build the tier->model map from TieredChatClientRegistry and pass it plus the configured multiplier to Analyze. Add a deterministic ratchet-stop (GuardBalancedCeilingDecrease) that refuses to lower balancedCeiling while the observed High routing share exceeds the target — independent of the LLM. - DreamOptions: TierRoutingHighCostFloorMultiplier (2.0), TierRoutingHighTargetPct (20). - routing-dream.md: tell the reviewer to trust projectedCostDelta and prefer raising balancedCeiling when High share is high. The MCP summary tool keeps its existing 3-arg call (no floor) — its alert needs only counts; the floor is for the tuner. Tests: cost-floor sign/zero/premium-model cases on the analyzer; over/under-budget and null-config cases on the guard. Full Host.Tests suite green (1037). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Bump version to 0.12.24 for dream routing cost-floor test deploy Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf2d8d2 commit 60de244

7 files changed

Lines changed: 317 additions & 23 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<!-- Version can be overridden from the command line: -p:Version=0.3.1
1010
AssemblyVersion and FileVersion are derived automatically by the SDK
1111
(prerelease suffixes like -beta001 are stripped for assembly versions). -->
12-
<Version>0.12.23</Version>
12+
<Version>0.12.24</Version>
1313
</PropertyGroup>
1414

1515
<!-- NuGet package metadata (shared across all packable projects) -->

src/RockBot.Agent/agent/routing-dream.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ Apply a threshold shift only when **both** of these hold:
8282
- The cost delta is favorable, OR you have a clear quality reason (e.g., panic-escalation
8383
clusters dominating a tier)
8484

85+
**Trust `projectedCostDelta`.** It now reflects a *routing cost floor*: the High tier is
86+
priced as a premium tier (≥ a fixed multiple of Balanced) even when High and Balanced
87+
currently share the same model. So a `balancedCeiling` **decrease** (which routes more
88+
traffic to High) will show a **positive (unfavorable)** delta, and an **increase** a
89+
**negative (favorable)** one. Do not read "same model today" as "free" — lowering
90+
`balancedCeiling` is treated as a real future cost. When High routing share is already
91+
high, prefer **raising** `balancedCeiling` to pull verbose-but-simple tasks back to Balanced.
92+
8593
Adjustments must be **small (±0.05)**. Hard bounds the code clamps to:
8694
- `lowCeiling`[0.15, 0.40]
8795
- `balancedCeiling`[0.40, 0.80]

src/RockBot.Host.Abstractions/DreamOptions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ public sealed class DreamOptions
7777
/// <summary>Whether the tier routing self-correction review pass is enabled.</summary>
7878
public bool TierRoutingReviewEnabled { get; set; } = true;
7979

80+
/// <summary>
81+
/// Routing cost floor for the tier-routing review pass. The High tier is priced at no less
82+
/// than this multiple of the Balanced tier's real rate when projecting threshold-scan and
83+
/// flagged-cluster costs. This stops the dream from reading a Balanced→High shift as
84+
/// zero-cost — and ratcheting <c>balancedCeiling</c> to its floor — when High currently
85+
/// shares Balanced's model. A genuinely premium High model keeps its real (higher) price.
86+
/// Set to 1.0 to disable the floor. Default: 2.0.
87+
/// </summary>
88+
public double TierRoutingHighCostFloorMultiplier { get; set; } = 2.0;
89+
90+
/// <summary>
91+
/// Target ceiling on the High-tier routing share (percent of routing decisions in the review
92+
/// window). While the observed High share exceeds this, the review pass will NOT apply a
93+
/// <c>balancedCeiling</c> decrease (which would push even more traffic to High) — a
94+
/// deterministic backstop independent of the LLM's judgment. Default: 20 (%).
95+
/// </summary>
96+
public double TierRoutingHighTargetPct { get; set; } = 20.0;
97+
8098
/// <summary>
8199
/// Path to the routing dream directive file, relative to <see cref="AgentProfileOptions.BasePath"/>.
82100
/// When the file does not exist, a built-in fallback directive is used.

src/RockBot.Host.Abstractions/TierRoutingAnalyzer.cs

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,20 @@ public static class TierRoutingAnalyzer
3535
/// Run the analyzer over a snapshot of entries. The caller is responsible for
3636
/// any time-window filtering before calling — this method aggregates whatever it gets.
3737
/// </summary>
38+
/// <param name="highTierCostFloorMultiplier">
39+
/// Routing cost floor. For threshold-scan and flagged-cluster cost projections, the High
40+
/// tier is priced at no less than this multiple of the Balanced tier's real rate. Prevents
41+
/// the dream from reading a Balanced→High shift as zero-cost — and ratcheting balancedCeiling
42+
/// to its floor — when High currently shares Balanced's model. <c>Math.Max</c> means a
43+
/// genuinely premium High model keeps its real (higher) price. 1.0 disables the floor.
44+
/// Does NOT affect <see cref="TierRoutingProjectedCost"/>, which always reports real spend.
45+
/// </param>
3846
public static TierRoutingAnalysis Analyze(
3947
IReadOnlyList<TierRoutingEntry> entries,
4048
TierSelectorConfig? currentConfig = null,
4149
IReadOnlyList<LlmPricingRow>? pricing = null,
42-
IReadOnlyDictionary<ModelTier, string?>? tierModelMap = null)
50+
IReadOnlyDictionary<ModelTier, string?>? tierModelMap = null,
51+
double highTierCostFloorMultiplier = 1.0)
4352
{
4453
var lowCeiling = currentConfig?.LowCeiling ?? DefaultLowCeiling;
4554
var balancedCeiling = currentConfig?.BalancedCeiling ?? DefaultBalancedCeiling;
@@ -65,9 +74,9 @@ public static TierRoutingAnalysis Analyze(
6574

6675
var globalStats = BuildGlobalStats(entries, fallbackCount);
6776
var clusters = BuildClusters(quality);
68-
var flagged = FlagClusters(clusters, pricing, tierModelMap);
77+
var flagged = FlagClusters(clusters, pricing, tierModelMap, highTierCostFloorMultiplier);
6978
var keywordCandidates = BuildKeywordCandidates(quality);
70-
var scans = BuildThresholdScans(quality, lowCeiling, balancedCeiling, pricing, tierModelMap);
79+
var scans = BuildThresholdScans(quality, lowCeiling, balancedCeiling, pricing, tierModelMap, highTierCostFloorMultiplier);
7180
var projectedCost = BuildProjectedCost(entries, pricing);
7281

7382
return new TierRoutingAnalysis(
@@ -183,7 +192,8 @@ private static string BuildSignature(TierRoutingEntry e)
183192
private static IReadOnlyList<TierRoutingFlaggedCluster> FlagClusters(
184193
IReadOnlyList<TierRoutingCluster> clusters,
185194
IReadOnlyList<LlmPricingRow>? pricing,
186-
IReadOnlyDictionary<ModelTier, string?>? tierModelMap)
195+
IReadOnlyDictionary<ModelTier, string?>? tierModelMap,
196+
double highTierCostFloorMultiplier)
187197
{
188198
var flagged = new List<TierRoutingFlaggedCluster>();
189199

@@ -195,7 +205,7 @@ private static IReadOnlyList<TierRoutingFlaggedCluster> FlagClusters(
195205
{
196206
flagged.Add(BuildFlagged(c, "panicEscalation",
197207
$"Low tier averaging {c.AvgToolCalls:F1} tool calls across {c.Count} entries — model likely struggling.",
198-
ModelTier.Balanced, pricing, tierModelMap));
208+
ModelTier.Balanced, pricing, tierModelMap, highTierCostFloorMultiplier));
199209
continue;
200210
}
201211

@@ -204,7 +214,7 @@ private static IReadOnlyList<TierRoutingFlaggedCluster> FlagClusters(
204214
{
205215
flagged.Add(BuildFlagged(c, "tokenSurprise",
206216
$"Score {c.AvgComplexityScore:F2} but post-injection tokens {p} — context inflation, not user complexity. Informational only.",
207-
alternateTier: null, pricing, tierModelMap));
217+
alternateTier: null, pricing, tierModelMap, highTierCostFloorMultiplier));
208218
continue;
209219
}
210220

@@ -213,7 +223,7 @@ private static IReadOnlyList<TierRoutingFlaggedCluster> FlagClusters(
213223
{
214224
flagged.Add(BuildFlagged(c, "lowOutputAtHigh",
215225
$"High tier producing only {o} avg output tokens across {c.Count} entries — possible over-routing.",
216-
ModelTier.Balanced, pricing, tierModelMap));
226+
ModelTier.Balanced, pricing, tierModelMap, highTierCostFloorMultiplier));
217227
}
218228
}
219229

@@ -222,18 +232,19 @@ private static IReadOnlyList<TierRoutingFlaggedCluster> FlagClusters(
222232

223233
private static TierRoutingFlaggedCluster BuildFlagged(
224234
TierRoutingCluster cluster, string flag, string rationale, ModelTier? alternateTier,
225-
IReadOnlyList<LlmPricingRow>? pricing, IReadOnlyDictionary<ModelTier, string?>? tierModelMap)
235+
IReadOnlyList<LlmPricingRow>? pricing, IReadOnlyDictionary<ModelTier, string?>? tierModelMap,
236+
double highTierCostFloorMultiplier)
226237
{
227238
decimal? currentCost = null;
228239
decimal? alternateCost = null;
229240
if (pricing is not null && tierModelMap is not null
230241
&& cluster.AvgInputTokens is long ai && cluster.AvgOutputTokens is long ao)
231242
{
232-
var perCallCurrent = TryPriceCall(tierModelMap.GetValueOrDefault(cluster.Tier), ai, ao, pricing);
243+
var perCallCurrent = TryPriceCallForTier(cluster.Tier, ai, ao, tierModelMap, pricing, highTierCostFloorMultiplier);
233244
currentCost = perCallCurrent * cluster.Count;
234245
if (alternateTier is ModelTier alt)
235246
{
236-
var perCallAlt = TryPriceCall(tierModelMap.GetValueOrDefault(alt), ai, ao, pricing);
247+
var perCallAlt = TryPriceCallForTier(alt, ai, ao, tierModelMap, pricing, highTierCostFloorMultiplier);
237248
alternateCost = perCallAlt * cluster.Count;
238249
}
239250
}
@@ -325,14 +336,15 @@ private static IReadOnlyList<TierRoutingThresholdScan> BuildThresholdScans(
325336
IReadOnlyList<TierRoutingEntry> entries,
326337
double lowCeiling, double balancedCeiling,
327338
IReadOnlyList<LlmPricingRow>? pricing,
328-
IReadOnlyDictionary<ModelTier, string?>? tierModelMap)
339+
IReadOnlyDictionary<ModelTier, string?>? tierModelMap,
340+
double highTierCostFloorMultiplier)
329341
{
330342
return new[]
331343
{
332-
BuildScan(entries, "lowCeiling", +ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap),
333-
BuildScan(entries, "lowCeiling", -ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap),
334-
BuildScan(entries, "balancedCeiling", +ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap),
335-
BuildScan(entries, "balancedCeiling", -ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap)
344+
BuildScan(entries, "lowCeiling", +ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap, highTierCostFloorMultiplier),
345+
BuildScan(entries, "lowCeiling", -ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap, highTierCostFloorMultiplier),
346+
BuildScan(entries, "balancedCeiling", +ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap, highTierCostFloorMultiplier),
347+
BuildScan(entries, "balancedCeiling", -ThresholdScanDelta, lowCeiling, balancedCeiling, pricing, tierModelMap, highTierCostFloorMultiplier)
336348
};
337349
}
338350

@@ -341,7 +353,8 @@ private static TierRoutingThresholdScan BuildScan(
341353
string threshold, double delta,
342354
double lowCeiling, double balancedCeiling,
343355
IReadOnlyList<LlmPricingRow>? pricing,
344-
IReadOnlyDictionary<ModelTier, string?>? tierModelMap)
356+
IReadOnlyDictionary<ModelTier, string?>? tierModelMap,
357+
double highTierCostFloorMultiplier)
345358
{
346359
// Round shifted thresholds to 4 decimals — bare double subtraction yields
347360
// 0.15 - 0.05 = 0.09999999999999998, which makes boundary entries (score 0.10)
@@ -370,8 +383,8 @@ private static TierRoutingThresholdScan BuildScan(
370383
foreach (var (e, from, to) in flips)
371384
{
372385
if (e.InputTokens is not long it || e.OutputTokens is not long ot) continue;
373-
var fromCost = TryPriceCall(tierModelMap.GetValueOrDefault(from), it, ot, pricing);
374-
var toCost = TryPriceCall(tierModelMap.GetValueOrDefault(to), it, ot, pricing);
386+
var fromCost = TryPriceCallForTier(from, it, ot, tierModelMap, pricing, highTierCostFloorMultiplier);
387+
var toCost = TryPriceCallForTier(to, it, ot, tierModelMap, pricing, highTierCostFloorMultiplier);
375388
if (fromCost is null || toCost is null) continue;
376389
accum += toCost.Value - fromCost.Value;
377390
any = true;
@@ -442,11 +455,67 @@ private static TierRoutingProjectedCost BuildProjectedCost(
442455
EntriesUnpricedCount: unpriced);
443456
}
444457

458+
/// <summary>
459+
/// Prices a call by raw model ID at real pricing — no cost floor. Used for
460+
/// <see cref="BuildProjectedCost"/>, which reports actual spend.
461+
/// </summary>
445462
private static decimal? TryPriceCall(string? modelId, long inputTokens, long outputTokens, IReadOnlyList<LlmPricingRow> pricing)
446463
{
447-
if (string.IsNullOrEmpty(modelId)) return null;
448-
var row = pricing.FirstOrDefault(p => modelId.Contains(p.Prefix, StringComparison.OrdinalIgnoreCase));
464+
var row = MatchRow(modelId, pricing);
449465
if (row is null) return null;
450466
return (inputTokens * row.InputPerM + outputTokens * row.OutputPerM) / 1_000_000m;
451467
}
468+
469+
/// <summary>
470+
/// Prices a call for a specific <see cref="ModelTier"/> with the routing cost floor applied
471+
/// (see <see cref="EffectiveTierRates"/>). Used by the threshold-scan and flagged-cluster
472+
/// projections that drive tuning decisions — NOT by real-spend reporting.
473+
/// </summary>
474+
private static decimal? TryPriceCallForTier(
475+
ModelTier tier, long inputTokens, long outputTokens,
476+
IReadOnlyDictionary<ModelTier, string?> tierModelMap,
477+
IReadOnlyList<LlmPricingRow> pricing,
478+
double highTierCostFloorMultiplier)
479+
{
480+
var rates = EffectiveTierRates(tier, tierModelMap, pricing, highTierCostFloorMultiplier);
481+
if (rates is null) return null;
482+
var (inPerM, outPerM) = rates.Value;
483+
return (inputTokens * inPerM + outputTokens * outPerM) / 1_000_000m;
484+
}
485+
486+
/// <summary>
487+
/// Effective per-million-token rates for a tier. For the High tier, rates are floored at
488+
/// <paramref name="highTierCostFloorMultiplier"/> × the Balanced tier's real rates so a
489+
/// Balanced→High shift never projects as zero-cost when High shares Balanced's model.
490+
/// <c>Math.Max</c> keeps a genuinely premium High model at its real (higher) price.
491+
/// Returns <c>null</c> when the tier's model has no pricing row.
492+
/// </summary>
493+
private static (decimal InputPerM, decimal OutputPerM)? EffectiveTierRates(
494+
ModelTier tier,
495+
IReadOnlyDictionary<ModelTier, string?> tierModelMap,
496+
IReadOnlyList<LlmPricingRow> pricing,
497+
double highTierCostFloorMultiplier)
498+
{
499+
var row = MatchRow(tierModelMap.GetValueOrDefault(tier), pricing);
500+
if (row is null) return null;
501+
502+
if (tier == ModelTier.High && highTierCostFloorMultiplier > 1.0)
503+
{
504+
var balanced = MatchRow(tierModelMap.GetValueOrDefault(ModelTier.Balanced), pricing);
505+
if (balanced is not null)
506+
{
507+
var m = (decimal)highTierCostFloorMultiplier;
508+
return (Math.Max(row.InputPerM, balanced.InputPerM * m),
509+
Math.Max(row.OutputPerM, balanced.OutputPerM * m));
510+
}
511+
}
512+
513+
return (row.InputPerM, row.OutputPerM);
514+
}
515+
516+
private static LlmPricingRow? MatchRow(string? modelId, IReadOnlyList<LlmPricingRow> pricing)
517+
{
518+
if (string.IsNullOrEmpty(modelId)) return null;
519+
return pricing.FirstOrDefault(p => modelId.Contains(p.Prefix, StringComparison.OrdinalIgnoreCase));
520+
}
452521
}

0 commit comments

Comments
 (0)