mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(lease): name the double-crash limit of breaking a dead lock Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit89bf556c11) * test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit7f6c70459a) * test: the DRIVER template names the reviewer trailer relay (pregate lane) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit2545d9a6d2) * test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commitb509096218) * feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commit9335e45de2) * test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> (cherry picked from commita7a974d268) * test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog entry for field list 2 wave A Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.115.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
618 lines
44 KiB
TypeScript
618 lines
44 KiB
TypeScript
/**
|
||
* eval-plan — the tier-1 machinery behind /eval-plan (plan E1): the
|
||
* preregistration check, eval arms from the model policy (with the repo
|
||
* policy-document fallback) priced from lib/pricing.ts, the `$0` stub-model
|
||
* dry run over the real input file with its checklist, and the priced pilot
|
||
* (n≈3) that reserves through lib/spend-ledger.ts, settles actual cost, and
|
||
* extrapolates to the full run with an error band before the approval ask.
|
||
* Admission is an estimate: the price table excludes billing categories
|
||
* (lib/pricing.ts), so the spend table always shows `unknown` charges and the
|
||
* worst-case overrun (one in-flight call per worker) beside `spent`.
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { modelsMain } from './model-policy-cli';
|
||
import { isConfigUnavailable, loadConfigFromBase } from './pregate/config';
|
||
import { EXCLUDED_BILLING_CATEGORIES, estimateCostUsd, hasPricing, PRICING, pricingRow } from './pricing';
|
||
import type { ResultCodeName } from './result-codes';
|
||
import type { ModelRulesCheck } from './eval-models';
|
||
import { readLedger, release, reserve, settle, SpendCapExceeded, summarize, type SpendLedger, type SpendSummary } from './spend-ledger';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// preregistration
|
||
// ---------------------------------------------------------------------------
|
||
export const PREREG_SECTIONS = ['Bars', 'Arms', 'Decoys', 'Stop rules', 'Held-out exposure'] as const;
|
||
export interface PreregCheck { ok: boolean; present: string[]; missing: string[]; empty: string[]; diagnostics: PreregDiagnostics | null }
|
||
|
||
export function preregTemplate(title = 'Eval preregistration'): string {
|
||
return `# ${title}
|
||
|
||
## Bars
|
||
<!-- One line per metric: name, the pass bar, the direction, and what a miss means for the decision. -->
|
||
|
||
## Arms
|
||
<!-- One line per arm: model id (must be in lib/pricing.ts), settings, and the per-item cost from \`gstack-eval-plan arms\`. -->
|
||
|
||
## Decoys
|
||
<!-- Inputs the scorer must reject or score low; how many, how they were made, what a decoy pass proves. -->
|
||
|
||
## Stop rules
|
||
<!-- When the run stops early: the spend cap, a bar missed on the pilot, a scorer below its bar, budget_exhausted. -->
|
||
|
||
## Held-out exposure
|
||
<!-- Every held-out set this plan touches: | set | prior exposure (which runs, which models) | reservation (what this run may read) | -->
|
||
<!-- Optional: a \`## Diagnostics\` section declares the summarizer (\`summarizer: <command naming {fixture}>\`), its synthetic records (\`fixture: <path>\`) and one | diagnostic | expected | row per diagnostic; \`prereg --check --allow-repo-commands\` runs it. -->
|
||
`;
|
||
}
|
||
|
||
/** A section's content lines (blank lines and one-line `<!-- -->` guidance dropped); null when the heading is absent. */
|
||
function sectionBody(text: string, name: string): string[] | null {
|
||
const lines = text.split('\n');
|
||
const headings = lines.map((l, i) => ({ i, m: /^#{1,6}\s+(.+?)\s*$/.exec(l) })).filter(x => x.m) as Array<{ i: number; m: RegExpExecArray }>;
|
||
const idx = headings.findIndex(h => h.m[1]!.toLowerCase().replace(/[^a-z]/g, '') === name.toLowerCase().replace(/[^a-z]/g, ''));
|
||
if (idx < 0) return null;
|
||
const end = idx + 1 < headings.length ? headings[idx + 1]!.i : lines.length;
|
||
return lines.slice(headings[idx]!.i + 1, end).filter(l => l.trim() && !/^\s*<!--.*-->\s*$/.test(l));
|
||
}
|
||
|
||
export function checkPrereg(text: string): PreregCheck {
|
||
const present: string[] = []; const missing: string[] = []; const empty: string[] = [];
|
||
for (const name of PREREG_SECTIONS) {
|
||
const body = sectionBody(text, name);
|
||
if (body === null) missing.push(name); else if (body.length === 0) empty.push(name); else present.push(name);
|
||
}
|
||
const diagnostics = parsePreregDiagnostics(text);
|
||
return { ok: missing.length === 0 && empty.length === 0 && !diagnostics?.errors.length, present, missing, empty, diagnostics };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// preregistered diagnostics: the declared summarizer on a declared synthetic fixture
|
||
// ---------------------------------------------------------------------------
|
||
/** Placeholder the summarizer command names the fixture by; it runs as "$GSTACK_PREREG_FIXTURE", never spliced into the shell text. */
|
||
export const PREREG_FIXTURE_PLACEHOLDER = '{fixture}';
|
||
export const PREREG_SUMMARIZER_TIMEOUT_MS = 300_000;
|
||
export interface PreregDiagnostics { summarizer: string | null; fixture: string | null; expected: Array<{ name: string; count: number }>; errors: string[] }
|
||
export interface DiagnosticRow { name: string; expected: number | null; got: number | null; status: 'ok' | 'mismatch' | 'missing' | 'undeclared' }
|
||
export interface PreregDiagnosticRun { ok: boolean; command: string; fixture: string; fixture_sha256: string | null; exit: number | null; rows: DiagnosticRow[]; error: string | null }
|
||
|
||
/**
|
||
* The optional `## Diagnostics` section: `summarizer: <command naming {fixture}>`,
|
||
* `fixture: <repo-relative path>` and a `| diagnostic | expected |` table with one
|
||
* row per preregistered diagnostic. Absent or empty: nothing declared (null).
|
||
*/
|
||
export function parsePreregDiagnostics(text: string): PreregDiagnostics | null {
|
||
const body = sectionBody(text, 'Diagnostics');
|
||
if (!body?.length) return null;
|
||
const d: PreregDiagnostics = { summarizer: null, fixture: null, expected: [], errors: [] };
|
||
const value = (raw: string) => raw.trim().replace(/^`(.+)`$/, '$1').trim();
|
||
for (const line of body) {
|
||
const kv = /^\s*(?:[-*]\s+)?(summarizer|fixture):\s*(.+)$/i.exec(line);
|
||
if (kv) { d[kv[1]!.toLowerCase() as 'summarizer' | 'fixture'] = value(kv[2]!); continue; }
|
||
const row = /^\s*\|(.+)\|\s*$/.exec(line);
|
||
if (!row || /^[\s:|-]+$/.test(row[1]!)) continue;
|
||
const cells = row[1]!.split('|').map(value);
|
||
if (cells[0]!.toLowerCase() === 'diagnostic') continue;
|
||
if (cells.length < 2 || !cells[0] || !/^\d+$/.test(cells[1]!)) { d.errors.push(`row ${JSON.stringify(line.trim())}: needs | <diagnostic> | <expected count, a non-negative integer> |`); continue; }
|
||
if (d.expected.some(e => e.name === cells[0])) { d.errors.push(`diagnostic ${cells[0]} is declared twice`); continue; }
|
||
d.expected.push({ name: cells[0]!, count: Number(cells[1]) });
|
||
}
|
||
if (!d.summarizer) d.errors.push('no summarizer: line (the command that summarizes the run records)');
|
||
else if (!d.summarizer.includes(PREREG_FIXTURE_PLACEHOLDER)) d.errors.push(`the summarizer command never names the fixture (${PREREG_FIXTURE_PLACEHOLDER})`);
|
||
if (!d.fixture) d.errors.push('no fixture: line (the synthetic records file the summarizer runs on)');
|
||
if (!d.expected.length) d.errors.push('no | diagnostic | expected | rows');
|
||
return d;
|
||
}
|
||
|
||
/**
|
||
* Run the declared summarizer on the declared fixture (a repo command: the
|
||
* caller gates it on --allow-repo-commands). Its last non-empty stdout line is
|
||
* one JSON object of diagnostic name to count; every declared diagnostic must
|
||
* appear with its expected count and nothing undeclared may appear.
|
||
*/
|
||
export function runPreregDiagnostics(d: PreregDiagnostics, repoRoot: string, env: NodeJS.ProcessEnv = process.env): PreregDiagnosticRun {
|
||
const command = d.summarizer!.split(PREREG_FIXTURE_PLACEHOLDER).join('"$GSTACK_PREREG_FIXTURE"');
|
||
const fixture = path.resolve(repoRoot, d.fixture!);
|
||
const run: PreregDiagnosticRun = { ok: false, command, fixture: d.fixture!, fixture_sha256: null, exit: null, rows: [], error: null };
|
||
if (!fs.existsSync(fixture) || !fs.statSync(fixture).isFile()) return { ...run, error: `declared fixture ${d.fixture} is not a file under ${repoRoot}` };
|
||
run.fixture_sha256 = createHash('sha256').update(fs.readFileSync(fixture)).digest('hex');
|
||
const r = spawnSync('sh', ['-c', command], { cwd: repoRoot, env: { ...env, GSTACK_PREREG_FIXTURE: fixture }, encoding: 'utf8', timeout: PREREG_SUMMARIZER_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
|
||
run.exit = r.status;
|
||
if (r.status === null) return { ...run, error: r.signal ? `summarizer did not finish within ${PREREG_SUMMARIZER_TIMEOUT_MS / 1000} s (${r.signal})` : `summarizer did not start: ${r.error?.message ?? 'unknown error'}` };
|
||
const tail = (r.stderr ?? '').trim().split('\n').slice(-1)[0] ?? '';
|
||
if (r.status !== 0) return { ...run, error: `summarizer exited ${r.status}${tail ? `: ${tail}` : ''}` };
|
||
const last = (r.stdout ?? '').trim().split('\n').filter(l => l.trim()).slice(-1)[0] ?? '';
|
||
let counts: Record<string, unknown> | null = null;
|
||
try { const v = JSON.parse(last); counts = v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { counts = null; }
|
||
if (!counts) return { ...run, error: `the summarizer's last stdout line is not a JSON object of diagnostic counts (${JSON.stringify(last.slice(0, 120))})` };
|
||
const bad = Object.entries(counts).filter(([, v]) => !Number.isInteger(v) || (v as number) < 0).map(([k]) => k);
|
||
if (bad.length) return { ...run, error: `the summarizer reported non-count values for ${bad.join(', ')}` };
|
||
for (const e of d.expected) {
|
||
const got = counts[e.name] === undefined ? null : counts[e.name] as number;
|
||
run.rows.push({ name: e.name, expected: e.count, got, status: got === null ? 'missing' : got === e.count ? 'ok' : 'mismatch' });
|
||
}
|
||
for (const [name, got] of Object.entries(counts)) if (!d.expected.some(e => e.name === name)) run.rows.push({ name, expected: null, got: got as number, status: 'undeclared' });
|
||
return { ...run, ok: run.rows.every(row => row.status === 'ok') };
|
||
}
|
||
|
||
export function renderPreregDiagnostics(run: PreregDiagnosticRun): string {
|
||
const rows = run.rows.map(r => `PREREG_DIAGNOSTIC: ${r.name} expected=${r.expected ?? 'undeclared'} got=${r.got ?? 'none'} ${r.status}`);
|
||
rows.push(`PREREG_DIAGNOSTICS: ${run.ok ? 'ok' : 'untested'} n=${run.rows.filter(r => r.status !== 'undeclared').length} fixture=${run.fixture}${run.fixture_sha256 ? ` sha256=${run.fixture_sha256.slice(0, 12)}` : ''}${run.error ? ` error=${JSON.stringify(run.error)}` : ''}`);
|
||
return rows.join('\n') + '\n';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// arms
|
||
// ---------------------------------------------------------------------------
|
||
export interface Arm { model: string; provider: string | null; source: string; priced: boolean; input_per_mtok: number | null; output_per_mtok: number | null; per_item_usd: number | null }
|
||
export interface ArmsResult { arms: Arm[]; source: string; errors: Array<{ code: ResultCodeName; message: string }> }
|
||
export interface TokenEstimate { input: number; output: number }
|
||
export const DEFAULT_TOKENS: TokenEstimate = { input: 4_000, output: 2_000 };
|
||
const POLICY_DOCS = ['docs/model-policy.md', 'MODEL_POLICY.md', 'docs/MODELS.md'];
|
||
|
||
function priceArm(model: string, source: string, provider: string | null, tokens: TokenEstimate): Arm {
|
||
const row = pricingRow(model);
|
||
return { model, provider, source, priced: !!row, input_per_mtok: row?.input_per_mtok ?? null, output_per_mtok: row?.output_per_mtok ?? null, per_item_usd: row ? estimateCostUsd(tokens, model) : null };
|
||
}
|
||
|
||
/** Models named in the repo's policy document that the price table knows. */
|
||
export function armsFromPolicyDoc(file: string): string[] {
|
||
const text = fs.readFileSync(file, 'utf8');
|
||
const found = new Set<string>();
|
||
for (const model of Object.keys(PRICING)) {
|
||
const re = new RegExp(`(^|[^A-Za-z0-9._-])${model.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9._-])`, 'm');
|
||
if (re.test(text)) found.add(model);
|
||
}
|
||
return [...found];
|
||
}
|
||
|
||
export interface ArmsOptions { cwd: string; explicit?: string[]; policyDoc?: string; tokens?: TokenEstimate; env?: NodeJS.ProcessEnv; modelsCli?: (args: string[]) => { code: number; stdout: string } }
|
||
|
||
/** `gstack-models resolve --role eval-arms --json`, else the repo policy doc, else `--arms`. Every arm carries its source. */
|
||
export function resolveArms(o: ArmsOptions): ArmsResult {
|
||
const tokens = o.tokens ?? DEFAULT_TOKENS;
|
||
const errors: ArmsResult['errors'] = [];
|
||
if (o.explicit?.length) {
|
||
const arms = o.explicit.map(m => priceArm(m, 'flag:--arms', null, tokens));
|
||
for (const a of arms) if (!a.priced) errors.push({ code: 'EVAL_PRICE_MISSING', message: `${a.model} has no row in lib/pricing.ts` });
|
||
return { arms, source: 'flag:--arms', errors };
|
||
}
|
||
const cli = o.modelsCli ?? ((args: string[]) => modelsMain(args, o.env ?? process.env, process.platform, o.cwd));
|
||
const r = cli(['resolve', '--role', 'eval-arms', '--json']);
|
||
if (r.code === 0) {
|
||
try {
|
||
const doc = JSON.parse(r.stdout) as { selections?: Array<{ provider?: string; requestedModel?: string | null; status?: string; tier?: string }> };
|
||
const selected = (doc.selections ?? []).filter(s => s.status === 'selected' && s.requestedModel);
|
||
if (selected.length) {
|
||
const arms = selected.map(s => priceArm(s.requestedModel!, `policy:eval-arms/${s.tier ?? 'tier'}`, s.provider ?? null, tokens));
|
||
for (const a of arms) if (!a.priced) errors.push({ code: 'EVAL_PRICE_MISSING', message: `${a.model} has no row in lib/pricing.ts` });
|
||
return { arms, source: 'policy:eval-arms', errors };
|
||
}
|
||
} catch { /* fall through to the policy document */ }
|
||
}
|
||
const doc = o.policyDoc ?? POLICY_DOCS.map(p => path.join(o.cwd, p)).find(p => fs.existsSync(p));
|
||
if (doc && fs.existsSync(doc)) {
|
||
const models = armsFromPolicyDoc(doc);
|
||
if (models.length) return { arms: models.map(m => priceArm(m, `policy-doc:${path.relative(o.cwd, doc) || doc}`, null, tokens)), source: `policy-doc:${path.relative(o.cwd, doc) || doc}`, errors };
|
||
errors.push({ code: 'EVAL_ARMS_UNRESOLVED', message: `${doc} names no model in the price table` });
|
||
} else errors.push({ code: 'EVAL_ARMS_UNRESOLVED', message: `model policy returned no arm and no policy document found (${POLICY_DOCS.join(', ')})` });
|
||
return { arms: [], source: 'none', errors };
|
||
}
|
||
|
||
export function renderArms(r: ArmsResult, tokens: TokenEstimate): string {
|
||
const lines = r.arms.map(a => `ARM: ${a.model} provider=${a.provider ?? 'unknown'} source=${a.source} price=${a.priced ? `${a.input_per_mtok}/${a.output_per_mtok} per MTok` : 'MISSING'} per_item=${a.per_item_usd === null ? 'unknown' : `$${a.per_item_usd.toFixed(4)}`} (${tokens.input} in / ${tokens.output} out tokens)`);
|
||
lines.push(`ARMS: n=${r.arms.length} priced=${r.arms.filter(a => a.priced).length} source=${r.source}`);
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// $0 stub-model dry run
|
||
// ---------------------------------------------------------------------------
|
||
/** Reasoning models spend output tokens on thinking; a budget under this truncates the answer the scorer reads. */
|
||
export const REASONING_OUTPUT_FLOOR = 8_192;
|
||
export interface DryRunRow { line: number; ok: boolean; missing: string[]; input_tokens: number }
|
||
export interface DryRunResult {
|
||
schema_version: 1; input: string; rows: number; bad_rows: DryRunRow[]; est_input_tokens: number; est_output_tokens: number;
|
||
arms: string[];
|
||
checks: {
|
||
price_table: { ok: boolean; missing: string[] }; output_budget: { ok: boolean; budget: number; floor: number }; retry_prompt: { ok: boolean; text: string | null };
|
||
/** Present with `cap`: one call per arm (the largest row's input plus the output budget), priced, fits under the cap. */
|
||
reservation_fits_cap?: { ok: boolean; cap: number; max_input_tokens: number; per_arm: Record<string, number | null> };
|
||
/** Present when the caller linted the arms against .gstack/eval-models.json (lib/eval-models.ts). */
|
||
model_rules?: { ok: boolean; source: string; refused: string[] };
|
||
};
|
||
per_item_usd: Record<string, number | null>; excluded_billing: readonly string[]; pass: boolean; stub: 'stub-model (no network, $0)';
|
||
}
|
||
export interface DryRunOptions { input: string; arms: string[]; fields?: string[]; outputBudget?: number; retryPrompt?: string; maxRows?: number; cap?: number; modelRules?: ModelRulesCheck }
|
||
|
||
/** Four bytes per token, the estimator every gstack admission uses. */
|
||
export const estimateTokens = (text: string) => Math.ceil(Buffer.byteLength(text, 'utf8') / 4);
|
||
|
||
export function dryRun(o: DryRunOptions): DryRunResult {
|
||
const fields = o.fields?.length ? o.fields : ['prompt'];
|
||
const budget = o.outputBudget ?? REASONING_OUTPUT_FLOOR;
|
||
const text = fs.readFileSync(o.input, 'utf8');
|
||
const lines = text.split('\n');
|
||
const bad: DryRunRow[] = []; let rows = 0; let inputTokens = 0; let maxInput = 0;
|
||
lines.forEach((line, i) => {
|
||
if (!line.trim()) return;
|
||
if (o.maxRows && rows >= o.maxRows) return;
|
||
rows += 1;
|
||
let parsed: Record<string, unknown> | null = null;
|
||
try { const v = JSON.parse(line); parsed = v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { parsed = null; }
|
||
if (!parsed) { bad.push({ line: i + 1, ok: false, missing: ['<not a JSON object>'], input_tokens: 0 }); return; }
|
||
const missing = fields.filter(f => parsed![f] === undefined || parsed![f] === null || parsed![f] === '');
|
||
const t = estimateTokens(fields.map(f => typeof parsed![f] === 'string' ? parsed![f] as string : JSON.stringify(parsed![f] ?? '')).join('\n'));
|
||
inputTokens += t;
|
||
maxInput = Math.max(maxInput, t);
|
||
if (missing.length) bad.push({ line: i + 1, ok: false, missing, input_tokens: t });
|
||
});
|
||
const retryText = o.retryPrompt && fs.existsSync(o.retryPrompt) ? fs.readFileSync(o.retryPrompt, 'utf8').trim() : null;
|
||
const missingPrice = o.arms.filter(a => !hasPricing(a));
|
||
const perItem: Record<string, number | null> = {};
|
||
const avgIn = rows ? Math.ceil(inputTokens / rows) : 0;
|
||
for (const a of o.arms) perItem[a] = hasPricing(a) ? estimateCostUsd({ input: avgIn, output: budget }, a) : null;
|
||
const checks = {
|
||
price_table: { ok: missingPrice.length === 0 && o.arms.length > 0, missing: missingPrice },
|
||
output_budget: { ok: budget >= REASONING_OUTPUT_FLOOR, budget, floor: REASONING_OUTPUT_FLOOR },
|
||
retry_prompt: { ok: !!retryText, text: retryText },
|
||
} as DryRunResult['checks'];
|
||
if (o.cap !== undefined) {
|
||
const perArm: Record<string, number | null> = {};
|
||
for (const a of o.arms) perArm[a] = hasPricing(a) ? estimateCostUsd({ input: maxInput, output: budget }, a) : null;
|
||
checks.reservation_fits_cap = { ok: Object.values(perArm).every(v => v === null || v <= o.cap!), cap: o.cap, max_input_tokens: maxInput, per_arm: perArm };
|
||
}
|
||
if (o.modelRules) checks.model_rules = { ok: o.modelRules.ok, source: o.modelRules.source, refused: o.modelRules.errors.map(e => e.message) };
|
||
return {
|
||
schema_version: 1, input: o.input, rows, bad_rows: bad, est_input_tokens: inputTokens, est_output_tokens: rows * budget, arms: o.arms, checks, per_item_usd: perItem,
|
||
excluded_billing: EXCLUDED_BILLING_CATEGORIES, pass: rows > 0 && bad.length === 0 && checks.price_table.ok && checks.output_budget.ok && checks.retry_prompt.ok && (checks.reservation_fits_cap?.ok ?? true) && (checks.model_rules?.ok ?? true), stub: 'stub-model (no network, $0)',
|
||
};
|
||
}
|
||
|
||
export function renderDryRun(r: DryRunResult): string {
|
||
const lines = [
|
||
`DRY_RUN: ${r.stub} input=${r.input} rows=${r.rows} bad_rows=${r.bad_rows.length} est_input_tokens=${r.est_input_tokens} est_output_tokens=${r.est_output_tokens}`,
|
||
...r.bad_rows.slice(0, 20).map(b => `DRY_RUN_ROW: line ${b.line} missing=${b.missing.join(',')}`),
|
||
`CHECK: price-table ${r.checks.price_table.ok ? 'ok' : `missing ${r.checks.price_table.missing.join(',') || '(no arms)'}`}`,
|
||
`CHECK: output-budget ${r.checks.output_budget.ok ? 'ok' : 'too small'} (${r.checks.output_budget.budget} tokens; reasoning floor ${r.checks.output_budget.floor})`,
|
||
`CHECK: retry-prompt ${r.checks.retry_prompt.ok ? `ok — ${JSON.stringify(r.checks.retry_prompt.text)}` : 'missing (pass --retry-prompt <file> with the exact text the run will send)'}`,
|
||
...(r.checks.reservation_fits_cap ? [renderReservationCheck(r.checks.reservation_fits_cap, r.checks.output_budget.budget)] : []),
|
||
...(r.checks.model_rules ? [`CHECK: model-rules ${r.checks.model_rules.ok ? 'ok' : `refused ${r.checks.model_rules.refused.join('; ') || 'invalid rules'}`} (source ${r.checks.model_rules.source})`] : []),
|
||
...Object.entries(r.per_item_usd).map(([m, usd]) => `ESTIMATE: ${m} per_item=${usd === null ? 'unknown (no price)' : `$${usd.toFixed(4)}`} (estimate: excludes ${r.excluded_billing.length} billing categories, see lib/pricing.ts)`),
|
||
`DRY_RUN_RESULT: ${r.pass ? 'pass' : 'fail'}`,
|
||
];
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
function renderReservationCheck(c: NonNullable<DryRunResult['checks']['reservation_fits_cap']>, budget: number): string {
|
||
const worst = Math.max(0, ...Object.values(c.per_arm).filter((v): v is number => v !== null));
|
||
return `CHECK: reservation-fits-cap ${c.ok ? 'ok' : 'exceeds'} ($${worst.toFixed(4)} ${c.ok ? '<=' : '>'} cap $${c.cap.toFixed(2)}) one call = ${c.max_input_tokens} in + ${budget} out tokens`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// priced pilot over lib/spend-ledger.ts
|
||
// ---------------------------------------------------------------------------
|
||
export const PILOT_FILES = { spend: 'spend.json', pilot: 'pilot.json' } as const;
|
||
export interface PilotAttempt { attempt: string; model: string; row: number; estimate_usd: number }
|
||
export interface PilotRecord { schema_version: 1; n: number; arms: string[]; cap_usd: number; dry_run: string; attempts: PilotAttempt[]; started_at: string; status: 'reserved' | 'budget_exhausted' }
|
||
export interface PilotStartOptions { out: string; cap: number; n: number; arms: string[]; dryRun: string; tokens?: TokenEstimate; now?: Date }
|
||
|
||
/** Reserve n attempts per arm under the cap; a refused reservation ends the pilot `budget_exhausted` with nothing else reserved. */
|
||
export function startPilot(o: PilotStartOptions): { record: PilotRecord; error?: { code: ResultCodeName; message: string } } {
|
||
const dry = JSON.parse(fs.readFileSync(o.dryRun, 'utf8')) as DryRunResult;
|
||
if (!dry.pass) return { record: null as unknown as PilotRecord, error: { code: 'EVAL_DRY_RUN_FAILED', message: `${o.dryRun} did not pass; the pilot never starts on a failed dry run` } };
|
||
const tokens = o.tokens ?? { input: dry.rows ? Math.ceil(dry.est_input_tokens / dry.rows) : DEFAULT_TOKENS.input, output: dry.checks.output_budget.budget };
|
||
fs.mkdirSync(o.out, { recursive: true });
|
||
const ledgerFile = path.join(o.out, PILOT_FILES.spend);
|
||
const record: PilotRecord = { schema_version: 1, n: o.n, arms: o.arms, cap_usd: o.cap, dry_run: o.dryRun, attempts: [], started_at: (o.now ?? new Date()).toISOString(), status: 'reserved' };
|
||
let error: { code: ResultCodeName; message: string } | undefined;
|
||
outer: for (const model of o.arms) {
|
||
if (!hasPricing(model)) { error = { code: 'EVAL_PRICE_MISSING', message: `${model} has no row in lib/pricing.ts` }; break; }
|
||
for (let i = 1; i <= o.n; i++) {
|
||
const estimate = estimateCostUsd(tokens, model);
|
||
const attempt = `pilot-${model.replace(/[^A-Za-z0-9.-]/g, '_')}-${i}`;
|
||
try { reserve(ledgerFile, attempt, estimate, { cap: o.cap, label: `pilot ${model} #${i}`, now: o.now }); }
|
||
catch (e) {
|
||
if (e instanceof SpendCapExceeded) { record.status = 'budget_exhausted'; error = { code: 'EVAL_BUDGET_EXHAUSTED', message: e.message }; break outer; }
|
||
throw e;
|
||
}
|
||
record.attempts.push({ attempt, model, row: i, estimate_usd: estimate });
|
||
}
|
||
}
|
||
fs.writeFileSync(path.join(o.out, PILOT_FILES.pilot), JSON.stringify(record, null, 2) + '\n');
|
||
return { record, error };
|
||
}
|
||
|
||
export function settlePilot(out: string, attempt: string, usd: number | 'unknown'): SpendLedger {
|
||
return settle(path.join(out, PILOT_FILES.spend), attempt, usd);
|
||
}
|
||
export function releasePilot(out: string, attempt: string): SpendLedger {
|
||
return release(path.join(out, PILOT_FILES.spend), attempt);
|
||
}
|
||
|
||
/** Two-sided 95% Student t for n-1 degrees of freedom (n settled items); 2.0 past the table. */
|
||
const T95: Record<number, number> = { 2: 12.706, 3: 4.303, 4: 3.182, 5: 2.776, 6: 2.571, 7: 2.447, 8: 2.365, 9: 2.306, 10: 2.262 };
|
||
export interface Projection { n: number; per_item_usd: number | null; sd_usd: number | null; total: number; projected_usd: number | null; band_usd: [number, number] | null; verdict: 'within' | 'exceeds' | 'unknown' }
|
||
export interface SpendTable { summary: SpendSummary; workers: number; max_estimate_usd: number; worst_case_usd: number; worst_case_overrun_usd: number }
|
||
export interface PilotReport { record: PilotRecord; spend: SpendTable; projection: Projection; settled_known: number; settled_unknown: number }
|
||
|
||
export function pilotReport(out: string, o: { total: number; workers?: number }): PilotReport {
|
||
const record = JSON.parse(fs.readFileSync(path.join(out, PILOT_FILES.pilot), 'utf8')) as PilotRecord;
|
||
const ledger = readLedger(path.join(out, PILOT_FILES.spend), record.cap_usd);
|
||
const summary = summarize(ledger);
|
||
const known = ledger.settled.filter(s => typeof s.usd === 'number').map(s => s.usd as number);
|
||
const unknown = ledger.settled.length - known.length;
|
||
const workers = Math.max(1, o.workers ?? 1);
|
||
const maxEstimate = Math.max(0, ...record.attempts.map(a => a.estimate_usd));
|
||
const worst = summary.worst_case_usd + workers * maxEstimate;
|
||
const n = known.length;
|
||
const mean = n ? known.reduce((s, x) => s + x, 0) / n : null;
|
||
const sd = n > 1 && mean !== null ? Math.sqrt(known.reduce((s, x) => s + (x - mean) ** 2, 0) / (n - 1)) : null;
|
||
const perArmTotal = o.total * Math.max(1, record.arms.length);
|
||
const projected = mean === null ? null : mean * perArmTotal;
|
||
const half = sd !== null && mean !== null ? (T95[n] ?? 2.0) * (sd / Math.sqrt(n)) * perArmTotal : null;
|
||
const band: [number, number] | null = projected !== null && half !== null ? [Math.max(0, projected - half), projected + half] : null;
|
||
const verdict: Projection['verdict'] = projected === null || band === null || unknown > 0 ? 'unknown' : band[1] + summary.worst_case_usd <= record.cap_usd ? 'within' : 'exceeds';
|
||
return {
|
||
record, settled_known: n, settled_unknown: unknown,
|
||
spend: { summary, workers, max_estimate_usd: maxEstimate, worst_case_usd: worst, worst_case_overrun_usd: Math.max(0, worst - record.cap_usd) },
|
||
projection: { n, per_item_usd: mean, sd_usd: sd, total: o.total, projected_usd: projected, band_usd: band, verdict },
|
||
};
|
||
}
|
||
|
||
const usd = (v: number | null) => (v === null ? 'unknown' : `$${v.toFixed(4)}`);
|
||
|
||
export function renderSpendTable(t: SpendTable, cap: number): string {
|
||
const s = t.summary;
|
||
return [
|
||
'| spent | reserved | unknown charges | worst case (+1 in-flight call × workers) | cap | worst-case overrun |',
|
||
'|---|---|---|---|---|---|',
|
||
`| $${s.spent_usd.toFixed(4)} | $${s.reserved_usd.toFixed(4)} (${s.open_attempts.length} open) | ${s.unknown_count} ($${s.unknown_usd.toFixed(4)} at reservation) | $${t.worst_case_usd.toFixed(4)} (${t.workers} worker${t.workers === 1 ? '' : 's'} × $${t.max_estimate_usd.toFixed(4)}) | $${cap.toFixed(2)} | $${t.worst_case_overrun_usd.toFixed(4)} |`,
|
||
].join('\n');
|
||
}
|
||
|
||
export function renderPilotReport(r: PilotReport): string {
|
||
const p = r.projection;
|
||
return [
|
||
`PILOT: n=${r.record.n} arms=${r.record.arms.join(',')} settled=${r.settled_known} unknown=${r.settled_unknown} status=${r.record.status}`,
|
||
renderSpendTable(r.spend, r.record.cap_usd),
|
||
`PILOT_PROJECTION: items=${p.total} per_item=${usd(p.per_item_usd)} sd=${usd(p.sd_usd)} projected=${usd(p.projected_usd)} band=${p.band_usd ? `[${usd(p.band_usd[0])}, ${usd(p.band_usd[1])}]` : 'unknown'} cap=$${r.record.cap_usd.toFixed(2)} verdict=${p.verdict}`,
|
||
p.verdict === 'unknown' ? `APPROVAL: not ready — ${r.settled_known < 2 ? 'settle at least 2 pilot attempts with actual cost' : 'unknown charges make the band meaningless'}; settle, then rerun report` : `APPROVAL: run ${p.total} items at projected ${usd(p.projected_usd)} (95% band ${usd(p.band_usd![0])}–${usd(p.band_usd![1])}) under cap $${r.record.cap_usd.toFixed(2)}: ${p.verdict === 'within' ? 'fits; ask the owner before any further priced call' : 'EXCEEDS the cap; stop, raise the cap on purpose or cut arms or items'}`,
|
||
].join('\n') + '\n';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// paid gates priced from a call-plan data file (docs/eval-calls.md)
|
||
// ---------------------------------------------------------------------------
|
||
/**
|
||
* The plan file is DATA: `price-gate` reads it, checks its binding (the exact
|
||
* gate command, the head it was produced at, each input's sha256 at HEAD) and
|
||
* prices its `GSTACK_EVAL_CALLS v1` rows. Nothing in it is ever executed.
|
||
*/
|
||
export const EVAL_CALL_KEYS = ['purpose', 'model', 'calls', 'in_tokens', 'out_tokens', 'max_out_tokens', 'scope'] as const;
|
||
export interface EvalCallRow { purpose: string; model: string; calls: number; in_tokens: number; out_tokens: number; max_out_tokens: number; scope: 'full' | 'smoke' }
|
||
export interface EvalPlanInput { path: string; sha256: string }
|
||
export interface EvalCallPlan { gate: string; head: string; inputs: EvalPlanInput[]; preconditions: string[]; rows: EvalCallRow[] }
|
||
|
||
/** The reference emitter a consuming repo copies: binding lines, preconditions, one line per call purpose, the end line. */
|
||
export function formatEvalCalls(p: EvalCallPlan): string {
|
||
return [
|
||
'# gstack eval call plan, wire format v1 (docs/eval-calls.md); priced by `gstack-eval-plan price-gate`, never executed',
|
||
`GSTACK_EVAL_GATE v1: ${p.gate}`,
|
||
`GSTACK_EVAL_HEAD v1: ${p.head}`,
|
||
...p.inputs.map(i => `GSTACK_EVAL_INPUT v1: sha256=${i.sha256} path=${i.path}`),
|
||
...p.preconditions.map(t => `GSTACK_EVAL_PRECONDITION: ${t}`),
|
||
...p.rows.map(r => `GSTACK_EVAL_CALLS v1: ${EVAL_CALL_KEYS.map(k => `${k}=${r[k]}`).join(' ')}`),
|
||
`GSTACK_EVAL_CALLS v1 end: lines=${p.rows.length}`,
|
||
].join('\n') + '\n';
|
||
}
|
||
|
||
const INT_KEYS = ['calls', 'in_tokens', 'out_tokens', 'max_out_tokens'] as const;
|
||
|
||
function parseCallRow(body: string, at: string): EvalCallRow | string {
|
||
const fields: Record<string, string> = {};
|
||
for (const tok of body.trim().split(/ +/)) {
|
||
const eq = tok.indexOf('=');
|
||
if (eq <= 0) return `${at}: ${JSON.stringify(tok)} is not key=value`;
|
||
const key = tok.slice(0, eq);
|
||
if (!(EVAL_CALL_KEYS as readonly string[]).includes(key)) return `${at}: unknown key ${key}`;
|
||
if (key in fields) return `${at}: duplicate key ${key}`;
|
||
fields[key] = tok.slice(eq + 1);
|
||
}
|
||
for (const key of EVAL_CALL_KEYS) if (!(key in fields)) return `${at}: missing key ${key}`;
|
||
for (const key of INT_KEYS) if (!/^\d+$/.test(fields[key]!)) return `${at}: ${key} must be a non-negative integer (got ${JSON.stringify(fields[key])})`;
|
||
if (Number(fields.calls) < 1) return `${at}: calls must be at least 1`;
|
||
if (fields.scope !== 'full' && fields.scope !== 'smoke') return `${at}: scope must be full or smoke (got ${JSON.stringify(fields.scope)})`;
|
||
if (!/^[A-Za-z0-9._:-]+$/.test(fields.purpose!)) return `${at}: purpose must match [A-Za-z0-9._:-]+`;
|
||
if (!fields.model) return `${at}: model is empty`;
|
||
return { purpose: fields.purpose!, model: fields.model, calls: Number(fields.calls), in_tokens: Number(fields.in_tokens), out_tokens: Number(fields.out_tokens), max_out_tokens: Number(fields.max_out_tokens), scope: fields.scope };
|
||
}
|
||
|
||
/** Strict v1 parser: a malformed line, an unknown kind or version, a missing binding or a missing or miscounted end line refuses, naming the line. */
|
||
export function parseEvalCalls(text: string): { plan: EvalCallPlan } | { error: string } {
|
||
const plan: EvalCallPlan = { gate: '', head: '', inputs: [], preconditions: [], rows: [] };
|
||
const seen = new Set<string>();
|
||
let gate: string | null = null; let head: string | null = null; let ended = false;
|
||
const lines = text.split('\n');
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const raw = lines[i]!.replace(/\r$/, ''); const at = `line ${i + 1}`;
|
||
if (!raw.trim() || raw.startsWith('#')) continue;
|
||
if (ended) return { error: `${at}: content after the end line` };
|
||
const m = /^(GSTACK_EVAL_[A-Z_]+?)(?: (v\d+))?( end)?: (.*)$/.exec(raw);
|
||
if (!m) return { error: `${at}: not a GSTACK_EVAL_ line (${JSON.stringify(raw.slice(0, 80))})` };
|
||
const [, kind, version, end, body] = m as unknown as [string, string, string | undefined, string | undefined, string];
|
||
if (kind === 'GSTACK_EVAL_PRECONDITION' && !version && !end) { plan.preconditions.push(body.trim()); continue; }
|
||
if (!['GSTACK_EVAL_GATE', 'GSTACK_EVAL_HEAD', 'GSTACK_EVAL_INPUT', 'GSTACK_EVAL_CALLS'].includes(kind)) return { error: `${at}: unknown line kind ${kind}` };
|
||
if (version !== 'v1') return { error: `${at}: ${kind} version ${version ?? '(none)'} is not supported (this gstack reads v1)` };
|
||
if (end) {
|
||
const n = kind === 'GSTACK_EVAL_CALLS' ? /^lines=(\d+)$/.exec(body.trim()) : null;
|
||
if (!n) return { error: `${at}: malformed end line (expected GSTACK_EVAL_CALLS v1 end: lines=<n>)` };
|
||
if (Number(n[1]) !== plan.rows.length) return { error: `${at}: end line says lines=${n[1]} but the file has ${plan.rows.length} call lines` };
|
||
ended = true; continue;
|
||
}
|
||
if (kind === 'GSTACK_EVAL_GATE') {
|
||
if (gate !== null) return { error: `${at}: second GSTACK_EVAL_GATE line` };
|
||
if (!body.trim()) return { error: `${at}: empty gate command` };
|
||
gate = body; continue;
|
||
}
|
||
if (kind === 'GSTACK_EVAL_HEAD') {
|
||
if (head !== null) return { error: `${at}: second GSTACK_EVAL_HEAD line` };
|
||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(body.trim())) return { error: `${at}: head must be a full commit sha` };
|
||
head = body.trim(); continue;
|
||
}
|
||
if (kind === 'GSTACK_EVAL_INPUT') {
|
||
const inp = /^sha256=([0-9a-f]{64}) path=(.+)$/.exec(body);
|
||
if (!inp) return { error: `${at}: expected GSTACK_EVAL_INPUT v1: sha256=<64 hex> path=<repo-relative path>` };
|
||
if (path.isAbsolute(inp[2]!) || inp[2]!.split(/[\\/]/).includes('..')) return { error: `${at}: input path must be repo-relative without .. (${inp[2]})` };
|
||
plan.inputs.push({ path: inp[2]!, sha256: inp[1]! }); continue;
|
||
}
|
||
const row = parseCallRow(body, at);
|
||
if (typeof row === 'string') return { error: row };
|
||
if (seen.has(`${row.purpose}\0${row.model}`)) return { error: `${at}: duplicate purpose=${row.purpose} model=${row.model}` };
|
||
seen.add(`${row.purpose}\0${row.model}`);
|
||
plan.rows.push(row);
|
||
}
|
||
if (gate === null) return { error: 'missing GSTACK_EVAL_GATE v1 line (the exact gate command this plan prices)' };
|
||
if (head === null) return { error: 'missing GSTACK_EVAL_HEAD v1 line (the commit the plan was produced at)' };
|
||
if (!ended) return { error: 'missing end line (GSTACK_EVAL_CALLS v1 end: lines=<n>); a partial file is refused' };
|
||
return { plan: { ...plan, gate, head } };
|
||
}
|
||
|
||
export interface PricedCallRow extends EvalCallRow { usd: number | null; reservation_usd: number | null }
|
||
export interface GateReceipt {
|
||
schema_version: 1; kind: 'gstack-eval-price-gate'; created_at: string; gstack_revision: string;
|
||
gate_command: string; gate_sha256: string; head_sha: string;
|
||
plan: { file: string | null; source: 'flag' | 'price_file' | 'none'; sha256: string | null; declared_head: string | null };
|
||
inputs: EvalPlanInput[]; preconditions: string[]; rows: PricedCallRow[];
|
||
total_usd: number | null; calls: number; purposes: string[]; cap_usd: number | null; smoke_cap_usd: number | null;
|
||
result: 'priced' | 'price_missing' | 'over_cap' | 'unpriced' | 'unpriced_override'; code: ResultCodeName | null;
|
||
unpriced_reason: string | null; unpriced_override: string | null; excluded_billing: readonly string[];
|
||
}
|
||
export interface PlanSource { text: Buffer | null; file: string | null; source: GateReceipt['plan']['source']; repoPath: string | null; why: string | null }
|
||
export interface PriceGateOptions { repoRoot: string; gate: string; plan: PlanSource; capUsd?: number; smokeCapUsd?: number; allowUnpriced?: string; now?: Date }
|
||
export interface PriceGateResult { lines: string[]; receipt: GateReceipt | null; error?: { code: ResultCodeName; message: string } }
|
||
|
||
const sha256 = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
|
||
function gitOut(repoRoot: string, args: string[]): Buffer | null {
|
||
const r = spawnSync('git', args, { cwd: repoRoot, timeout: 30_000 });
|
||
return r.status === 0 ? r.stdout : null;
|
||
}
|
||
|
||
export interface PlanSourceRefusal { code: ResultCodeName; message: string; refused: boolean }
|
||
|
||
/**
|
||
* `--plan-file` wins; else the base policy's `price_file`, read from the HEAD
|
||
* commit's bytes. Only "no plan declared" is unpriced (overridable); a declared
|
||
* file missing at HEAD is stale, an unresolvable base or an invalid policy refuses.
|
||
*/
|
||
export function resolvePlanSource(repoRoot: string, o: { planFile?: string; base: string }): PlanSource | PlanSourceRefusal {
|
||
if (o.planFile) {
|
||
const file = path.resolve(o.planFile);
|
||
const rel = path.relative(repoRoot, file);
|
||
return { text: fs.readFileSync(file), file, source: 'flag', repoPath: rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : null, why: null };
|
||
}
|
||
const load = loadConfigFromBase(repoRoot, o.base);
|
||
if (isConfigUnavailable(load)) return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `no --plan-file and the base policy cannot be read (${load.detail})`, refused: true };
|
||
if (load.errors.length) return { code: 'PREGATE_CONFIG_INVALID', message: `${load.label}: ${load.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, refused: true };
|
||
const rel = load.config.price_file;
|
||
if (!rel) return { text: null, file: null, source: 'none', repoPath: null, why: `no --plan-file and ${load.label} declares no price_file` };
|
||
const text = gitOut(repoRoot, ['show', `HEAD:${rel}`]);
|
||
if (!text) return { code: 'EVAL_PLAN_STALE', message: `price_file ${rel} (declared in ${load.label}) is not in the HEAD commit`, refused: false };
|
||
return { text, file: rel, source: 'price_file', repoPath: rel, why: null };
|
||
}
|
||
|
||
/** Stale binding: the gate command, the head (only the plan file itself may change after it) and every input hash at HEAD. */
|
||
function checkBinding(o: PriceGateOptions, plan: EvalCallPlan, headSha: string): string | null {
|
||
if (plan.gate !== o.gate) return `gate command differs: --gate ${JSON.stringify(o.gate)} vs plan ${JSON.stringify(plan.gate)}`;
|
||
if (plan.head !== headSha) {
|
||
const diff = gitOut(o.repoRoot, ['diff', '--name-only', plan.head, headSha]);
|
||
if (diff === null) return `declared head ${plan.head.slice(0, 12)} is not a commit in this repository (HEAD ${headSha.slice(0, 12)})`;
|
||
const changed = diff.toString('utf8').split('\n').filter(Boolean);
|
||
if (changed.some(f => f !== o.plan.repoPath)) return `declared head ${plan.head.slice(0, 12)} is behind HEAD ${headSha.slice(0, 12)}: ${changed.length} file(s) changed since (${changed.slice(0, 3).join(', ')})`;
|
||
}
|
||
for (const input of plan.inputs) {
|
||
const bytes = gitOut(o.repoRoot, ['show', `HEAD:${input.path}`]);
|
||
if (!bytes) return `input ${input.path} is not in the HEAD commit`;
|
||
const got = sha256(bytes);
|
||
if (got !== input.sha256) return `input ${input.path} sha256 ${got.slice(0, 12)} at HEAD != declared ${input.sha256.slice(0, 12)}`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function gstackRevision(): string {
|
||
const root = path.resolve(import.meta.dir, '..');
|
||
const version = fs.existsSync(path.join(root, 'VERSION')) ? fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim() : 'unknown';
|
||
const sha = gitOut(root, ['rev-parse', '--short', 'HEAD'])?.toString('utf8').trim();
|
||
return sha ? `${version} (${sha})` : version;
|
||
}
|
||
|
||
const amount = (v: number | null) => (v === null ? 'unknown' : String(+v.toFixed(6)));
|
||
|
||
function priceRows(rows: EvalCallRow[]): PricedCallRow[] {
|
||
return rows.map(r => {
|
||
if (!hasPricing(r.model)) return { ...r, usd: null, reservation_usd: null };
|
||
return { ...r, usd: estimateCostUsd({ input: r.in_tokens, output: r.out_tokens }, r.model), reservation_usd: estimateCostUsd({ input: Math.ceil(r.in_tokens / r.calls), output: r.max_out_tokens }, r.model) };
|
||
});
|
||
}
|
||
|
||
/** Price a paid gate from its call-plan file. Malformed or stale files return an error and no receipt; every other outcome returns a receipt. */
|
||
export function priceGate(o: PriceGateOptions): PriceGateResult {
|
||
const headSha = gitOut(o.repoRoot, ['rev-parse', 'HEAD'])?.toString('utf8').trim() ?? '';
|
||
let plan: EvalCallPlan | null = null;
|
||
if (o.plan.text) {
|
||
const parsed = parseEvalCalls(o.plan.text.toString('utf8'));
|
||
if ('error' in parsed) return { lines: [], receipt: null, error: { code: 'EVAL_DRY_RUN_FAILED', message: `${o.plan.file}: ${parsed.error}` } };
|
||
const stale = checkBinding(o, parsed.plan, headSha);
|
||
if (stale) return { lines: [], receipt: null, error: { code: 'EVAL_PLAN_STALE', message: `${o.plan.file}: ${stale}` } };
|
||
plan = parsed.plan;
|
||
}
|
||
const rows = priceRows(plan?.rows ?? []);
|
||
const total = rows.some(r => r.usd === null) ? null : +rows.reduce((s, r) => s + r.usd!, 0).toFixed(6);
|
||
const lines = [
|
||
...(plan ? [`GATE_PLAN: file=${o.plan.file} source=${o.plan.source} sha256=${sha256(o.plan.text!)} head=${plan.head.slice(0, 12)}${plan.head === headSha ? '' : ` (HEAD ${headSha.slice(0, 12)}; only the plan file changed since)`} inputs=${plan.inputs.length} verified`] : []),
|
||
...(plan?.preconditions ?? []).map(t => `GATE_PRECONDITION: ${t}`),
|
||
...rows.map(r => `GATE_CALLS: purpose=${r.purpose} model=${r.model} calls=${r.calls} usd=${amount(r.usd)} reservation_usd=${amount(r.reservation_usd)} scope=${r.scope}`),
|
||
];
|
||
let error: PriceGateResult['error'];
|
||
let result: GateReceipt['result'] = 'priced';
|
||
const unpricedWhy = !plan ? o.plan.why ?? 'no call-plan file' : rows.length === 0 ? `${o.plan.file} has no GSTACK_EVAL_CALLS lines` : null;
|
||
if (unpricedWhy) {
|
||
result = o.allowUnpriced ? 'unpriced_override' : 'unpriced';
|
||
if (o.allowUnpriced) lines.push(`GATE_UNPRICED_OVERRIDE: ${o.allowUnpriced} (owner-authorized override; not authorization to spend)`);
|
||
else error = { code: 'EVAL_GATE_UNPRICED', message: unpricedWhy };
|
||
} else if (total === null) {
|
||
result = 'price_missing';
|
||
error = { code: 'EVAL_PRICE_MISSING', message: `no price row for ${rows.filter(r => r.usd === null).map(r => r.model).join(', ')}; the total is unknown, never zero` };
|
||
} else {
|
||
const over: string[] = [];
|
||
if (o.capUsd !== undefined || o.smokeCapUsd !== undefined) {
|
||
for (const r of rows) {
|
||
const smoke = r.scope === 'smoke' && o.smokeCapUsd !== undefined && r.reservation_usd! > o.smokeCapUsd;
|
||
const full = o.capUsd !== undefined && r.reservation_usd! > o.capUsd;
|
||
if (!smoke && !full) continue;
|
||
const why = `$${r.reservation_usd!.toFixed(4)} > ${smoke ? `smoke cap $${o.smokeCapUsd!.toFixed(2)}` : `cap $${o.capUsd!.toFixed(2)}`}`;
|
||
lines.push(`CHECK: reservation-fits-cap exceeds (${why}) purpose=${r.purpose}`);
|
||
over.push(`reservation-fits-cap: purpose=${r.purpose} one call ${why}`);
|
||
}
|
||
if (!over.length) lines.push(`CHECK: reservation-fits-cap ok (largest one-call reservation $${Math.max(0, ...rows.map(r => r.reservation_usd!)).toFixed(4)})`);
|
||
}
|
||
if (o.capUsd !== undefined && total > o.capUsd) over.unshift(`total $${total.toFixed(4)} > cap $${o.capUsd.toFixed(2)}`);
|
||
if (over.length) { result = 'over_cap'; error = { code: 'EVAL_GATE_OVER_CAP', message: over.join('; ') }; }
|
||
}
|
||
const receipt: GateReceipt = {
|
||
schema_version: 1, kind: 'gstack-eval-price-gate', created_at: (o.now ?? new Date()).toISOString(), gstack_revision: gstackRevision(),
|
||
gate_command: o.gate, gate_sha256: sha256(o.gate), head_sha: headSha,
|
||
plan: { file: o.plan.file, source: o.plan.source, sha256: o.plan.text ? sha256(o.plan.text) : null, declared_head: plan?.head ?? null },
|
||
inputs: plan?.inputs ?? [], preconditions: plan?.preconditions ?? [], rows,
|
||
total_usd: unpricedWhy ? null : total, calls: rows.reduce((s, r) => s + r.calls, 0), purposes: rows.map(r => r.purpose),
|
||
cap_usd: o.capUsd ?? null, smoke_cap_usd: o.smokeCapUsd ?? null, result, code: error?.code ?? (unpricedWhy ? 'EVAL_GATE_UNPRICED' : null),
|
||
unpriced_reason: unpricedWhy, unpriced_override: unpricedWhy ? o.allowUnpriced ?? null : null, excluded_billing: EXCLUDED_BILLING_CATEGORIES,
|
||
};
|
||
return { lines, receipt, error };
|
||
}
|
||
|
||
export function renderGatePrice(r: GateReceipt, receiptPath: string): string {
|
||
return `GATE_PRICE: total=${amount(r.total_usd)} calls=${r.calls} purposes=${r.purposes.join(',') || 'none'} receipt=${receiptPath}`;
|
||
}
|