diff --git a/README.md b/README.md index 515b002..360ed91 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ validation. Export previews report exact serialized UTF-8 bytes and a model-agnostic `ceil(bytes / 4)` approximate AI-input-token estimate. Compact JSON is the default for AI sharing; Pretty JSON remains available for human review, and preview estimates reflect the selected serialization. This is a sharing-budget aid, not tokenizer output or a billing guarantee. Exports exclude fixed prompts, raw provider payloads, credentials, authorization headers, private reasoning, and unbounded diagnostics. +World Setup also configures server-owned provider-attempt and conservative +credit-admission limits. The per-attempt credit reservation bounds admission +exposure using exact decimal accounting; it is not an upstream provider-account +spending cap or billing guarantee. + ## Opt-in real-provider smoke The smoke command performs exactly one bounded real decision request and validates it. It is never part of default tests or CI: diff --git a/ROADMAP.md b/ROADMAP.md index 2d19af0..7ba63b0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,6 +107,10 @@ Persistent short- and long-term objectives, compact memories, plan revision, sum ## PR 6 — Persistent autonomous world -Scheduled turns, snapshots, replay, retries, idempotency, budgets, failure recovery, and operation without the World Lab browser being open. +Scheduled turns, snapshots, replay, retries, idempotency, durable budget/attempt +ledgers, failure recovery, and operation without the World Lab browser being +open. The current process-local attempt and credit-admission ceilings are an +operator safety boundary, not runtime persistence or provider-account balance +enforcement. Player development begins only after these agent milestones demonstrate compelling behavior. diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 8e58840..4858ad7 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -109,8 +109,10 @@ describe('game API simulation boundary', () => { globalModelId: 'deterministic-script', }, executionLimits: { - version: 'execution-limits-v1', + version: 'execution-limits-v2', providerAttemptLimit: 1, + creditLimit: null, + reservationCreditsPerAttempt: '0.01', }, }), }); @@ -122,7 +124,7 @@ describe('game API simulation boundary', () => { error: { code: 'experiment_budget_exhausted', message: - 'The experiment does not have enough provider attempts remaining for a complete tick.', + 'The experiment does not have enough provider-attempt or credit-admission capacity for a complete tick.', }, }); expect(calls).toBe(0); diff --git a/apps/game-api/src/attempt-accounting.test.ts b/apps/game-api/src/attempt-accounting.test.ts index fb32509..9060153 100644 --- a/apps/game-api/src/attempt-accounting.test.ts +++ b/apps/game-api/src/attempt-accounting.test.ts @@ -3,7 +3,7 @@ import { AttemptAccounting } from './attempt-accounting'; describe('AttemptAccounting', () => { it('reserves atomically and finalizes known and unknown cost exactly once', () => { - const accounting = new AttemptAccounting(3); + const accounting = new AttemptAccounting(3, '0.03', '0.01'); expect(accounting.reserve(3)).toBe(true); expect(accounting.reserve(1)).toBe(false); const first = accounting.startReserved()!; @@ -30,15 +30,21 @@ describe('AttemptAccounting', () => { attemptsFinalized: 3, attemptsInFlight: 0, remainingAttempts: 0, - knownCostCredits: 0.3, + creditLimit: '0.03', + reservationCreditsPerAttempt: '0.01', + unstartedReservedCredits: '0', + committedCreditExposure: '0.31', + remainingAdmissionCredits: '0', + knownFinalizedCostCredits: '0.3', + reservationOverageCredits: '0.28', attemptsWithUnknownCost: 1, exhausted: true, - exhaustionReason: 'provider-attempt-limit', + exhaustionReason: 'credit-reservation-overrun', }); }); it('releases unused reservations without charging them', () => { - const accounting = new AttemptAccounting(3); + const accounting = new AttemptAccounting(3, '0.03', '0.01'); expect(accounting.reserve(3)).toBe(true); const permit = accounting.startReserved()!; accounting.finalize(permit); @@ -48,6 +54,8 @@ describe('AttemptAccounting', () => { attemptsFinalized: 1, remainingAttempts: 2, attemptsWithUnknownCost: 1, + committedCreditExposure: '0.01', + unstartedReservedCredits: '0', exhausted: false, }); }); @@ -63,6 +71,95 @@ describe('AttemptAccounting', () => { }); }); + it('reports configured decimal inputs canonically', () => { + const accounting = new AttemptAccounting(2, '1.00', '0.0100'); + expect(accounting.snapshot()).toMatchObject({ + creditLimit: '1', + reservationCreditsPerAttempt: '0.01', + remainingAdmissionCredits: '1', + }); + }); + + it('uses exact decimal admission, refunds known cost, and retains unknown exposure', () => { + const accounting = new AttemptAccounting(null, '0.00000003', '0.00000001'); + expect(accounting.reserve(3)).toBe(true); + expect(accounting.reserve(1)).toBe(false); + const known = accounting.startReserved()!; + const unknown = accounting.startReserved()!; + accounting.finalize(known, { + provider: 'scripted-test', + model: 'deterministic-script' as never, + latencyMs: 0, + costCredits: 0.000000004, + }); + accounting.finalize(unknown); + accounting.releaseReservations(); + expect(accounting.snapshot()).toMatchObject({ + committedCreditExposure: '0.000000014', + knownFinalizedCostCredits: '0.000000004', + attemptsWithUnknownCost: 1, + remainingAdmissionCredits: '0.000000016', + }); + }); + + it('rejects a reservation atomically and retains in-flight coverage', () => { + const rejected = new AttemptAccounting(10, '0.02', '0.01'); + expect(rejected.reserve(3)).toBe(false); + expect(rejected.reserve(1)).toBe(false); + expect(rejected.snapshot()).toMatchObject({ + reservedPermits: 0, + unstartedReservedCredits: '0', + exhaustionReason: 'credit-admission-limit', + }); + const accounting = new AttemptAccounting(10, '0.02', '0.01'); + expect(accounting.reserve(2)).toBe(true); + expect(accounting.startReserved()).not.toBeNull(); + expect(accounting.snapshot()).toMatchObject({ + committedCreditExposure: '0.01', + unstartedReservedCredits: '0.01', + }); + }); + + it('stops future admission after reported cost exceeds its reservation', () => { + const accounting = new AttemptAccounting(null, '1', '0.01'); + const permit = accounting.startAdditional()!; + accounting.finalize(permit, { + provider: 'scripted-test', + model: 'deterministic-script' as never, + latencyMs: 0, + costCredits: 0.015, + }); + accounting.finalize(permit); + expect(accounting.startAdditional()).toBeNull(); + expect(accounting.snapshot()).toMatchObject({ + committedCreditExposure: '0.015', + knownFinalizedCostCredits: '0.015', + reservationOverageCredits: '0.005', + exhaustionReason: 'credit-reservation-overrun', + attemptsFinalized: 1, + }); + }); + + it('reports an overage without exhausting unlimited credit admission', () => { + const accounting = new AttemptAccounting(null, null, '0.01'); + const permit = accounting.startAdditional()!; + accounting.finalize(permit, { + provider: 'scripted-test', + model: 'deterministic-script' as never, + latencyMs: 0, + costCredits: 0.015, + }); + expect(accounting.startAdditional()).not.toBeNull(); + expect(accounting.snapshot()).toMatchObject({ + creditLimit: null, + committedCreditExposure: '0.025', + knownFinalizedCostCredits: '0.015', + reservationOverageCredits: '0.005', + exhausted: false, + exhaustionReason: null, + }); + }); + it.each([0, -1, 1.5, Number.NaN])( 'rejects invalid internal reservation count %s', (count) => { diff --git a/apps/game-api/src/attempt-accounting.ts b/apps/game-api/src/attempt-accounting.ts index a7b2308..711b4fa 100644 --- a/apps/game-api/src/attempt-accounting.ts +++ b/apps/game-api/src/attempt-accounting.ts @@ -1,5 +1,9 @@ import type { ProviderMetadata } from '@hexzero/shared'; -import { addDecimalValue } from './experiment-export'; + +export type AttemptExhaustionReason = + | 'provider-attempt-limit' + | 'credit-admission-limit' + | 'credit-reservation-overrun'; export interface AttemptAccountingSnapshot { providerAttemptLimit: number | null; @@ -8,33 +12,130 @@ export interface AttemptAccountingSnapshot { attemptsFinalized: number; attemptsInFlight: number; remainingAttempts: number | null; - knownCostCredits: number; + creditLimit: string | null; + reservationCreditsPerAttempt: string; + unstartedReservedCredits: string; + committedCreditExposure: string; + remainingAdmissionCredits: string | null; + knownFinalizedCostCredits: string; + reservationOverageCredits: string; attemptsWithUnknownCost: number; exhausted: boolean; - exhaustionReason: 'provider-attempt-limit' | null; + exhaustionReason: AttemptExhaustionReason | null; +} + +interface Decimal { + integer: bigint; + scale: number; +} + +function parts(value: string | number): Decimal { + const [mantissa, exponentText = '0'] = value + .toString() + .toLowerCase() + .split('e'); + const [whole, fraction = ''] = mantissa!.split('.'); + let integer = BigInt(`${whole}${fraction}`); + let scale = fraction.length - Number(exponentText); + if (scale < 0) { + integer *= 10n ** BigInt(-scale); + scale = 0; + } + return { integer, scale }; +} + +function align(left: Decimal, right: Decimal): [bigint, bigint, number] { + const scale = Math.max(left.scale, right.scale); + return [ + left.integer * 10n ** BigInt(scale - left.scale), + right.integer * 10n ** BigInt(scale - right.scale), + scale, + ]; +} + +function text(integer: bigint, scale: number): string { + if (integer === 0n) return '0'; + if (scale === 0) return integer.toString(); + const padded = integer.toString().padStart(scale + 1, '0'); + return `${padded.slice(0, -scale)}.${padded.slice(-scale)}` + .replace(/0+$/u, '') + .replace(/\.$/u, ''); +} + +function add(left: string, right: string | number): string { + const [a, b, scale] = align(parts(left), parts(right)); + return text(a + b, scale); +} + +function subtractFloor(left: string, right: string): string { + const [a, b, scale] = align(parts(left), parts(right)); + return text(a > b ? a - b : 0n, scale); +} + +function compare(left: string, right: string): number { + const [a, b] = align(parts(left), parts(right)); + return a < b ? -1 : a > b ? 1 : 0; } -/** Billing accounting is independent of transactional world/tick state. */ +function multiply(value: string, count: number): string { + const parsed = parts(value); + return text(parsed.integer * BigInt(count), parsed.scale); +} + +function canonical(value: string): string { + const parsed = parts(value); + return text(parsed.integer, parsed.scale); +} + +/** Provider billing exposure is independent of transactional world/tick state. */ export class AttemptAccounting { #reserved = 0; #started = 0; #finalized = 0; #knownCost = '0'; + #committedExposure = '0'; + #reservationOverage = '0'; #unknownCost = 0; #nextPermitId = 1; readonly #inFlight = new Set(); - #exhausted = false; + #exhaustionReason: AttemptExhaustionReason | null = null; + readonly limit: number | null; + readonly creditLimit: string | null; + readonly reservationCreditsPerAttempt: string; - constructor(readonly limit: number | null) {} + constructor( + limit: number | null, + creditLimit: string | null = null, + reservationCreditsPerAttempt = '0.01', + ) { + this.limit = limit; + this.creditLimit = creditLimit === null ? null : canonical(creditLimit); + this.reservationCreditsPerAttempt = canonical(reservationCreditsPerAttempt); + } reserve(count: number): boolean { if (!Number.isInteger(count) || count < 1) throw new Error('Attempt reservation count must be a positive integer.'); + if (this.#exhaustionReason !== null) return false; if ( this.limit !== null && this.#started + this.#reserved + count > this.limit ) { - this.#exhausted = true; + this.#exhaustionReason = 'provider-attempt-limit'; + return false; + } + const requestedCredits = multiply(this.reservationCreditsPerAttempt, count); + if ( + this.creditLimit !== null && + compare( + add( + add(this.#committedExposure, this.#reservedCredits()), + requestedCredits, + ), + this.creditLimit, + ) > 0 + ) { + this.#exhaustionReason = 'credit-admission-limit'; return false; } this.#reserved += count; @@ -45,6 +146,10 @@ export class AttemptAccounting { if (this.#reserved < 1) return null; this.#reserved -= 1; this.#started += 1; + this.#committedExposure = add( + this.#committedExposure, + this.reservationCreditsPerAttempt, + ); const permitId = this.#nextPermitId++; this.#inFlight.add(permitId); return permitId; @@ -62,30 +167,78 @@ export class AttemptAccounting { finalize(permitId: number, metadata?: ProviderMetadata): void { if (!this.#inFlight.delete(permitId)) return; this.#finalized += 1; - if (metadata?.costCredits === undefined) this.#unknownCost += 1; - else - this.#knownCost = addDecimalValue(this.#knownCost, metadata.costCredits); + if (metadata?.costCredits === undefined) { + this.#unknownCost += 1; + return; + } + const actualParts = parts(metadata.costCredits); + const actual = text(actualParts.integer, actualParts.scale); + this.#knownCost = add(this.#knownCost, actual); + const reservation = this.reservationCreditsPerAttempt; + if (compare(actual, reservation) > 0) { + const overage = subtractFloor(actual, reservation); + this.#reservationOverage = add(this.#reservationOverage, overage); + this.#committedExposure = add(this.#committedExposure, overage); + if (this.creditLimit !== null) + this.#exhaustionReason = 'credit-reservation-overrun'; + } else { + this.#committedExposure = subtractFloor( + this.#committedExposure, + subtractFloor(reservation, actual), + ); + } + } + + #reservedCredits(): string { + return multiply(this.reservationCreditsPerAttempt, this.#reserved); } snapshot(): AttemptAccountingSnapshot { - const remaining = + const remainingAttempts = this.limit === null ? null : Math.max(0, this.limit - this.#started - this.#reserved); - const exhausted = + const unstartedReservedCredits = this.#reservedCredits(); + const remainingAdmissionCredits = + this.creditLimit === null + ? null + : subtractFloor( + this.creditLimit, + add(this.#committedExposure, unstartedReservedCredits), + ); + let reason = this.#exhaustionReason; + if ( + !reason && this.limit !== null && - (this.#exhausted || (remaining === 0 && this.#reserved === 0)); + remainingAttempts === 0 && + this.#reserved === 0 + ) + reason = 'provider-attempt-limit'; + if ( + !reason && + this.creditLimit !== null && + compare(remainingAdmissionCredits!, this.reservationCreditsPerAttempt) < + 0 && + this.#reserved === 0 + ) + reason = 'credit-admission-limit'; return { providerAttemptLimit: this.limit, reservedPermits: this.#reserved, attemptsStarted: this.#started, attemptsFinalized: this.#finalized, attemptsInFlight: this.#inFlight.size, - remainingAttempts: remaining, - knownCostCredits: Number(this.#knownCost), + remainingAttempts, + creditLimit: this.creditLimit, + reservationCreditsPerAttempt: this.reservationCreditsPerAttempt, + unstartedReservedCredits, + committedCreditExposure: this.#committedExposure, + remainingAdmissionCredits, + knownFinalizedCostCredits: this.#knownCost, + reservationOverageCredits: this.#reservationOverage, attemptsWithUnknownCost: this.#unknownCost, - exhausted, - exhaustionReason: exhausted ? 'provider-attempt-limit' : null, + exhausted: reason !== null, + exhaustionReason: reason, }; } } diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index bb0fdfd..aaa77d4 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -1313,7 +1313,7 @@ describe('SimulationService', () => { attemptsStarted: 1, attemptsFinalized: 1, attemptsInFlight: 0, - knownCostCredits: 0.125, + knownFinalizedCostCredits: '0.125', attemptsWithUnknownCost: 0, }, }, @@ -1846,6 +1846,53 @@ describe('SimulationService', () => { }); }); + it('denies an automatic retry when the first unknown-cost call consumed credit exposure', async () => { + let calls = 0; + const simulation = service({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide(_observation, model) { + calls += 1; + throw new AgentProviderError({ + code: 'invalid-json', + message: 'Invalid decision JSON.', + retryable: true, + model, + validationCodes: ['invalid-json'], + }); + }, + }); + const setup = defaultWorldSetupRequest(); + simulation.applyWorldSetup({ + ...setup, + modelConfiguration: { + ...setup.modelConfiguration, + globalModelId: 'deterministic-script', + }, + executionLimits: { + version: 'execution-limits-v2', + providerAttemptLimit: null, + creditLimit: '0.01', + reservationCreditsPerAttempt: '0.01', + }, + }); + + await expect(simulation.executeNextTurn()).rejects.toMatchObject({ + code: 'experiment_budget_exhausted', + message: + 'The experiment does not have enough provider-attempt or credit-admission capacity.', + }); + expect(calls).toBe(1); + expect(simulation.getSnapshot().experiment.attemptAccounting).toMatchObject( + { + committedCreditExposure: '0.01', + attemptsWithUnknownCost: 1, + exhaustionReason: 'credit-admission-limit', + }, + ); + }); + it.each([ ['valid Retry-After', 2_000, 2_000], ['missing Retry-After', undefined, 1_500], @@ -4136,7 +4183,7 @@ describe('SimulationService', () => { attemptsStarted: 4, attemptsFinalized: 4, attemptsInFlight: 0, - knownCostCredits: 0.04, + knownFinalizedCostCredits: '0.04', attemptsWithUnknownCost: 0, }); const skipped = simulation.skipFailedTurn(); @@ -4325,7 +4372,7 @@ describe('SimulationService', () => { attemptAccounting: { attemptsStarted: 1, attemptsFinalized: 1, - knownCostCredits: 0.125, + knownFinalizedCostCredits: '0.125', attemptsWithUnknownCost: 0, }, }, @@ -4703,8 +4750,10 @@ describe('SimulationService', () => { globalModelId: 'deterministic-script', }, executionLimits: { - version: 'execution-limits-v1', + version: 'execution-limits-v2', providerAttemptLimit: setup.roster.length, + creditLimit: '0.08', + reservationCreditsPerAttempt: '0.01', }, }); await simulation.executeNextTick(); @@ -4713,10 +4762,12 @@ describe('SimulationService', () => { attemptsStarted: setup.roster.length, attemptsFinalized: setup.roster.length, attemptsInFlight: 0, - knownCostCredits: 0, + knownFinalizedCostCredits: '0', attemptsWithUnknownCost: 0, remainingAttempts: 0, exhausted: true, + creditLimit: '0.08', + committedCreditExposure: '0', }); simulation.updateAgentPersonality( afterFirst.world.agents[0]!.id, @@ -4734,9 +4785,11 @@ describe('SimulationService', () => { expect(simulation.reset().experiment.attemptAccounting).toMatchObject({ attemptsStarted: 0, attemptsFinalized: 0, - knownCostCredits: 0, + knownFinalizedCostCredits: '0', attemptsWithUnknownCost: 0, exhausted: false, + creditLimit: '0.08', + committedCreditExposure: '0', }); await simulation.executeNextTick(); expect( @@ -4752,9 +4805,57 @@ describe('SimulationService', () => { expect(applied.experiment.attemptAccounting).toMatchObject({ attemptsStarted: 0, attemptsFinalized: 0, - knownCostCredits: 0, + knownFinalizedCostCredits: '0', attemptsWithUnknownCost: 0, exhausted: false, + creditLimit: null, + committedCreditExposure: '0', + }); + }); + + it('rejects a whole simultaneous tick before dispatch when credit cannot cover the roster', async () => { + let calls = 0; + const simulation = service({ + mode: 'scripted-test', + model: 'deterministic-script', + configured: true, + async decide() { + calls += 1; + throw new Error('Credit admission must prevent this call.'); + }, + }); + const setup = defaultWorldSetupRequest(); + simulation.applyWorldSetup({ + ...setup, + modelConfiguration: { + ...setup.modelConfiguration, + globalModelId: 'deterministic-script', + }, + executionLimits: { + version: 'execution-limits-v2', + providerAttemptLimit: null, + creditLimit: '0.07999999', + reservationCreditsPerAttempt: '0.01', + }, + }); + + await expect(simulation.executeNextTick()).rejects.toMatchObject({ + code: 'experiment_budget_exhausted', + }); + expect(calls).toBe(0); + expect(simulation.getSnapshot()).toMatchObject({ + tickNumber: 0, + turnNumber: 0, + status: 'budget-exhausted', + experiment: { + attemptAccounting: { + attemptsStarted: 0, + unstartedReservedCredits: '0', + committedCreditExposure: '0', + remainingAdmissionCredits: '0.07999999', + exhaustionReason: 'credit-admission-limit', + }, + }, }); }); }); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 5a2e4d3..0dbba83 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -103,6 +103,16 @@ import { geographicDirectionBetweenCells } from './geographic-direction'; import { ObservationHistory } from './observation-history'; import { AttemptAccounting } from './attempt-accounting'; +function attemptAccountingForScenario( + executionLimits: AppliedScenario['executionLimits'], +): AttemptAccounting { + return new AttemptAccounting( + executionLimits.providerAttemptLimit, + executionLimits.creditLimit, + executionLimits.reservationCreditsPerAttempt, + ); +} + const RESET_GENERATED_AT = '2026-08-13T12:00:00.000Z'; const MAX_TURN_HISTORY = 120; const MAX_WORLD_EVENT_HISTORY = 120; @@ -348,8 +358,8 @@ export class SimulationService { modelConfiguration: structuredClone(this.#modelConfiguration), behaviorConfiguration: structuredClone(this.#behaviorConfiguration), }; - this.#attemptAccounting = new AttemptAccounting( - this.#scenario.executionLimits.providerAttemptLimit, + this.#attemptAccounting = attemptAccountingForScenario( + this.#scenario.executionLimits, ); } @@ -451,8 +461,8 @@ export class SimulationService { this.#experimentMetrics = new ExperimentMetricAccumulator([ ...this.#state.agents.keys(), ]); - this.#attemptAccounting = new AttemptAccounting( - this.#scenario.executionLimits.providerAttemptLimit, + this.#attemptAccounting = attemptAccountingForScenario( + this.#scenario.executionLimits, ); this.#modelConfiguration = { ...structuredClone(this.#scenario.modelConfiguration), @@ -595,8 +605,8 @@ export class SimulationService { this.#experimentMetrics = new ExperimentMetricAccumulator([ ...this.#state.agents.keys(), ]); - this.#attemptAccounting = new AttemptAccounting( - this.#scenario.executionLimits.providerAttemptLimit, + this.#attemptAccounting = attemptAccountingForScenario( + this.#scenario.executionLimits, ); this.#status = this.#provider.configured ? 'paused' : 'configuration-error'; return this.getSnapshot(); @@ -1054,7 +1064,7 @@ export class SimulationService { this.#status = 'budget-exhausted'; throw new SimulationValidationError( 'experiment_budget_exhausted', - 'The experiment does not have enough provider attempts remaining for a complete tick.', + 'The experiment does not have enough provider-attempt or credit-admission capacity for a complete tick.', ); } @@ -1496,7 +1506,7 @@ export class SimulationService { this.#status = 'budget-exhausted'; throw new SimulationValidationError( 'experiment_budget_exhausted', - 'The experiment provider-attempt limit was exhausted.', + 'The experiment does not have enough provider-attempt or credit-admission capacity.', ); } const currentAttemptStartedAt = this.#now(); diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 96a2d32..b7c04b5 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1121,7 +1121,13 @@ describe('WorldLab', () => { attemptsFinalized: 8, attemptsInFlight: 0, remainingAttempts: 0, - knownCostCredits: 0.25, + creditLimit: '1', + reservationCreditsPerAttempt: '0.01', + unstartedReservedCredits: '0', + committedCreditExposure: '0.25', + remainingAdmissionCredits: '0.75', + knownFinalizedCostCredits: '0.000000004', + reservationOverageCredits: '0', attemptsWithUnknownCost: 1, exhausted: true, exhaustionReason: 'provider-attempt-limit', @@ -1141,6 +1147,7 @@ describe('WorldLab', () => { screen.getByLabelText(/Experiment details\. Tick 0, budget exhausted/), ); expect(screen.getByText('Provider-attempt limit exhausted')).toBeVisible(); + expect(screen.getByText('0.000000004 credits')).toBeVisible(); expect(screen.getByText('8 / 8')).toBeVisible(); expect(screen.getByText('1', { selector: 'dd' })).toBeVisible(); }); @@ -1154,14 +1161,20 @@ describe('WorldLab', () => { attemptAccounting: { providerAttemptLimit: 1, reservedPermits: 0, - attemptsStarted: 0, - attemptsFinalized: 0, + attemptsStarted: 1, + attemptsFinalized: 1, attemptsInFlight: 0, - remainingAttempts: 1, - knownCostCredits: 0, - attemptsWithUnknownCost: 0, + remainingAttempts: 0, + creditLimit: '0.01', + reservationCreditsPerAttempt: '0.01', + unstartedReservedCredits: '0', + committedCreditExposure: '0.01', + remainingAdmissionCredits: '0', + knownFinalizedCostCredits: '0', + reservationOverageCredits: '0', + attemptsWithUnknownCost: 1, exhausted: true, - exhaustionReason: 'provider-attempt-limit', + exhaustionReason: 'credit-admission-limit', }, }, }); @@ -1194,6 +1207,12 @@ describe('WorldLab', () => { expect( screen.getByLabelText(/Experiment details\. Tick 0, budget exhausted/), ).toBeInTheDocument(); + await userEvent + .setup() + .click( + screen.getByLabelText(/Experiment details\. Tick 0, budget exhausted/), + ); + expect(screen.getByText('Credit admission limit exhausted')).toBeVisible(); expect(snapshotReads).toBe(2); }); @@ -1230,6 +1249,18 @@ describe('WorldLab', () => { expect( screen.getByLabelText('Unlimited provider attempts'), ).not.toBeChecked(); + expect( + screen.getByLabelText('Unlimited experiment credit admission'), + ).toBeChecked(); + expect( + screen.getByLabelText('Experiment credit admission limit'), + ).toBeDisabled(); + expect( + screen.getByLabelText('Reserved credits per provider attempt'), + ).toHaveValue('0.01'); + expect( + screen.getByText(/does not guarantee the upstream provider bill/i), + ).toBeVisible(); for (const label of [ 'World simulation seed', 'Spawn assignment seed', diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 819f8e1..2f48e80 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -853,7 +853,7 @@ export function WorldLab() { {formatCost( - snapshot.experiment.attemptAccounting.knownCostCredits, + snapshot.experiment.attemptAccounting.committedCreditExposure, )} @@ -904,10 +904,35 @@ export function WorldLab() {
-
Known credits
+
Committed exposure
{formatCost( - snapshot.experiment.attemptAccounting.knownCostCredits, + snapshot.experiment.attemptAccounting + .committedCreditExposure, + )}{' '} + /{' '} + {snapshot.experiment.attemptAccounting.creditLimit === null + ? 'Unlimited' + : formatCost( + snapshot.experiment.attemptAccounting.creditLimit, + )} +
+
+
+
Unstarted reserved credits
+
+ {formatCost( + snapshot.experiment.attemptAccounting + .unstartedReservedCredits, + )} +
+
+
+
Known finalized cost
+
+ {formatCost( + snapshot.experiment.attemptAccounting + .knownFinalizedCostCredits, )}
@@ -920,10 +945,26 @@ export function WorldLab() { } + {snapshot.experiment.attemptAccounting + .reservationOverageCredits !== '0' && ( +
+
Reservation overage
+
+ {formatCost( + snapshot.experiment.attemptAccounting + .reservationOverageCredits, + )} +
+
+ )} {snapshot.experiment.attemptAccounting.exhaustionReason && (
Execution limit
-
Provider-attempt limit exhausted
+
+ {attemptExhaustionLabel( + snapshot.experiment.attemptAccounting.exhaustionReason, + )} +
)}
@@ -1704,9 +1745,11 @@ function RunHealthSummary({
{elapsedMinutes} min
-
Known cost
+
Committed credit exposure
- {formatCost(snapshot.experiment.attemptAccounting.knownCostCredits)} + {formatCost( + snapshot.experiment.attemptAccounting.committedCreditExposure, + )}
@@ -2302,7 +2345,8 @@ function WorldSetupPanel({ setDraft({ ...draft, executionLimits: { - version: 'execution-limits-v1', + ...draft.executionLimits, + version: 'execution-limits-v2', providerAttemptLimit: event.target.checked ? null : 1000, }, }) @@ -2322,13 +2366,69 @@ function WorldSetupPanel({ setDraft({ ...draft, executionLimits: { - version: 'execution-limits-v1', + ...draft.executionLimits, + version: 'execution-limits-v2', providerAttemptLimit: Number(event.target.value), }, }) } /> + + + +

+ This limits server admission and reserved exposure; it does not + guarantee the upstream provider bill. +