diff --git a/bin/gstack-ship-budget b/bin/gstack-ship-budget index 3f1361c0e..f28c62255 100755 --- a/bin/gstack-ship-budget +++ b/bin/gstack-ship-budget @@ -7,7 +7,7 @@ * * start --record [--token-cap ] [--meter capy|claude|estimate] [--reading ] [--prior-reading ] * [--session |--nonce ] [--reserve ] [--delegate auto|never] [--calibration ] [--ledger ] - * check --run |--record --step --next [,...]> [--reading ] [--prior-reading ] [--returned [,...]] + * check --run |--record --step --next [,...]> [--reading ] [--prior-reading ] [--returned [,...]] [--runtime-lines ] * settle --run|--record --reservation [--unknown] [--reading ] release --run|--record --reservation * handoff --run|--record --from --to --base origin/ [--reviewed-tree ] [--trail --trail-run ] * [--sub-cap ] [--reading ] [--child-session ] diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 232c3c0f3..bee6a573e 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -117,6 +117,14 @@ Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11. `--delegate never` turns delegate into stop for hosts without children. +**Small runs keep their segment.** `check --runtime-lines ` (runtime_lines from +`gstack-review-plan --json`, which `gstack-ship-preflight` prints) continues at the Step 7 and +Step 12 boundaries, with a `SMALL_RUN:` line, when the diff is light (at most 50 runtime lines) +and the segment's calibrated calls at this run's per-call charge cost at most 12M. A child costs +a fixed 2–4.5M (gstack-evals #34: boot, install check, verify-handoff, section reads, +reconcile) and saves at most about 3/8 of each parent call, so a segment under 4.5M ÷ 0.375 +cannot pay for one. Without the flag the rule above applies unchanged. + ## Stop and resume A stop prints diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index 55f83e357..a8ba1ecb3 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -13,7 +13,7 @@ import * as path from 'node:path'; import { resultLine } from './result-codes'; import { BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, - admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, + admits, applyReading, budgetError, budgetLine, keepsSmallRun, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths, runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, type Ledger, type MeterKind, type Next, @@ -94,7 +94,7 @@ export function writeReturnFile(file: string, lines: string[]): void { } /** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */ -export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { +export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false, runtimeLines?: number): Outcome { const p = projector(l); const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`; const { prior, tokens: projected } = p.project(next); @@ -102,7 +102,8 @@ export function decide(l: Ledger, step: string, next: Next, call: number | null, return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'budget_exhausted' }), exit: 3 }; } const boundary = next.kind === 'step' && (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); - const wants = boundary && (startThreshold ? call !== null && call >= DELEGATE_START_CALL : call === null || call >= DELEGATE_SEGMENT_CALL); + const small = boundary && !startThreshold && next.kind === 'step' ? keepsSmallRun(loadCalibration(l.calibration), { runtimeLines, from: next.step, to: SEGMENTS[next.step]!, call }) : null; + const wants = boundary && !small && (startThreshold ? call !== null && call >= DELEGATE_START_CALL : call === null || call >= DELEGATE_SEGMENT_CALL); if (wants && (l.role === 'child' || l.delegate === 'never')) { return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold' }), exit: 3 }; } @@ -110,7 +111,7 @@ export function decide(l: Ledger, step: string, next: Next, call: number | null, return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }), `DELEGATE: Steps ${next.step}-${SEGMENTS[next.step]} go to a fresh child: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${SEGMENTS[next.step]} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; } - const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })]; + const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' }), ...(small ? [small] : [])]; if (l.cap !== null) { const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, prior, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`); @@ -190,7 +191,9 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { * reviewer consumed), measure them, then admit the next step or dispatch. */ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { - const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading', 'returned']); + const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading', 'returned', 'runtime-lines']); + const runtimeRaw = str(o, 'runtime-lines'); + if (runtimeRaw !== undefined && !/^\d+$/.test(runtimeRaw)) throw usageError(`--runtime-lines '${runtimeRaw}' is not a line count; pass runtime_lines from gstack-review-plan --json`); const step = parseStep(str(o, 'step')); const next = parseNext(str(o, 'next')); const returned = (str(o, 'returned') ?? '').split(',').filter(Boolean); @@ -206,7 +209,7 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { if (done) settleIn(l, r); } const drift = freshReading(l, o) ? measureSettled(l) : []; - const out = decide(l, step, next, call); + const out = decide(l, step, next, call, false, runtimeRaw === undefined ? undefined : Number(runtimeRaw)); return { stdout: [...drift, ...out.stdout], exit: out.exit }; }); } diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index ad93c3291..51800efa8 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -25,7 +25,7 @@ import { resolveStateRoot } from './state-root'; import { resultLine, type ResultCodeName } from './result-codes'; import { capyDriveFolder } from './autoplan-checkpoint'; import { BIN_DIR, runScriptSync } from './script-invocation'; -import type { Role } from './review-depth'; +import { SMALL_DIFF_LINES, type Role } from './review-depth'; export const SHIP_STEPS = ['0.9', '1', '2', '3', '4', '5', '6', '6.5', '7', '8', '9', '10', '11', '11.5', '12', '13', '14', '14.5', '15', '16', '17', '18', '19', '20', '21', '22'] as const; export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check'] as const satisfies readonly Role[]; @@ -199,6 +199,33 @@ export function segmentEstimate(cal: Calibration, unit: string, from: string, to return SHIP_STEPS.slice(i, j + 1).reduce((sum, s) => sum + stepProjection(cal, unit, s) + (cal.segment_dispatches[s] ?? 0) * k.per_dispatch, 0); } +/** + * Small-run rule (GSTA-167 PR 4). A delegation pays a fixed child cost (boot, install check, verify-handoff, + * section reads, reconcile: gstack-evals #34's two children cost 4.53M, 2-4.5M each) to save the parent's + * context above the host floor on each call of the segment. #34's parent calls ran 0.2-0.3M over a 0.15M + * floor, so a fresh child saves at most about 3/8 of a call, and a segment under CHILD_FIXED_COST over that + * share (12M) cannot pay for its child. A light-depth diff (runtime lines at most SMALL_DIFF_LINES) keeps + * its segment when the segment's calibrated calls at this run's own per-call charge stay under that. + */ +export const SMALL_RUN_LINES = SMALL_DIFF_LINES; +export const CHILD_FIXED_COST = 4_500_000; +export const SMALL_RUN_SEGMENT = CHILD_FIXED_COST / 0.375; +/** #34's per-call charge midpoint, used when this call's charge is unknown. */ +export const SMALL_RUN_CALL = 250_000; + +export function smallRunSegment(cal: Calibration, from: string, to: string, call: number | null): number { + const steps = SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1); + return steps.reduce((n, s) => n + (cal.step_calls[s] ?? cal.step_calls.default ?? 0), 0) * (call ?? SMALL_RUN_CALL); +} + +/** The SMALL_RUN line when a segment boundary should continue instead of delegating; null keeps the delegate rule. */ +export function keepsSmallRun(cal: Calibration, o: { runtimeLines: number | undefined; from: string; to: string; call: number | null }): string | null { + if (o.runtimeLines === undefined || o.runtimeLines > SMALL_RUN_LINES) return null; + const segment = smallRunSegment(cal, o.from, o.to, o.call); + if (segment > SMALL_RUN_SEGMENT) return null; + return `SMALL_RUN: runtime_lines=${o.runtimeLines}<=${SMALL_RUN_LINES} segment=${o.from}-${o.to} projected=${segment}<=${SMALL_RUN_SEGMENT}; continuing here: a child's fixed ${CHILD_FIXED_COST} would cost more than it saves`; +} + export const runDirFor = (env: NodeJS.ProcessEnv, run: string) => path.join(resolveStateRoot(env), 'ship-runs', run); export function mintRunId(now = new Date()): string { return `ship-${now.toISOString().replace(/[-:]/g, '').slice(0, 15)}-${crypto.randomBytes(3).toString('hex')}`; diff --git a/test/ship-budget-small-run.test.ts b/test/ship-budget-small-run.test.ts new file mode 100644 index 000000000..d3c9c2d80 --- /dev/null +++ b/test/ship-budget-small-run.test.ts @@ -0,0 +1,58 @@ +/** + * GSTA-167 PR 4: a small run keeps its segment instead of delegating it. A child + * costs a fixed 2-4.5M (boot, install check, verify-handoff, section reads, + * reconcile; gstack-evals #34), so a light-depth diff whose segment costs less + * than a child can save continues in the parent's context. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { CHILD_FIXED_COST, keepsSmallRun, loadCalibration, SMALL_RUN_LINES, SMALL_RUN_SEGMENT, smallRunSegment } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const cal = loadCalibration(); +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +describe('small-run rule', () => { + // Value: protects=the threshold is the child's fixed cost over the share of a call a fresh child can save, so a segment under it never pays for a child; fails_when=the constants drift apart or the segment ignores this run's own per-call charge; + // why_new=no prior test prices a segment from the measured per-call charge; seam=none; no_claim=the #34 measurements themselves + test('the segment threshold is derived from the child cost; the segment is its calibrated calls at this run\'s per-call charge', () => { + expect(SMALL_RUN_SEGMENT).toBe(12_000_000); + expect(SMALL_RUN_SEGMENT * 0.375).toBe(CHILD_FIXED_COST); + const calls = ['7', '8', '9', '10', '11', '11.5'].reduce((n, s) => n + (cal.step_calls[s] ?? cal.step_calls.default!), 0); + expect(smallRunSegment(cal, '7', '11.5', 200_000)).toBe(calls * 200_000); + expect(smallRunSegment(cal, '7', '11.5', null)).toBe(calls * 250_000); + }); + + // Value: protects=only a light diff with a cheap segment keeps it, and a missing runtime count keeps today's delegation; fails_when=a large diff or a costly context stops delegating, or an unknown diff size is treated as small; + // why_new=keepsSmallRun is new; seam=none; no_claim=what the parent does after continuing + test('keeps only a light diff whose segment is under the threshold; an unknown runtime count never keeps', () => { + expect(keepsSmallRun(cal, { runtimeLines: 0, from: '7', to: '11.5', call: 220_000 })).toContain('SMALL_RUN: runtime_lines=0'); + expect(keepsSmallRun(cal, { runtimeLines: SMALL_RUN_LINES, from: '12', to: '22', call: 300_000 })).not.toBeNull(); + expect(keepsSmallRun(cal, { runtimeLines: SMALL_RUN_LINES + 1, from: '7', to: '11.5', call: 220_000 })).toBeNull(); + expect(keepsSmallRun(cal, { runtimeLines: 0, from: '7', to: '11.5', call: 400_000 })).toBeNull(); + expect(keepsSmallRun(cal, { runtimeLines: undefined, from: '7', to: '11.5', call: 220_000 })).toBeNull(); + }); +}); + +describe('check --runtime-lines', () => { + // Value: protects=the registered check command continues a small run at a segment boundary and still delegates a large one; fails_when=check ignores --runtime-lines or applies it to a large diff; + // why_new=the delegation tests never pass a diff size; seam=none; no_claim=the handoff itself + test('a light diff continues at the 6->7 boundary with a SMALL_RUN line; a large diff and an omitted count still delegate', () => { + const h = fresh(); + const start = (cap: string) => { + const r = h.sb(['start', '--record', h.record(`r${cap}.md`), '--token-cap', cap, '--reading', '0']); + return h.field(r.out, 'BUDGET_RUN', 'id'); + }; + const small = h.sb(['check', '--run', start('80M'), '--step', '6', '--next', '7', '--reading', '1220000', '--prior-reading', '1000000', '--runtime-lines', '0']); + expect(small.code).toBe(0); + expect(h.field(small.out, 'BUDGET', 'verdict')).toBe('continue'); + expect(small.out).toContain('SMALL_RUN: runtime_lines=0'); + expect(small.out).not.toContain('DELEGATE:'); + const large = h.sb(['check', '--run', start('81M'), '--step', '6', '--next', '7', '--reading', '1220000', '--prior-reading', '1000000', '--runtime-lines', '900']); + expect(h.field(large.out, 'BUDGET', 'verdict')).toBe('delegate'); + const omitted = h.sb(['check', '--run', start('82M'), '--step', '6', '--next', '7', '--reading', '1220000', '--prior-reading', '1000000']); + expect(h.field(omitted.out, 'BUDGET', 'verdict')).toBe('delegate'); + expect(h.sb(['check', '--run', start('83M'), '--step', '6', '--next', '7', '--reading', '5', '--runtime-lines', 'many']).code).toBe(2); + }); +});