Files
gbrain/test/batch-projection.test.ts
T
127842e9ef v0.41.22.1 feat: brainstorm/lsd judge fixes (closes #1540 end-to-end) (#1562)
* feat(core): add splitProviderModelId centralizer for pricing-side parsing

New pure helper in src/core/model-id.ts that splits provider:model,
provider/model, and bare model strings into a {provider, model} pair.
Defensive contract: null/undefined/empty/whitespace returns
{provider: null, model: ''}.

Will be wired into the 5 pricing/budget sites in the next commit.
Named splitProviderModelId (not parseModelId) to avoid the in-project
collision with the gateway-side src/core/ai/model-resolver.ts:parseModelId
which has a different bare-name contract.

Pinned by 16 cases in test/model-id.test.ts covering all separator
forms plus defensive + edge inputs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(gateway): accept slash-form provider id in model-resolver

src/core/ai/model-resolver.ts:parseModelId now accepts both
provider:model (colon) and provider/model (slash) forms. Colon wins
when both separators present so OpenRouter nested ids like
openrouter:anthropic/claude-sonnet-4.6 route as
{providerId: 'openrouter', modelId: 'anthropic/claude-sonnet-4.6'}.

Pre-fix: every gateway entry point (chat / embed / rerank) threw
AIConfigError 'missing a provider prefix' on slash form ids. That
meant CLI users running

  gbrain brainstorm --judge-model anthropic/claude-sonnet-4-6

would still fail mid-judge with AIConfigError even after pricing
was relaxed to accept slash form. Closes the end-to-end bug class.

Bare names without ANY separator still throw — gateway routing
always needs an explicit provider. Existing tests pinning that
throw (test/ai/capabilities.test.ts:43) stay green.

Pinned by 10 cases in test/ai/model-resolver-slash.test.ts
including a resolveRecipe round-trip that slash and colon forms
land on the same recipe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: route 5 pricing/config sites through splitProviderModelId

Five sites had inline ':'-only provider-prefix splits that silently
missed slash-form ids. Centralizing through splitProviderModelId
closes the bug class:

  - src/core/anthropic-pricing.ts:estimateMaxCostUsd
  - src/core/budget/budget-tracker.ts:lookupPricing (closes the
    headline BudgetExhausted no_pricing failure on --max-cost +
    slash-form --judge-model)
  - src/core/eval-contradictions/cost-tracker.ts:pricingFor
    (legacy silent-Haiku fallback preserved per plan D9)
  - src/core/minions/batch-projection.ts (deleted bareModel inline
    helper; inlined splitProviderModelId at 2 call sites)
  - src/core/model-config.ts:isAnthropicProvider (silently fixed
    v0.31.12 subagent-guard bypass for slash-form Anthropic ids)

Test gates land together so any bisect step is green:

  - NEW test/anthropic-pricing.test.ts (7 cases including structural
    regression guard: every ANTHROPIC_PRICING key reachable via all
    three forms)
  - NEW test/eval-contradictions/cost-tracker-slash.test.ts (6 cases
    including legacy-Haiku-fallback pin)
  - EXTENDED test/batch-projection.test.ts (slash + double-separator
    cases)
  - EXTENDED test/model-config.serial.test.ts (2 slash-form
    isAnthropicProvider cases)
  - EXTENDED test/core/budget/budget-tracker.test.ts (2 slash + colon
    reserve() cases)

Behavior changes for slash-prefix ids only; bare and colon ids
unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(brainstorm): scale judge maxTokens with per-model output cap

Replace the hard-coded maxTokens: 4000 with computeJudgeMaxTokens
that scales with idea count and respects each model's actual output
cap.

Pre-fix: any judge call with 36+ ideas produced ~100 tokens/idea of
JSON that got truncated mid-output. parseJudgeJSON threw, orchestrator
surfaced judge_failed: true, all ideas saved unscored. Verified
failure mode on 72-idea fixture: 0/72 passing before, 39/72 after.

Formula: min(modelCap, max(LEGACY_MIN_MAX_TOKENS, ideaCount*150+500))

Named constants extracted at top of judges.ts:

  - TOKEN_BUDGET_PER_IDEA = 150 (1.5x headroom over observed ~100/idea)
  - TOKEN_BUDGET_ENVELOPE = 500 (JSON wrapper)
  - LEGACY_MIN_MAX_TOKENS = 4000 (pre-fix floor preserved for 1-idea)
  - MAX_OUTPUT_TOKENS_CEIL = 32_000 (fallback when model unknown)
  - ANTHROPIC_OUTPUT_CAPS (per-model: Opus 4.7 = 32K, Sonnet 4.6 /
    Haiku 4.5 = 64K, legacy 3.5 = 8K)

When the caller passes no modelOverride, the cap routes through the
gateway's actual configured chat model via getChatModel() so the
formula matches what chat() will use, not whatever the override
hints at. Pre-fix the undefined-override case fell back to 32K even
if the configured default was a legacy 8K model.

Pinned by 16 cases in test/brainstorm/judges-maxtokens.test.ts:
formula at 1/10/36/96/200/300 ideas, per-model cap binding (Haiku 3.5
8K, Opus 4.7 32K, Sonnet 4.6 64K), and integration via runJudge with
a stubbed chatFn that captures ChatOpts.maxTokens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: bump version and changelog (v0.41.21.0)

Brainstorm judge fix-wave: closes #1540 end-to-end. parseModelId
centralizer + gateway resolver slash-form acceptance + per-model
maxTokens cap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: update project documentation for v0.41.21.0

CLAUDE.md: add v0.41.21.0 annotations to brainstorm/judges + model-config
entries; add new key-files entry for src/core/model-id.ts (the shared
splitProviderModelId centralizer) and src/core/ai/model-resolver.ts
slash-form extension.

README.md: add user-facing callout for the brainstorm judge_failed +
slash-form pricing fix, mirroring the v0.41.19.0 callout shape.

llms-full.txt: regenerated to absorb the CLAUDE.md + README changes
(passes test/build-llms.test.ts drift guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:16:30 -07:00

283 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.41 D4 — batch projection unit tests.
*
* Pure-function math; no DB or LLM needed. Pins the 4 critical contracts:
*
* 1. Cold-start fallback (no history → wide-guess with annotation)
* 2. Unknown model → cost_estimate_unavailable tagged variant
* 3. ±30% confidence band (or sample-stddev-derived when historical)
* 4. Threshold gating respects env-var overrides
* 5. Raise-cap hint fires only when lease cap is binding + meaningful speedup
*/
import { describe, test, expect } from 'bun:test';
import {
projectBatch,
formatProjection,
shouldPromptAtThreshold,
type RecentJobStats,
} from '../src/core/minions/batch-projection.ts';
import { withEnv } from './helpers/with-env.ts';
function coldStats(opts: Partial<RecentJobStats> = {}): RecentJobStats {
return {
sample_size: 0,
effective_concurrency: 8,
...opts,
};
}
function warmStats(opts: Partial<RecentJobStats> = {}): RecentJobStats {
return {
sample_size: 100,
mean_latency_ms: 4000,
mean_cost_usd: 0.03,
stddev_cost_usd: 0.01,
effective_concurrency: 8,
...opts,
};
}
describe('projectBatch', () => {
test('cold start: no history → cold_start=true with model-default cost guess', () => {
const p = projectBatch({
job_count: 100,
model: 'anthropic:claude-sonnet-4-6',
stats: coldStats(),
current_lease_cap: 32,
});
expect(p.cold_start).toBe(true);
expect(p.total_cost_usd).toBeGreaterThan(0);
expect(p.total_duration_ms).toBeGreaterThan(0);
// 100 jobs at concurrency=8, ~5s each → ≈63s.
expect(p.effective_concurrency).toBe(8);
});
test('unknown model → unknown_model tagged variant, total_cost_usd=null', () => {
const p = projectBatch({
job_count: 100,
model: 'mystery:foo-2',
stats: coldStats(),
current_lease_cap: 32,
});
expect(p.unknown_model).toBe('foo-2');
expect(p.total_cost_usd).toBeNull();
expect(p.cost_band_usd).toBeNull();
// Duration is still computable from latency × jobs / concurrency.
expect(p.total_duration_ms).toBeGreaterThan(0);
});
test('warm window with stddev → uses stddev-derived band (×1.96 ≈ 95%)', () => {
const p = projectBatch({
job_count: 100,
model: 'anthropic:claude-sonnet-4-6',
stats: warmStats({ mean_cost_usd: 0.05, stddev_cost_usd: 0.02 }),
current_lease_cap: 32,
});
expect(p.cold_start).toBe(false);
expect(p.total_cost_usd).toBeCloseTo(5.00, 2); // 100 × $0.05
expect(p.cost_band_usd).toBeCloseTo(100 * 0.02 * 1.96, 1); // ≈ $3.92
});
test('warm window without stddev → blanket ±30% band', () => {
const p = projectBatch({
job_count: 100,
model: 'anthropic:claude-sonnet-4-6',
stats: warmStats({ stddev_cost_usd: undefined, mean_cost_usd: 0.05 }),
current_lease_cap: 32,
});
expect(p.cost_band_usd).toBeCloseTo(p.total_cost_usd! * 0.30, 2);
});
test('effective_concurrency clamps to min(seen, lease_cap)', () => {
const p = projectBatch({
job_count: 100,
model: 'anthropic:claude-sonnet-4-6',
stats: warmStats({ effective_concurrency: 16 }),
current_lease_cap: 4, // tighter than seen
});
expect(p.effective_concurrency).toBe(4);
});
test('raise_cap_hint fires when lease is binding AND a 4x raise meaningfully helps', () => {
const p = projectBatch({
job_count: 1000,
model: 'anthropic:claude-sonnet-4-6',
stats: warmStats({
effective_concurrency: 32, // seen lots; lease is the constraint
lease_headroom: 0.05, // hot — nearly saturated
}),
current_lease_cap: 8,
});
expect(p.raise_cap_hint).toBeDefined();
expect(p.raise_cap_hint).toContain('GBRAIN_ANTHROPIC_MAX_INFLIGHT');
});
test('no raise_cap_hint when not binding', () => {
const p = projectBatch({
job_count: 100,
model: 'anthropic:claude-sonnet-4-6',
stats: warmStats({ lease_headroom: 0.8 }), // plenty of room
current_lease_cap: 32,
});
expect(p.raise_cap_hint).toBeUndefined();
});
test('no raise_cap_hint when already at the ceiling', () => {
const p = projectBatch({
job_count: 100,
model: 'anthropic:claude-sonnet-4-6',
stats: warmStats({ lease_headroom: 0.05 }),
current_lease_cap: 128, // at ceiling
});
expect(p.raise_cap_hint).toBeUndefined();
});
describe('v0.41.20.0 — slash-prefix model id routing (THE FIX)', () => {
test('slash-form anthropic/claude-sonnet-4-6 strips to bare name + pricing hits', () => {
// Pre-fix: inline bareModel(model) only handled `:`; slash-form fell
// through to the unknown_model branch silently. Post-fix: parseModelId
// handles both forms; pricing lookup succeeds; cold-start path produces
// a non-null cost estimate.
const p = projectBatch({
job_count: 100,
model: 'anthropic/claude-sonnet-4-6',
stats: { sample_size: 0, effective_concurrency: 4 },
current_lease_cap: 32,
});
expect(p.unknown_model).toBeUndefined();
expect(p.total_cost_usd).not.toBeNull();
expect(p.total_cost_usd).toBeGreaterThan(0);
});
test('double-separator openrouter:anthropic/X → unknown_model branch fires', () => {
// Per D2: colon wins; tail is `anthropic/claude-sonnet-4-6` which
// doesn't match ANTHROPIC_PRICING keys. Confirms the deliberate
// OpenRouter-out-of-scope posture is observable downstream.
const p = projectBatch({
job_count: 100,
model: 'openrouter:anthropic/claude-sonnet-4-6',
stats: { sample_size: 0, effective_concurrency: 4 },
current_lease_cap: 32,
});
expect(p.unknown_model).toBe('anthropic/claude-sonnet-4-6');
expect(p.total_cost_usd).toBeNull();
});
});
});
describe('formatProjection', () => {
test('known model: prints cost + duration + bands', () => {
const s = formatProjection({
total_duration_ms: 600_000, // 10 min
total_cost_usd: 2.40,
cost_band_usd: 0.72,
duration_band_ms: 180_000,
effective_concurrency: 8,
cold_start: false,
});
expect(s).toContain('$2.40');
expect(s).toContain('±$0.72');
expect(s).toContain('10min');
expect(s).toContain('concurrency=8');
expect(s).not.toContain('no history');
});
test('cold start: includes annotation', () => {
const s = formatProjection({
total_duration_ms: 600_000,
total_cost_usd: 2.40,
cost_band_usd: 0.72,
duration_band_ms: 180_000,
effective_concurrency: 8,
cold_start: true,
});
expect(s).toContain('no history');
expect(s).toContain('wide guess');
});
test('unknown model: replaces cost section with explanation', () => {
const s = formatProjection({
total_duration_ms: 600_000,
total_cost_usd: null,
cost_band_usd: null,
duration_band_ms: 180_000,
effective_concurrency: 8,
cold_start: true,
unknown_model: 'foo-2',
});
expect(s).toContain('cost estimate unavailable');
expect(s).toContain('foo-2');
expect(s).toContain('pricing maps');
});
test('raise-cap hint surfaces inline', () => {
const s = formatProjection({
total_duration_ms: 600_000,
total_cost_usd: 2.40,
cost_band_usd: 0.72,
duration_band_ms: 180_000,
effective_concurrency: 8,
cold_start: false,
raise_cap_hint: 'raise GBRAIN_ANTHROPIC_MAX_INFLIGHT to 32 to finish in ~3min',
});
expect(s).toContain('raise GBRAIN_ANTHROPIC_MAX_INFLIGHT to 32');
});
});
describe('shouldPromptAtThreshold', () => {
test('prompts at >$5 default threshold', () => {
expect(
shouldPromptAtThreshold({
total_duration_ms: 60_000,
total_cost_usd: 5.01,
cost_band_usd: 1,
duration_band_ms: 10_000,
effective_concurrency: 8,
cold_start: false,
}),
).toBe(true);
});
test('prompts at >30min default threshold', () => {
expect(
shouldPromptAtThreshold({
total_duration_ms: 31 * 60_000,
total_cost_usd: 0.50,
cost_band_usd: 0.10,
duration_band_ms: 60_000,
effective_concurrency: 8,
cold_start: false,
}),
).toBe(true);
});
test('does NOT prompt below both thresholds', () => {
expect(
shouldPromptAtThreshold({
total_duration_ms: 60_000,
total_cost_usd: 2.00,
cost_band_usd: 0.50,
duration_band_ms: 10_000,
effective_concurrency: 8,
cold_start: false,
}),
).toBe(false);
});
test('env var GBRAIN_BATCH_PROMPT_THRESHOLD_USD lowers the prompt floor', async () => {
await withEnv({ GBRAIN_BATCH_PROMPT_THRESHOLD_USD: '1' }, async () => {
expect(
shouldPromptAtThreshold({
total_duration_ms: 60_000,
total_cost_usd: 1.50,
cost_band_usd: 0.20,
duration_band_ms: 10_000,
effective_concurrency: 8,
cold_start: false,
}),
).toBe(true);
});
});
});