mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
1 parent
cc28dd35c5
commit
dbe700417c
5 files changed
+104
-8
No files matched your search
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* start --record <path> [--token-cap <n|none>] [--meter capy|claude|estimate] [--reading <n>] [--prior-reading <n>]
|
||||
* [--session <id>|--nonce <v>] [--reserve <n>] [--delegate auto|never] [--calibration <file>] [--ledger <path>]
|
||||
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--prior-reading <n>] [--returned <id>[,<id>...]]
|
||||
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--prior-reading <n>] [--returned <id>[,<id>...]] [--runtime-lines <n>]
|
||||
* settle --run|--record --reservation <id> [--unknown] [--reading <n>] release --run|--record --reservation <id>
|
||||
* handoff --run|--record --from <step> --to <step> --base origin/<base> [--reviewed-tree <t>] [--trail <f> --trail-run <id>]
|
||||
* [--sub-cap <n>] [--reading <n>] [--child-session <id>]
|
||||
|
||||
@@ -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 <n>` (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
|
||||
|
||||
@@ -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
@@ -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')}`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user