feat(ship-budget): a light diff keeps its segment instead of delegating it (GSTA-167)

check --runtime-lines <n> continues at the Step 7 and 12 boundaries when the diff has at most
50 runtime lines and the segment's calibrated calls at this run's per-call charge cost at most
12M: a child's fixed 2-4.5M (gstack-evals #34) saves at most 3/8 of each parent call.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 23:44:18 +00:00
1 parent cc28dd35c5
commit dbe700417c
5 files changed
+104 -8

No files matched your search

+9 -6
View File
@@ -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/<base> (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 };
});
}
+28 -1
View File
@@ -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')}`;