diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index d39c2cf95..a505ec214 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -22,7 +22,7 @@ Resolve `scripts/install.sh` relative to this SKILL.md, not the working reposito ## Launch every gstack command with the GSTACK_LAUNCH line -Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: ` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: `: the same assignments as a sourceable script, mode 0600, under the state root beside the install verdict. Apply them with `. ` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands); the plain `GSTACK_LAUNCH:` line is for callers that do not evaluate shell syntax, never for re-typing. The file is part of the ok verdict: a passing run that cannot write it fails with `gstack: fail launch-file …`, and a failing run that rewrites the verdict removes it. Never re-type them as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export` in a tool fence: when the substitution comes back empty, `export $(...)` is a bare `export`, which prints every exported variable, API keys included, into the tool output (that is how GSTA-96's docs-audit child leaked three keys). Hand a child agent the file path, never the values. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum. +Every successful run, `--check` included, prints `GSTACK_LAUNCH: PATH=:$PATH GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT= GSTACK_EPHEMERAL=1 GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=` (the `PATH=` part only when the verified Bun is not first on PATH), then `GSTACK_LAUNCH_PATH: ` for callers that do not evaluate shell syntax, then `GSTACK_LAUNCH_FILE: `: the same assignments as a sourceable script, plus the checkout's `bin/` appended to PATH (so a bare `gstack-ship-budget` or `gstack-review-packet` resolves in a shell that sourced only this file), mode 0600, under the state root beside the install verdict. Apply them with `. ` at the start of every shell that runs a gstack command (each Capy bash call is a fresh shell, and a child shell's export cannot configure the parent's later commands); the plain `GSTACK_LAUNCH:` line is for callers that do not evaluate shell syntax, never for re-typing. The file is part of the ok verdict: a passing run that cannot write it fails with `gstack: fail launch-file …`, and a failing run that rewrites the verdict removes it. Never re-type them as `export $(...)`, and never run `env`, `printenv`, a bare `set` or a bare `export` in a tool fence: when the substitution comes back empty, `export $(...)` is a bare `export`, which prints every exported variable, API keys included, into the tool output (that is how GSTA-96's docs-audit child leaked three keys). Hand a child agent the file path, never the values. The PATH part is what makes gstack's `#!/usr/bin/env bun` commands run the Bun the trailer certified; the Capy image's `/usr/local/bin/bun` is 1.3.14, below gstack's minimum. With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session checks the install verdict the last run wrote for this checkout, HEAD and workflow, and the Bun on PATH. When either fails it prints an `INSTALL: fail …` row with its fix and `GSTACK_RESULT: skill= status=refused run=-` and exits 3: stop, run the fix, start again. An ok whose pins row was `SKIP pins (planning-only; …)` does not admit a workflow that runs the repo's tests (`review`, `ship`, `qa`), and a verdict that did not check a component the workflow needs does not admit it either; run `--for ` first. @@ -44,7 +44,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` from `tools.thread.get({ thread_id: "this" })` (one execute program returning only that number) into `gstack-ship-budget --reading ` and obeys `verdict=` (`docs/ship-budget.md`). -/ship reviewers on Capy (`docs/review-policy.md#reviewer-packets`): one execute program runs `gstack-review-packet build` with the plan run's flags, creates one subagent per `PACKET:` line (`tools.subagent.create` takes at most 10 per call) with `machine: "shared"` on the lane's model at reasoning `medium` (red-team and adversarial keep the lane's reasoning; a cross-family outside voice stays as it is), and starts at most the free slots of the running-subagent cap, retrying refused starts as slots free. Each brief is the packet brief with that packet's `read=` ranges, plus one more parallel call in turn 1: `install.sh --check --for review-voice` in bash. A reviewer then finishes in about three calls: the parallel reads, at most one turn of extra ranges, one write. Under a token cap, `gstack-ship-budget check --next` takes build's `BUDGET_NEXT:` value. The same program polls `tools.subagent.get` with `transcript: "none"` every ~20 s until every reviewer settles, then runs `gstack-review-packet collect --dir ` and returns only its output; keep one poll program under ~8 minutes and run it again until collect has run (a lost program loses no reviewer), and never read a reviewer's transcript. +/ship reviewers on Capy (`docs/review-policy.md#reviewer-packets`): one execute program runs `gstack-review-packet build` with the plan run's flags, creates one subagent per `PACKET:` line (`tools.subagent.create` takes at most 10 per call) with `machine: "shared"` on the lane's model at reasoning `medium` (red-team and adversarial keep the lane's reasoning; a cross-family outside voice stays as it is), and starts at most the free slots of the running-subagent cap, retrying refused starts as slots free. Each brief is the packet brief with that packet's `read=` ranges, plus one more parallel call in turn 1: `install.sh --check --for review-voice` in bash. A reviewer then finishes in about three calls: the parallel reads, at most one turn of extra ranges, one write. Under a token cap, `gstack-ship-budget check --next` takes build's `BUDGET_NEXT:` value (`role@bytes` per packet, so each reader is priced from its packet), and the same program measures the batch: it reads the counter right before it creates the readers (`BEFORE`; a batch check that runs in this program takes this same reading) and again right after the last reader settles (`AFTER`), then runs `gstack-ship-budget settle --run --reservation --dispatched-at --reading --readers ` before collect. The parent makes no model call while the program runs, so only that pair measures the readers; a batch settled from a later program (a rerun poll) omits `--dispatched-at` and stays `UNMEASURED`. The same program polls `tools.subagent.get` with `transcript: "none"` every ~20 s until every reviewer settles, then runs `gstack-review-packet collect --dir ` and returns only its output; keep one poll program under ~8 minutes and run it again until collect has run (a lost program loses no reviewer), and never read a reviewer's transcript. ## Plan, approve, ship diff --git a/docs/ship-budget.md b/docs/ship-budget.md index fead84f7f..0679abacb 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -86,24 +86,29 @@ gstack-review-log --start review --reservation ``` Name one role per `DISPATCH:` line (six specialists are `specialist` six times): drift and the -measured means are per reader. Each reader is priced at max(its projection, the run's measured -mean per reader for that role over measured dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and +measured means are per reader. A reader's prior comes from its packet when `--next` carries +`role@`: 3 calls x (the host call floor + bytes/4), rounded up to 10,000; without bytes it is +the calibrated per-dispatch constant. The run's dispatch factor scales that prior, but never above +the larger of the prior and the run's clean per-reader mean, and the price never drops below the +role's own measured mean. When the whole batch does not fit, `check` admits the largest prefix that does and prints `ADMIT: k of n (remaining=)`: dispatch those k, then re-check the remaining roles once they settle. Only a batch whose first reader does not fit stops, and `close` flags remaining roles no later check requested (`DROPPED:`, status=incomplete until the owner accepts). Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, -`fix-check`. /ship passes the `BUDGET_NEXT: dispatch:` value that the batch's +`fix-check`. /ship passes the `BUDGET_NEXT: dispatch:` value that the batch's `gstack-review-packet build` prints, one role per packet: a Step 7/8 audit reserves as `specialist` and a shard as its reviewer's role ([review-policy.md](review-policy.md#reviewer-packets)). `--finish ` is the pass's completion receipt: it settles that reservation and every other dispatch reservation started in the same step, so reviewers admitted one at a time -do not stay reserved. Name a reviewer that came back in your next check -(`check ... --returned [,...]`), or, when other work comes first, `settle --reservation ---reading ` as it returns so its cost is measured alone. A check that moves to the next step -settles every dispatch a reviewer started in the step before. Only a dispatch settled with a -reading at its return is measured: `--finish` and a step change settle without one, so their -dispatches print `UNMEASURED:` and leave the factor alone (their interval also holds the parent's -own work). +do not stay reserved. The program that dispatches a batch measures it: it reads the counter right +before it starts the readers and again right after the last one settles, then runs `settle +--reservation --dispatched-at --reading --readers ` (or names the batch in +a check with `--returned --dispatched-at `). The parent makes no model call inside that +program, so the pair holds only the readers. A check that moves to the next step settles every +dispatch a reviewer started in the step before. Only that pair is measured: `--finish`, a step +change, `--returned` or a settle without `--dispatched-at` (a later program), and a +`--dispatched-at` older than another command's reading, all settle without measuring, print +`UNMEASURED:` and leave the factor alone (their interval also holds the parent's own work). `close` audits after the fact: each review-log row of the run with no reservation gets one BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to accept (`close --approved-by --words ""`); a reservation is never created after the fact. @@ -147,7 +152,8 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check grandchild writing after the parent moves on. 4. `gstack-ship-budget reconcile --run --return --reading `; idempotent per handoff hash, so a duplicate return charges nothing twice. A refused, incomplete or interrupted child - exits 3: regenerate the handoff or stop. Up a chain each token is charged once: the Capy + exits 3 and stops the run (`reason=child_`): every check refuses until a regenerated + child reconciles complete or `resume` runs; regenerate the handoff or stop. Up a chain each token is charged once: the Capy counter already includes the whole subtree, a session `launch` starts joins the Claude Code meter of every ancestor, and under `meter=estimate` a child's returned `used` carries its grandchild's. @@ -189,8 +195,9 @@ Claude-metered /ship completes. Each unit's `call_floor` is calibrated separatel evidence in `call_floor_why`; a floor is the largest 10,000 multiple at or below its smallest observed fresh call. -These constants are the prior. A dispatch is measured from its check's reading to the reading it -was settled with on return (dispatches settled at one reading split it per reader). A step is +These constants are the prior. A dispatch is measured from its dispatch program's +`--dispatched-at` reading to the reading it was settled with in that program (dispatches with one +pair split it per reader, `--readers` or one per role). A step is measured at the next step change, from its check's reading minus the dispatches reserved inside it, so the parent's interleaved calls stay in the step; a step holding an unmeasured dispatch is not measured. Each measurement prints @@ -198,7 +205,7 @@ not measured. Each measurement prints DRIFT: id= kind=step|dispatch next= projected= actual= ratio= and each reservation that cannot be measured prints -`UNMEASURED: id= kind=step|dispatch next= reason=settled_without_reading|dispatch_unmeasured`. +`UNMEASURED: id= kind=step|dispatch next= reason=settled_without_reading|no_dispatch_reading|reading_between|dispatch_unmeasured`. From then on the run projects steps and dispatches with its own factor: the largest actual/prior ratio among its last three measurements of that kind, times 1.5. The step factor diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index a499d27d0..622030522 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -78,6 +78,27 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean, notes: st return reading - prior; } +/** + * The dispatch program's reading from right before its readers started + * (--dispatched-at, with --readers), paired with this command's --reading taken + * after every reader settled. It is late when another command read the meter + * after it: the interval then spans a later program, so it measures nothing. + */ +function dispatchedOpt(o: Opts, lastBefore: number | null): { at: number; readers?: number; late: boolean } | undefined { + const at = tokensOpt(o, 'dispatched-at'); + const raw = str(o, 'readers'); + const readers = raw === undefined ? undefined : Number(raw); + if (readers !== undefined && !(Number.isInteger(readers) && readers > 0)) throw usageError(`--readers '${raw}' is not a positive integer`); + if (at === undefined) { + if (readers !== undefined) throw usageError('--readers needs --dispatched-at '); + return undefined; + } + const reading = tokensOpt(o, 'reading'); + if (reading === undefined) throw usageError('--dispatched-at needs --reading '); + if (at > reading) throw budgetError('BUDGET_READING_REJECTED', `--dispatched-at ${at} is above --reading ${reading}`); + return { at, ...(readers !== undefined ? { readers } : {}), late: lastBefore !== null && lastBefore > at }; +} + /** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */ export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold'; threshold?: number }): string[] { l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next }; @@ -113,7 +134,7 @@ export function writeReturnFile(file: string, lines: string[]): void { * continues with a CONTEXT_HIGH note. Admission still stops anyone. */ export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { - if (next.kind === 'dispatch') return admitBatch(l, step, next.roles, call); + if (next.kind === 'dispatch') return admitBatch(l, step, next.roles, call, next.bytes); const p = projector(l); const nextId = next.step; const { prior, tokens: projected } = p.project(next); @@ -148,16 +169,17 @@ export function decide(l: Ledger, step: string, next: Next, call: number | null, } /** - * A dispatch batch: each reader is priced at max(its projection, the run's - * measured mean for its role) and the batch is reserved as one sum. When it + * A dispatch batch: each reader is priced by projector().reader (from its packet + * bytes when BUDGET_NEXT carries them) and the batch is reserved as one sum. When it * does not fit, the largest prefix that does is admitted and ADMIT names the * rest; the reservation keeps them as `remaining` until a later check of the * same step requests them, and close flags any still left. Only a batch whose * first reader does not fit stops. */ -function admitBatch(l: Ledger, step: string, roles: string[], call: number | null): Outcome { +function admitBatch(l: Ledger, step: string, roles: string[], call: number | null, bytes?: Array): Outcome { const p = projector(l); - const prices = roles.map(p.reader); + const prices = roles.map((role, i) => p.reader(role, bytes?.[i])); + const named = (from: number) => roles.slice(from).map((role, i) => (bytes?.[from + i] == null ? role : `${role}@${bytes[from + i]}`)).join(','); const cost = (k: number) => prices.slice(0, k).reduce((a, b) => a + b, 0); const t = tally(l); let k = roles.length; @@ -165,13 +187,14 @@ function admitBatch(l: Ledger, step: string, roles: string[], call: number | nul if (k === 0) return { stdout: stopLines(l, { call, next: `dispatch:${roles.join(',')}`, step, projected: cost(roles.length), reason: 'budget_exhausted' }), exit: 3 }; const admitted = roles.slice(0, k), remaining = roles.slice(k), nextId = `dispatch:${admitted.join(',')}`; const lines = [budgetLine(l, { call, next: nextId, projected: cost(k), verdict: 'continue' })]; - if (remaining.length) lines.push(`ADMIT: ${k} of ${roles.length} (remaining=${remaining.join(',')}); dispatch these ${k} now and re-check --next dispatch:${remaining.join(',')} after they settle`); + if (remaining.length) lines.push(`ADMIT: ${k} of ${roles.length} (remaining=${remaining.join(',')}); dispatch these ${k} now and re-check --next dispatch:${named(k)} after they settle`); if (l.cap === null) return { stdout: lines, exit: 0 }; for (const role of roles) { const older = l.reservations.find(r => r.step === step && r.remaining?.includes(role)); older?.remaining!.splice(older.remaining!.indexOf(role), 1); } - const r = reserveIn(l, { kind: 'dispatch', step, next: nextId, tokens: cost(k), prior: p.project({ kind: 'dispatch', roles: admitted }).prior, roles: admitted, ...(remaining.length ? { remaining } : {}) }); + const kept = bytes?.slice(0, k); + const r = reserveIn(l, { kind: 'dispatch', step, next: nextId, tokens: cost(k), prior: p.project({ kind: 'dispatch', roles: admitted, ...(kept ? { bytes: kept } : {}) }).prior, roles: admitted, ...(kept ? { bytes: kept } : {}), ...(remaining.length ? { remaining } : {}) }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${cost(k)}`); return { stdout: lines, exit: 0 }; } @@ -250,7 +273,7 @@ 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', 'dispatched-at', 'readers']); const step = parseStep(str(o, 'step')); const next = parseNext(str(o, 'next')); const returned = (str(o, 'returned') ?? '').split(',').filter(Boolean); @@ -260,12 +283,15 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { const back = returned.map(id => l.reservations.find(r => r.id === id && r.kind === 'dispatch')); const bad = returned.filter((_, i) => !back[i]); if (bad.length) throw usageError(`--returned ${bad.join(',')} is not a dispatch reservation of run ${l.run}; fix: pass the RESERVATION ids check printed for the reviewers that came back`); + if (str(o, 'dispatched-at') !== undefined && !back.length) throw usageError('--dispatched-at measures the reservations named by --returned; pass them, or use settle'); const notes: string[] = []; + const lastBefore = l.meter.last; + const dispatched = dispatchedOpt(o, lastBefore); const call = readMeter(l, o, true, notes); const fresh = freshReading(l, o); for (const r of l.reservations) { const done = r.kind === 'step' ? r.state === 'open' : r.kind === 'dispatch' && (back.includes(r) || (next.kind === 'step' && r.state === 'consumed')); - if (done) settleIn(l, r, { reading: fresh && back.includes(r) ? l.meter.last ?? undefined : undefined }); + if (done) settleIn(l, r, back.includes(r) && fresh ? { reading: l.meter.last ?? undefined, dispatched } : {}); } const drift = fresh ? measureSettled(l, next.kind === 'step') : []; if (l.parent && next.kind === 'step' && SHIP_STEPS.indexOf(step as any) >= SHIP_STEPS.indexOf(l.parent.from as any)) l.steps_done = [...new Set([...(l.steps_done ?? []), step])]; @@ -275,16 +301,17 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { } export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome { - const o = parseArgs(argv, ['run', 'record', 'reservation', ...(release ? [] : ['reading'])], release ? [] : ['unknown']); + const o = parseArgs(argv, ['run', 'record', 'reservation', ...(release ? [] : ['reading', 'dispatched-at', 'readers'])], release ? [] : ['unknown']); const id = str(o, 'reservation'); if (!id) throw usageError(`${release ? 'release' : 'settle'} needs --reservation `); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { const r = l.reservations.find(x => x.id === id); if (!r) throw usageError(`reservation ${id} is not in run ${l.run}`); if (release) { if (r.state === 'open') r.state = 'released'; return { stdout: [`RELEASED: id=${id} state=${r.state}`], exit: 0 }; } + const dispatched = dispatchedOpt(o, l.meter.last); readMeter(l, o, false); const fresh = freshReading(l, o); - settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration), reading: fresh ? l.meter.last ?? undefined : undefined }); + settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration), reading: fresh ? l.meter.last ?? undefined : undefined, dispatched }); return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(fresh ? measureSettled(l, false) : [])], exit: 0 }; }); } diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index a5bd9eb85..6c1e0bf67 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -57,7 +57,7 @@ export interface Reservation { id: string; kind: 'step' | 'dispatch' | 'child'; step: string; next: string; roles?: string[]; tokens: number; at: string; state: 'open' | 'consumed' | 'settled' | 'released'; token?: string; started_at?: string; settled_at?: string; prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; remaining?: string[]; - settled_reading?: number; unmeasured?: boolean; + settled_reading?: number; unmeasured?: boolean; dispatched_at?: number; readers?: number; bytes?: Array; late?: boolean; } export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number } export interface Handoff { @@ -97,13 +97,19 @@ export function parseStep(raw: string | undefined, flag = '--step'): string { throw usageError(`${flag} '${raw ?? ''}' is not a ship step id; valid: ${SHIP_STEPS.join(', ')}; fix: use a step number from ship/SKILL.md.tmpl`); } -export type Next = { kind: 'step'; step: string } | { kind: 'dispatch'; roles: string[] }; +/** + * A dispatch names one role per reader; `role@` (gstack-review-packet's + * BUDGET_NEXT) carries that reader's packet size, which prices it from the packet. + */ +export type Next = { kind: 'step'; step: string } | { kind: 'dispatch'; roles: string[]; bytes?: Array }; export function parseNext(raw: string | undefined): Next { if (raw?.startsWith('dispatch:')) { - const roles = raw.slice('dispatch:'.length).split(',').filter(Boolean); + const items = raw.slice('dispatch:'.length).split(',').filter(Boolean).map(item => /^([a-z-]+)(?:@(\d+))?$/.exec(item)); + const roles = items.map(m => m?.[1] ?? '?'); const bad = roles.filter(r => !(DISPATCH_ROLES as readonly string[]).includes(r)); - if (roles.length === 0 || bad.length) throw usageError(`--next '${raw}' names unknown roles (${bad.join(', ') || 'none given'}); valid: ${DISPATCH_ROLES.join(', ')}; fix: list the plan's roles, e.g. dispatch:specialist,red-team`); - return { kind: 'dispatch', roles }; + if (roles.length === 0 || bad.length) throw usageError(`--next '${raw}' names unknown roles (${bad.join(', ') || 'none given'}); valid: ${DISPATCH_ROLES.join(', ')}, each optionally @; fix: pass build's BUDGET_NEXT value, e.g. dispatch:specialist@112110,red-team@57153`); + const bytes = items.map(m => (m?.[2] === undefined ? null : Number(m[2]))); + return { kind: 'dispatch', roles, ...(bytes.some(b => b !== null) ? { bytes } : {}) }; } return { kind: 'step', step: parseStep(raw, '--next') }; } @@ -139,8 +145,16 @@ export function stepProjection(cal: Calibration, unit: string, step: string): nu return (cal.step_calls[step] ?? cal.step_calls.default ?? 0) * k.per_call + (cal.step_dispatches[step] ?? 0) * k.per_dispatch; } -export function projection(cal: Calibration, unit: string, next: Next): number { - return next.kind === 'dispatch' ? next.roles.length * unitConstants(cal, unit).per_dispatch : stepProjection(cal, unit, next.step); +/** Calls a packet reader makes: the parallel range reads, at most one turn of extra ranges, one write. */ +export const READER_CALLS = 3; + +/** One reader's prior: from its packet (READER_CALLS x (call floor + packet tokens)) when the bytes are known, else the calibrated per-dispatch constant. */ +export function readerPrior(cal: Calibration, unit: string, floor: number, bytes: number | null | undefined): number { + return bytes == null ? unitConstants(cal, unit).per_dispatch : Math.ceil((READER_CALLS * (floor + Math.ceil(bytes / 4))) / cal.round_to) * cal.round_to; +} + +export function projection(cal: Calibration, unit: string, next: Next, floor = cal.units[unit]?.call_floor ?? 0): number { + return next.kind === 'dispatch' ? next.roles.reduce((n, _, i) => n + readerPrior(cal, unit, floor, next.bytes?.[i]), 0) : stepProjection(cal, unit, next.step); } /** How many recent measurements of a kind the run's factor looks at (GST-101). */ @@ -162,40 +176,48 @@ export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind'] * with the step factor unless --reserve set it; the stop overhead keeps its * calibrated fit so a run always has room to stop. `floor` is the host's * per-call floor: start --call-floor, else the unit's calibrated value. - * `reader` prices one dispatch reader at max(its projection, the run's mean - * measured cost for that role over settled dispatch reservations). + * `reader` prices one dispatch reader: its prior (packet-based when the bytes are + * known) times the dispatch factor, but never above the larger of that prior and + * the run's clean per-reader mean, and never below the role's own measured mean. */ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) { const unit = l.meter.contract.unit; + const floor = l.call_floor ?? cal.units[unit]?.call_floor ?? 0; const step = measuredFactor(l, cal, 'step'), dispatch = measuredFactor(l, cal, 'dispatch'); const scale = (n: number, f: number | null) => (f === null ? n : roundUp(n * f, cal.round_to)); + const measured = new Map(); + for (const r of l.reservations) { + if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue; + for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / (r.readers ?? r.roles!.length)]); + } + const mean = (xs: number[]) => (xs.length ? Math.ceil(xs.reduce((a, b) => a + b, 0) / xs.length) : 0); + const clean = mean([...measured.values()].flat()); + const reader = (role: string, bytes?: number | null) => { + const prior = readerPrior(cal, unit, floor, bytes); + const scaled = scale(prior, dispatch); + return Math.max(clean ? Math.min(scaled, Math.max(prior, clean)) : scaled, mean(measured.get(role) ?? [])); + }; const project = (next: Next) => { + if (next.kind === 'dispatch') return { prior: projection(cal, unit, next, floor), tokens: next.roles.reduce((n, role, i) => n + reader(role, next.bytes?.[i]), 0) }; const prior = projection(cal, unit, next); - return { prior, tokens: scale(prior, next.kind === 'dispatch' ? dispatch : step) }; + return { prior, tokens: scale(prior, step) }; }; const segment = (from: string, to: string) => SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1) .reduce((sum, s) => sum + project({ kind: 'step', step: s }).tokens + (cal.segment_dispatches[s] ?? 0) * project({ kind: 'dispatch', roles: ['specialist'] }).tokens, 0); const label = (f: number | null) => (f === null ? 'prior' : f.toFixed(3)); - const measured = new Map(); - for (const r of l.reservations) { - if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue; - for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / r.roles!.length]); - } - const reader = (role: string) => { - const seen = measured.get(role) ?? []; - return Math.max(project({ kind: 'dispatch', roles: [role] }).tokens, seen.length ? Math.ceil(seen.reduce((a, b) => a + b, 0) / seen.length) : 0); - }; - return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}`, - floor: l.call_floor ?? cal.units[unit]?.call_floor ?? 0 }; + return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}`, floor }; } /** * Measure settled reservations against the current (fresh) reading (GSTA-167). - * A dispatch is measured only to the reading it was settled with when it - * returned (settle --reading, check --returned): dispatches settled at the same - * reading form one group from the earliest of their readings, split per reader - * (one role per reader). One settled without a reading (review-log --finish, a - * step change) is UNMEASURED: its interval also holds the parent's own work. + * A dispatch is measured only from the reading taken right before its readers + * started (--dispatched-at) to the one it was settled with after they all + * returned, both read inside the dispatch program, where the parent makes no + * model call; dispatches with the same pair split it per reader (--readers, + * else one per role). One settled without a reading (review-log --finish, a + * step change), without --dispatched-at (a later program), or after another + * command read the meter in between is UNMEASURED: its interval also holds the + * parent's own work, so it teaches nothing. * A step is measured on a step change, from its reading to now minus the * dispatches reserved after it, so the parent's interleaved calls stay in the * step; a window holding an unmeasured dispatch leaves the step unmeasured. A @@ -218,12 +240,18 @@ export function measureSettled(l: Ledger, stepChange = true): string[] { lines.push(`UNMEASURED: id=${r.id} kind=${r.kind} next=${r.next} reason=${reason}`); }; const dispatches = pending.filter(r => r.kind === 'dispatch'); - for (const r of dispatches) if (r.settled_reading === undefined) unmeasured(r, 'settled_without_reading'); - for (const end of new Set(dispatches.filter(r => !r.unmeasured).map(r => r.settled_reading!))) { - const group = dispatches.filter(r => r.settled_reading === end); - const from = Math.min(...group.map(r => r.reading_at!)); - const readers = group.reduce((n, r) => n + (r.roles?.length ?? 1), 0); - for (const r of group) measure(r, Math.round(((end - from) * (r.roles?.length ?? 1)) / readers)); + for (const r of dispatches) { + if (r.settled_reading === undefined) unmeasured(r, 'settled_without_reading'); + else if (r.late) unmeasured(r, 'reading_between'); + else if (r.dispatched_at === undefined) unmeasured(r, 'no_dispatch_reading'); + } + const count = (r: Reservation) => r.readers ?? r.roles?.length ?? 1; + const measurable = dispatches.filter(r => !r.unmeasured); + for (const key of new Set(measurable.map(r => `${r.dispatched_at}:${r.settled_reading}`))) { + const group = measurable.filter(r => `${r.dispatched_at}:${r.settled_reading}` === key); + const span = group[0]!.settled_reading! - group[0]!.dispatched_at!; + const readers = group.reduce((n, r) => n + count(r), 0); + for (const r of group) measure(r, Math.round((span * count(r)) / readers)); } if (!stepChange) return lines; for (const r of pending.filter(x => x.kind === 'step')) { @@ -319,12 +347,22 @@ export function reserveIn(l: Ledger, r: Omit return row; } -/** Settle at completion: the meter already holds the spend; estimate and unseen charges are booked here. */ -export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration; reading?: number } = {}): void { +/** + * Settle at completion: the meter already holds the spend; estimate and unseen + * charges are booked here. `dispatched` is the dispatch program's reading from + * right before the readers started, with the reader count; `late` marks one + * another command's reading came after. + */ +export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration; reading?: number; dispatched?: { at: number; readers?: number; late: boolean } } = {}): void { if (r.state === 'settled' || r.state === 'released') return; r.state = 'settled'; r.settled_at = new Date().toISOString(); if (opts.reading !== undefined) r.settled_reading = opts.reading; + if (opts.reading !== undefined && opts.dispatched) { + if (opts.dispatched.late) r.late = true; + else r.dispatched_at = opts.dispatched.at; + if (opts.dispatched.readers !== undefined) r.readers = opts.dispatched.readers; + } if (opts.unknown) { l.unknown += r.tokens; r.unseen = true; return; } if (l.meter.contract.meter === 'estimate') { l.estimate_used += r.tokens; return; } const unseen = l.meter.contract.meter === 'claude' ? (r.roles ?? []).filter(x => x === 'outside-voice').length : 0; diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index 6fa465a64..4b07da8ae 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -229,10 +229,12 @@ export function cmdReconcile(argv: string[], ctx: Ctx): Outcome { h.status = ret && ret.to !== h.to && ret.status === 'complete' ? 'incomplete' : ret?.status ?? 'interrupted'; h.child_used = childUsed; h.reconciled_at = new Date().toISOString(); + if (h.status !== 'complete') l.stopped = { at: h.reconciled_at, reason: `child_${h.status}`, step: h.from, next: h.from }; + else if (l.stopped?.reason.startsWith('child_')) delete l.stopped; pointMarker(l.marker, l); const after = SHIP_STEPS[SHIP_STEPS.indexOf(h.to as any) + 1] ?? '-'; const lines = [`RECONCILED: handoff=${h.sha} child=${h.status} child_used=${childUsed}`, budgetLine(l, { call, next: h.status === 'complete' ? after : h.from, projected: 0, verdict: h.status === 'complete' ? 'continue' : 'stop' })]; - if (h.status !== 'complete') lines.push(`NEXT: the child returned ${h.status}; regenerate the handoff (gstack-ship-budget handoff --run ${l.run} --from ${h.from} --to ${h.to} --base origin/) or stop`); + if (h.status !== 'complete') lines.push(`NEXT: the child returned ${h.status}; regenerate the handoff (gstack-ship-budget handoff --run ${l.run} --from ${h.from} --to ${h.to} --base origin/) or stop. Every check refuses until a regenerated child reconciles complete or the owner resumes (gstack-ship-budget resume --record ${l.record})`); return { stdout: lines, exit: exitFor(h.status) }; }); } diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md index d0928ceb8..b9f228785 100644 --- a/ship/sections/budget-handoff.md +++ b/ship/sections/budget-handoff.md @@ -29,9 +29,11 @@ role per packet, never packet names): On `ADMIT: k of n (remaining=)`, dispatch only those k and re-check the remaining roles after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. -Settle each reader when it returns: the next check's `--returned `, or -`gstack-ship-budget settle --run --reservation --reading ` (`--unknown` when the meter -cannot see it); `release` one that never ran. Only these are measured: `--finish` or a step change settles without a reading. +Settle each batch in the program that dispatched it: read the counter right before the readers +start and right after the last one settles, then `gstack-ship-budget settle --run --reservation +--dispatched-at --reading --readers ` (`--unknown` when the meter cannot see it); +`release` one that never ran. Only that pair is measured: `check --returned`, a settle from a later +program (no `--dispatched-at`), `--finish` or a step change settles without measuring. **verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and end status=incomplete. Raising the cap is the owner's call: `/ship --resume @@ -55,7 +57,8 @@ fresh child; you wait and advance only after reconciling it: `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): - regenerate the handoff or stop. + regenerate the handoff or stop; every check refuses until a regenerated child reconciles + complete or the owner resumes. **--resume ** and **--continue ** (you are the child) attach to the existing record and ledger instead of a fresh Step 1: keep the attempt counts and start tokens; under diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index 7c40d8d7e..a46dd8e6e 100644 --- a/ship/sections/budget-handoff.md.tmpl +++ b/ship/sections/budget-handoff.md.tmpl @@ -27,9 +27,11 @@ role per packet, never packet names): On `ADMIT: k of n (remaining=)`, dispatch only those k and re-check the remaining roles after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. -Settle each reader when it returns: the next check's `--returned `, or -`gstack-ship-budget settle --run --reservation --reading ` (`--unknown` when the meter -cannot see it); `release` one that never ran. Only these are measured: `--finish` or a step change settles without a reading. +Settle each batch in the program that dispatched it: read the counter right before the readers +start and right after the last one settles, then `gstack-ship-budget settle --run --reservation +--dispatched-at --reading --readers ` (`--unknown` when the meter cannot see it); +`release` one that never ran. Only that pair is measured: `check --returned`, a settle from a later +program (no `--dispatched-at`), `--finish` or a step change settles without measuring. **verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and end status=incomplete. Raising the cap is the owner's call: `/ship --resume @@ -53,7 +55,8 @@ fresh child; you wait and advance only after reconciling it: `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): - regenerate the handoff or stop. + regenerate the handoff or stop; every check refuses until a regenerated child reconciles + complete or the owner resumes. **--resume ** and **--continue ** (you are the child) attach to the existing record and ledger instead of a fresh Step 1: keep the attempt counts and start tokens; under diff --git a/test/ship-budget-batch.test.ts b/test/ship-budget-batch.test.ts index b65822eab..3dd3d7cd8 100644 --- a/test/ship-budget-batch.test.ts +++ b/test/ship-budget-batch.test.ts @@ -47,9 +47,9 @@ describe('parallel dispatch admission near the cap', () => { const h = fresh(); const s = start(h, 200_000_000); let id = h.field(batch(h, s.run, ['red-team'], 0).out, 'RESERVATION', 'id'); - let reading = 3_000_000; - for (let i = 0; i < 4; i++, reading += 500_000) id = h.field(batch(h, s.run, ['specialist'], reading, ['--returned', id]).out, 'RESERVATION', 'id'); - const c = batch(h, s.run, ['red-team', 'specialist'], reading, ['--returned', id]); + let reading = 3_000_000, at = 0; + for (let i = 0; i < 4; i++, reading += 500_000) { id = h.field(batch(h, s.run, ['specialist'], reading, ['--returned', id, '--dispatched-at', String(at)]).out, 'RESERVATION', 'id'); at = reading; } + const c = batch(h, s.run, ['red-team', 'specialist'], reading, ['--returned', id, '--dispatched-at', String(at)]); expect(c.code, c.out + c.err).toBe(0); const f = (500_000 / K.per_dispatch) * cal.headroom; const specialist = Math.ceil((K.per_dispatch * f) / cal.round_to) * cal.round_to; @@ -72,7 +72,7 @@ describe('parallel dispatch admission near the cap', () => { const h = fresh(); const s = start(h, CAP_35); const first = h.field(batch(h, s.run, READERS, AT_35).out, 'RESERVATION', 'id'); - expect(h.sb(['settle', '--run', s.run, '--reservation', first, '--reading', String(AT_35 + 3 * 760_000)]).code).toBe(0); + expect(h.sb(['settle', '--run', s.run, '--reservation', first, '--dispatched-at', String(AT_35), '--reading', String(AT_35 + 3 * 760_000)]).code).toBe(0); const rest = batch(h, s.run, READERS.slice(3), AT_35 + 3 * 760_000); expect(rest.code, rest.out + rest.err).toBe(0); expect(rest.out).not.toContain('ADMIT:'); diff --git a/test/ship-budget-chain.test.ts b/test/ship-budget-chain.test.ts index c6a4286a1..5dedff682 100644 --- a/test/ship-budget-chain.test.ts +++ b/test/ship-budget-chain.test.ts @@ -238,6 +238,41 @@ describe('a chained return is charged once', () => { }); }); +describe('a reconcile that stops the parent stops every check (replay 2, #36)', () => { + // Replay 2: after reconcile printed verdict=stop for an incomplete child, check --next 9 still admitted Step 9. + test('after an incomplete child, check refuses every step until resume; a regenerated child that returns complete also clears it', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '0', '--reserve', '0']); + const child = handOff(h, s.run, '7', '11.5', 1_000_000); + const ret = h.sb(['return', '--run', child.run, '--to', '9', '--status', 'incomplete', '--reading', '5000000']); + expect(ret.code, ret.out + ret.err).toBe(0); + const rec = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '5100000']); + expect(rec.code).toBe(3); + expect(h.field(rec.out, 'BUDGET', 'verdict')).toBe('stop'); + expect(rec.out).toContain('Every check refuses until a regenerated child reconciles complete or the owner resumes'); + for (const next of ['9', '7', 'dispatch:red-team']) { + const c = check(h, s.run, '8', next, ['--reading', '5200000']); + expect(c.code, next).toBe(3); + expect(c.out).toMatch(/^STOP: reason=child_incomplete at Step 7; resume first: /m); + expect(c.out).not.toContain('RESERVATION:'); + } + const resumed = h.sb(['resume', '--run', s.run, '--reading', '5300000']); + expect(resumed.code, resumed.out + resumed.err).toBe(0); + expect(resumed.out).toContain('RESUMED: from=Step 7 (child_incomplete)'); + expect(check(h, s.run, '8', '9', ['--reading', '5400000']).code).toBe(0); + + const t = start(h, ['--token-cap', '200M', '--reading', '0', '--reserve', '0']); + const first = handOff(h, t.run, '7', '11.5', 1_000_000); + h.sb(['return', '--run', first.run, '--to', '9', '--status', 'incomplete', '--reading', '2000000']); + expect(h.sb(['reconcile', '--run', t.run, '--return', first.returnFile, '--reading', '2000000']).code).toBe(3); + const second = handOff(h, t.run, '7', '11.5', 2_100_000); + expect(h.sb(['return', '--run', second.run, '--to', '11.5', '--reading', '3000000']).code).toBe(0); + const done = h.sb(['reconcile', '--run', t.run, '--return', second.returnFile, '--reading', '3100000']); + expect(done.code, done.out).toBe(0); + expect(check(h, t.run, '11.5', '12', ['--reading', '3200000']).code).toBe(0); + }); +}); + describe('a child returns complete only when its own work is finished', () => { test('a child that returns before reconciling its grandchild returns incomplete and names the handoff', () => { const h = fresh(); diff --git a/test/ship-budget-projection.test.ts b/test/ship-budget-projection.test.ts index 8411a618b..b223b0e64 100644 --- a/test/ship-budget-projection.test.ts +++ b/test/ship-budget-projection.test.ts @@ -72,10 +72,11 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => { reading += 750_000; expect(h.sb(['check', '--run', s.run, '--step', steps[i]!, '--next', steps[i + 1]!, '--reading', String(reading)]).code).toBe(0); } - let previous = ''; + let previous = '', at = 0; const dispatch = (role: string) => { reading += PARENT_CALL; - const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading), ...(previous ? ['--returned', previous] : [])]); + const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading), ...(previous ? ['--returned', previous, '--dispatched-at', String(at)] : [])]); + at = reading; return c; }; for (const [role, cost] of [...PASS2, ...PASS3]) { @@ -87,7 +88,7 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => { } const lean = h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch' && r.actual !== undefined); expect(lean.length).toBe(PASS2.length + PASS3.length - 1); - const settled = h.sb(['settle', '--run', s.run, '--reservation', previous, '--reading', String(reading)]); + const settled = h.sb(['settle', '--run', s.run, '--reservation', previous, '--dispatched-at', String(at), '--reading', String(reading)]); expect(settled.out).toMatch(/^DRIFT: id=\S+ kind=dispatch next=dispatch:fix-check projected=\d+ actual=368801 ratio=\d+\.\d\d$/m); reading = 17_790_000; previous = ''; @@ -104,8 +105,9 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => { for (let round = 0; round < 40 && !stop; round++) { parent += 50_000; reading += parent; - const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:fix-check', '--reading', String(reading), '--returned', previous]); + const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:fix-check', '--reading', String(reading), '--returned', previous, '--dispatched-at', String(at)]); if (c.code === 3) { stop = c.out; break; } + at = reading; previous = h.field(c.out, 'RESERVATION', 'id'); expect(h.reviewLog(['--start', 'review-fix-check', '--reservation', previous]).code).toBe(0); reading += 368_801; @@ -149,7 +151,7 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', () const a = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']); const first = h.field(a.out, 'RESERVATION', 'id'); expect(h.reviewLog(['--start', 'review', '--reservation', first]).code).toBe(0); - const b = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', first]); + const b = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', first, '--dispatched-at', '0']); expect(b.code, b.out + b.err).toBe(0); expect(h.field(b.out, 'BUDGET', 'reserved')).toBe('0'); expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === first)).toMatchObject({ state: 'settled', actual: 400_000 }); diff --git a/test/ship-budget-reader-drift.test.ts b/test/ship-budget-reader-drift.test.ts index 23c1e0dcd..da9b28df2 100644 --- a/test/ship-budget-reader-drift.test.ts +++ b/test/ship-budget-reader-drift.test.ts @@ -1,7 +1,8 @@ /** - * GSTA-167 PR 3a: dispatch drift is measured per reader and only to the reading - * a dispatch was settled with when it returned, never across the parent's own - * work; the parent's interleaved calls stay in the step's sample. The per-call + * GSTA-167 PR 3a: dispatch drift is measured per reader and only from the + * dispatch program's reading right before its readers started (--dispatched-at) + * to the one it was settled with after they returned, never across the parent's + * own work; the parent's interleaved calls stay in the step's sample. The per-call * charge takes a prior reading from the previous call only. Replays * gstack-evals #36 (a six-reader batch taught a 2.041 factor and priced the Red * Team at 15.17M, a false STOP with 14.4M of sub-cap unused) and #34 (the Red @@ -51,14 +52,16 @@ describe('per-reader dispatch drift (#36)', () => { expect(first.out).toMatch(/^ADMIT: 2 of 6 /m); const r1 = h.field(first.out, 'RESERVATION', 'id'); expect(h.reviewLog(['--start', 'review', '--reservation', r1]).code).toBe(0); + let at = reading; reading += 2 * READER; - const rest = check(h, s.run, '9', 'dispatch:' + SIX.slice(2).join(','), reading, ['--returned', r1]); + const rest = check(h, s.run, '9', 'dispatch:' + SIX.slice(2).join(','), reading, ['--returned', r1, '--dispatched-at', String(at)]); expect(rest.out).toMatch(new RegExp(`^DRIFT: id=${r1} kind=dispatch next=dispatch:specialist,specialist projected=${2 * K.per_dispatch} actual=${2 * READER} ratio=0\\.23$`, 'm')); expect(rest.out).not.toContain('ADMIT:'); expect(Number(h.field(rest.out, 'BUDGET', 'projected'))).toBe(4 * priced(2 * READER, 2)); const r2 = h.field(rest.out, 'RESERVATION', 'id'); + at = reading; reading += 4 * READER; - const red = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', r2]); + const red = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', r2, '--dispatched-at', String(at)]); expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue'); expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(priced(2 * READER, 2)); expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBeLessThan(3_000_000); @@ -69,13 +72,13 @@ describe('per-reader dispatch drift (#36)', () => { const h = fresh(); const s = start(h, 200_000_000); const a = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 0).out, 'RESERVATION', 'id'); - const b = check(h, s.run, '9', 'dispatch:red-team', 6 * READER, ['--returned', a]); + const b = check(h, s.run, '9', 'dispatch:red-team', 6 * READER, ['--returned', a, '--dispatched-at', '0']); const r = h.ledger(s.ledger).reservations.find((x: any) => x.id === a); expect(r.actual).toBe(6 * READER); expect(Number(h.field(b.out, 'BUDGET', 'projected'))).toBe(priced(6 * READER, 6)); const red = h.field(b.out, 'RESERVATION', 'id'); const heavy = 3_910_000; - const c = check(h, s.run, '9', 'dispatch:red-team,specialist', 6 * READER + heavy, ['--returned', red]); + const c = check(h, s.run, '9', 'dispatch:red-team,specialist', 6 * READER + heavy, ['--returned', red, '--dispatched-at', String(6 * READER)]); const factor = Math.max(READER / K.per_dispatch, heavy / K.per_dispatch) * cal.headroom; expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(Math.max(roundUp(K.per_dispatch * factor), heavy) + Math.max(roundUp(K.per_dispatch * factor), READER)); }); @@ -89,7 +92,7 @@ describe('a dispatch is measured to its return, never across the parent\'s own w const specialists = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 4_000_000).out, 'RESERVATION', 'id'); expect(h.reviewLog(['--start', 'review', '--reservation', specialists]).code).toBe(0); let reading = 4_000_000 + 6 * READER; - const redCheck = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', specialists]); + const redCheck = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', specialists, '--dispatched-at', '4000000']); const red = h.field(redCheck.out, 'RESERVATION', 'id'); expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0); reading += READER + 3_000_000; @@ -109,7 +112,7 @@ describe('a dispatch is measured to its return, never across the parent\'s own w check(h, s.run, '8', '9', 3_500_000); const red = h.field(check(h, s.run, '9', 'dispatch:red-team', 4_000_000).out, 'RESERVATION', 'id'); expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0); - const settled = ok(h, ['settle', '--run', s.run, '--reservation', red, '--reading', String(4_000_000 + READER)]); + const settled = ok(h, ['settle', '--run', s.run, '--reservation', red, '--dispatched-at', '4000000', '--reading', String(4_000_000 + READER)]); expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${red} kind=dispatch next=dispatch:red-team projected=${K.per_dispatch} actual=${READER} ratio=0\\.23$`, 'm')); const step = check(h, s.run, '9', '10', 4_000_000 + READER + 3_000_000); expect(step.out).toMatch(/^DRIFT: id=\S+ kind=step next=9 projected=\d+ actual=3500000 ratio=\d+\.\d\d$/m); @@ -130,6 +133,74 @@ describe('a dispatch is measured to its return, never across the parent\'s own w }); }); +/** + * GSTA-167 PR 3, replay 2 on gstack-evals #36 (handoffs/GSTA-167/replay2-36.md): the + * batched reader's reservation r5 was settled only at the next --returned check + * (17.72M -> 27.01M), so its interval held the child's own core review, QA and + * packet build, and the Red Team was priced at 7.43M x 1.875 = 13.94M: a false + * STOP with 14.8M of sub-cap unused. Only the dispatch program's own pair (read + * right before the readers start, again after they all settle) measures now, and + * a packet's bytes price its reader. + */ +describe("only the dispatch program's reading pair measures readers; packets price them (replay 2, #36)", () => { + const R5 = { cap: 34_044_378, spent: 19_258_962, reserved: 17_720_000, settled: 27_010_000, bytes: 112_110, red: 57_153 }; + const childStart = (h: Harness) => start(h, R5.cap, R5.settled - R5.spent); + const floor = cal.units['capy-tokens']!.call_floor!; + const packetPrior = (bytes: number) => roundUp(3 * (floor + Math.ceil(bytes / 4))); + + test('r5 replay: settled at the next --returned check with no dispatch reading, r5 is UNMEASURED and the Red Team is priced from its packet, not 13.94M', () => { + const h = fresh(); + const s = childStart(h); + const r5 = h.field(check(h, s.run, '9', `dispatch:specialist@${R5.bytes}`, R5.reserved).out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', r5]).code).toBe(0); + const red = check(h, s.run, '9', `dispatch:red-team@${R5.red}`, R5.settled, ['--returned', r5]); + expect(red.out).toMatch(new RegExp(`^UNMEASURED: id=${r5} kind=dispatch next=dispatch:specialist reason=no_dispatch_reading$`, 'm')); + expect(red.out).not.toMatch(/^DRIFT: id=\S+ kind=dispatch /m); + expect(h.field(red.out, 'BUDGET', 'used')).toBe(String(R5.spent)); + expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue'); + expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(packetPrior(R5.red)); + expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBeLessThanOrEqual(2_000_000); + expect(red.out).not.toContain('STOP:'); + }); + + test("the dispatch program's pair measures only the readers, and the learned factor never prices a reader above the clean per-reader mean", () => { + const h = fresh(); + const s = childStart(h); + const r5 = h.field(check(h, s.run, '9', `dispatch:specialist@${R5.bytes}`, R5.reserved).out, 'RESERVATION', 'id'); + const before = R5.settled - 1_510_000; + const settled = ok(h, ['settle', '--run', s.run, '--reservation', r5, '--dispatched-at', String(before), '--reading', String(R5.settled), '--readers', '1']); + expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${r5} kind=dispatch next=dispatch:specialist projected=${packetPrior(R5.bytes)} actual=1510000 `, 'm')); + const red = check(h, s.run, '9', `dispatch:red-team@${R5.red}`, R5.settled); + const factor = (1_510_000 / packetPrior(R5.bytes)) * cal.headroom; + expect(roundUp(packetPrior(R5.red) * factor)).toBeGreaterThan(1_510_000); + expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(1_510_000); + expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue'); + }); + + test('a dispatch reading older than another command\'s reading is UNMEASURED (a later program), and one above the settle reading is refused', () => { + const h = fresh(); + const s = childStart(h); + const r5 = h.field(check(h, s.run, '9', `dispatch:specialist@${R5.bytes}`, R5.reserved).out, 'RESERVATION', 'id'); + const late = ok(h, ['settle', '--run', s.run, '--reservation', r5, '--dispatched-at', String(R5.reserved - 1), '--reading', String(R5.settled)]); + expect(late.out).toMatch(new RegExp(`^UNMEASURED: id=${r5} kind=dispatch next=dispatch:specialist reason=reading_between$`, 'm')); + const r6 = h.field(check(h, s.run, '9', `dispatch:red-team@${R5.red}`, R5.settled).out, 'RESERVATION', 'id'); + const bad = h.sb(['settle', '--run', s.run, '--reservation', r6, '--dispatched-at', String(R5.settled + 2), '--reading', String(R5.settled + 1)]); + expect(bad.code).toBe(2); + expect(bad.err).toContain('BUDGET_READING_REJECTED'); + expect(h.sb(['settle', '--run', s.run, '--reservation', r6, '--readers', '2', '--reading', String(R5.settled + 1)]).code).toBe(2); + }); + + test('--readers splits the pair\'s interval per reader for the role mean', () => { + const h = fresh(); + const s = start(h, 200_000_000); + const id = h.field(check(h, s.run, '9', 'dispatch:specialist@40000,specialist@40000', 1_000_000).out, 'RESERVATION', 'id'); + ok(h, ['settle', '--run', s.run, '--reservation', id, '--dispatched-at', '1000000', '--reading', '4000000', '--readers', '2']); + expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === id)).toMatchObject({ actual: 3_000_000, readers: 2, dispatched_at: 1_000_000 }); + const next = check(h, s.run, '9', 'dispatch:specialist@40000', 4_000_000); + expect(Number(h.field(next.out, 'BUDGET', 'projected'))).toBe(1_500_000); + }); +}); + describe('the per-call charge takes a prior reading from the previous call only (#34)', () => { test('a prior reading older than the run\'s last reading is ignored: call=unknown, never a six-call span', () => { const h = fresh(); @@ -165,9 +236,9 @@ describe('the budget section states both contracts', () => { expect(tmpl).toMatch(/--prior-reading[\s\S]{0,200}previous call/); expect(tmpl).toMatch(/older than the run's last reading is ignored/); }); - test('a batch names one role per reader and each reader is settled with a reading when it returns', () => { + test('a batch names one role per reader and is settled with the dispatch program\'s own reading pair', () => { expect(tmpl).toMatch(/one role per (?:reader|`DISPATCH:` line)/); - expect(tmpl).toMatch(/Settle each reader when it returns[\s\S]{0,200}--reservation --reading /); - expect(tmpl).toMatch(/Only these are measured: `--finish` or a step change settles without a reading/); + expect(tmpl).toMatch(/Settle each batch in the program that dispatched it[\s\S]{0,300}--dispatched-at --reading --readers /); + expect(tmpl).toMatch(/Only that pair is measured: `check --returned`, a settle from a later\s+program \(no `--dispatched-at`\), `--finish` or a step change settles without measuring/); }); }); diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index c26066937..8f0876f86 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -93,11 +93,12 @@ describe('reservations', () => { const id = h.field(c.out, 'RESERVATION', 'id'); expect(Number(h.field(c.out, 'RESERVATION', 'tokens'))).toBe(K.per_dispatch); const spent = 2 * K.per_dispatch; - h.sb(['settle', '--run', s.run, '--reservation', id, '--reading', String(spent)]); + h.sb(['settle', '--run', s.run, '--reservation', id, '--dispatched-at', '0', '--reading', String(spent)]); const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(spent)]); expect(Number(h.field(next.out, 'BUDGET', 'used'))).toBe(spent); const projected = Number(h.field(next.out, 'BUDGET', 'projected')); - expect(projected).toBe(Math.ceil((K.per_dispatch * 2 * cal.headroom) / cal.round_to) * cal.round_to); + // The factor (2 x headroom) is bounded by the clean per-reader mean it was learned from (GSTA-167). + expect(projected).toBe(Math.min(Math.ceil((K.per_dispatch * 2 * cal.headroom) / cal.round_to) * cal.round_to, spent)); expect(next.code).toBe(spent + projected + K.stop_overhead <= cap ? 0 : 3); const later = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(cap - K.stop_overhead - K.per_dispatch + 1)]); expect(later.code).toBe(3);