Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ repair or transient-retry charge per agent. All agents observe the
same frozen pre-tick world; valid decisions resolve together while an individual
provider failure is retained as that agent's final lost tick. Start is
deliberately disabled when the server has no key. This development API has no
authentication or cost controls and is not suitable for an unauthenticated
authentication or monetary cost controls and is not suitable for an unauthenticated
public deployment.

State is held only in the Game API process. The API captures one active safe
Expand Down
64 changes: 64 additions & 0 deletions apps/game-api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,70 @@ describe('provider environment compatibility', () => {
});

describe('game API simulation boundary', () => {
it('returns a specific 409 before dispatch when a full tick cannot be reserved', async () => {
let calls = 0;
const app = createApp({
provider: {
mode: 'scripted-test',
model: 'deterministic-script',
configured: true,
async decide(): Promise<ProviderDecision> {
calls += 1;
return {
decision: { worldAction: { type: 'wait' }, summary: 'Wait.' },
metadata: {
provider: 'scripted-test',
model: 'deterministic-script',
latencyMs: 0,
costCredits: 0,
},
};
},
},
});
const defaults = defaultWorldSetupResponseSchema.parse(
await (
await app.request('/api/simulation/experiment/setup/default')
).json(),
).request;
await app.request('/api/simulation/experiment/setup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...defaults,
modelConfiguration: {
...defaults.modelConfiguration,
globalModelId: 'deterministic-script',
},
executionLimits: {
version: 'execution-limits-v1',
providerAttemptLimit: 1,
},
}),
});
const response = await app.request('/api/simulation/tick', {
method: 'POST',
});
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
error: {
code: 'experiment_budget_exhausted',
message:
'The experiment does not have enough provider attempts remaining for a complete tick.',
},
});
expect(calls).toBe(0);
expect(
simulationSnapshotSchema.parse(
await (await app.request('/api/simulation')).json(),
),
).toMatchObject({
tickNumber: 0,
status: 'budget-exhausted',
experiment: { attemptAccounting: { attemptsStarted: 0 } },
});
});

it('requires a known Patient Zero through the public setup boundary', async () => {
const app = createApp({
provider: new ScriptedAgentProvider([
Expand Down
76 changes: 76 additions & 0 deletions apps/game-api/src/attempt-accounting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { AttemptAccounting } from './attempt-accounting';

describe('AttemptAccounting', () => {
it('reserves atomically and finalizes known and unknown cost exactly once', () => {
const accounting = new AttemptAccounting(3);
expect(accounting.reserve(3)).toBe(true);
expect(accounting.reserve(1)).toBe(false);
const first = accounting.startReserved()!;
const second = accounting.startReserved()!;
const third = accounting.startReserved()!;
accounting.finalize(first, {
provider: 'scripted-test',
model: 'deterministic-script' as never,
latencyMs: 0,
costCredits: 0.1,
});
accounting.finalize(first);
accounting.finalize(second, {
provider: 'scripted-test',
model: 'deterministic-script' as never,
latencyMs: 0,
costCredits: 0.2,
});
accounting.finalize(third);
expect(accounting.snapshot()).toEqual({
providerAttemptLimit: 3,
reservedPermits: 0,
attemptsStarted: 3,
attemptsFinalized: 3,
attemptsInFlight: 0,
remainingAttempts: 0,
knownCostCredits: 0.3,
attemptsWithUnknownCost: 1,
exhausted: true,
exhaustionReason: 'provider-attempt-limit',
});
});

it('releases unused reservations without charging them', () => {
const accounting = new AttemptAccounting(3);
expect(accounting.reserve(3)).toBe(true);
const permit = accounting.startReserved()!;
accounting.finalize(permit);
accounting.releaseReservations();
expect(accounting.snapshot()).toMatchObject({
attemptsStarted: 1,
attemptsFinalized: 1,
remainingAttempts: 2,
attemptsWithUnknownCost: 1,
exhausted: false,
});
});

it('reports explicit unlimited capacity', () => {
const accounting = new AttemptAccounting(null);
expect(accounting.startAdditional()).not.toBeNull();
expect(accounting.snapshot()).toMatchObject({
providerAttemptLimit: null,
remainingAttempts: null,
exhausted: false,
exhaustionReason: null,
});
});

it.each([0, -1, 1.5, Number.NaN])(
'rejects invalid internal reservation count %s',
(count) => {
const accounting = new AttemptAccounting(10);
expect(() => accounting.reserve(count)).toThrow(
'Attempt reservation count must be a positive integer.',
);
expect(accounting.snapshot().attemptsStarted).toBe(0);
},
);
});
91 changes: 91 additions & 0 deletions apps/game-api/src/attempt-accounting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ProviderMetadata } from '@hexzero/shared';
import { addDecimalValue } from './experiment-export';

export interface AttemptAccountingSnapshot {
providerAttemptLimit: number | null;
reservedPermits: number;
attemptsStarted: number;
attemptsFinalized: number;
attemptsInFlight: number;
remainingAttempts: number | null;
knownCostCredits: number;
attemptsWithUnknownCost: number;
exhausted: boolean;
exhaustionReason: 'provider-attempt-limit' | null;
}

/** Billing accounting is independent of transactional world/tick state. */
export class AttemptAccounting {
#reserved = 0;
#started = 0;
#finalized = 0;
#knownCost = '0';
#unknownCost = 0;
#nextPermitId = 1;
readonly #inFlight = new Set<number>();
#exhausted = false;

constructor(readonly limit: number | null) {}

reserve(count: number): boolean {
if (!Number.isInteger(count) || count < 1)
throw new Error('Attempt reservation count must be a positive integer.');
if (
this.limit !== null &&
this.#started + this.#reserved + count > this.limit
) {
this.#exhausted = true;
return false;
}
this.#reserved += count;
return true;
}

startReserved(): number | null {
if (this.#reserved < 1) return null;
this.#reserved -= 1;
this.#started += 1;
const permitId = this.#nextPermitId++;
this.#inFlight.add(permitId);
return permitId;
}

startAdditional(): number | null {
if (!this.reserve(1)) return null;
return this.startReserved();
}

releaseReservations(): void {
this.#reserved = 0;
}

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);
}

snapshot(): AttemptAccountingSnapshot {
const remaining =
this.limit === null
? null
: Math.max(0, this.limit - this.#started - this.#reserved);
const exhausted =
this.limit !== null &&
(this.#exhausted || (remaining === 0 && this.#reserved === 0));
return {
providerAttemptLimit: this.limit,
reservedPermits: this.#reserved,
attemptsStarted: this.#started,
attemptsFinalized: this.#finalized,
attemptsInFlight: this.#inFlight.size,
remainingAttempts: remaining,
knownCostCredits: Number(this.#knownCost),
attemptsWithUnknownCost: this.#unknownCost,
exhausted,
exhaustionReason: exhausted ? 'provider-attempt-limit' : null,
};
}
}
2 changes: 1 addition & 1 deletion apps/game-api/src/experiment-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2134,7 +2134,7 @@ export function serializeExperimentExport(
: JSON.stringify(document);
}

function addDecimalValue(left: string, right: number): string {
export function addDecimalValue(left: string, right: number): string {
const leftParts = decimalParts(left);
const rightParts = decimalParts(right);
const scale = Math.max(leftParts.scale, rightParts.scale);
Expand Down
Loading
Loading