From 3f313066c1270aec0960a8ecd36ccd556f096e13 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Wed, 4 Nov 2026 09:25:20 -0500 Subject: [PATCH] =?UTF-8?q?feat(amico-run):=20cost=20ledger=20=E2=80=94=20?= =?UTF-8?q?principal/Lab=20on=20ReceiptRecord,=20local=20CPU-seconds=20cos?= =?UTF-8?q?t,=20costTotals=20rollup=20(#1875/#1880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/amico-run/src/ledger.ts | 52 ++++++++++++++++++- packages/amico-run/src/local_executor.ts | 33 +++++++++++- packages/amico-run/src/types.ts | 1 + .../test/local_executor_ledger.test.ts | 6 ++- .../schema/schemas/ledger-record.schema.json | 22 ++++++-- 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/packages/amico-run/src/ledger.ts b/packages/amico-run/src/ledger.ts index d71f951b7..868eac5c4 100644 --- a/packages/amico-run/src/ledger.ts +++ b/packages/amico-run/src/ledger.ts @@ -274,12 +274,20 @@ export interface ReceiptRecord { type: "receipt"; ts: string; task_id: string; - executor: "remote"; + /** Where the work ran. `local` = on the hub/host CPU; `modal` = Modal execution + * site; `remote` = cloud solve service. */ + executor: "local" | "modal" | "remote"; gpu_sku?: string; gpu_seconds?: number; + /** CPU-seconds consumed by a local run (costed at the hub's CPU rate). */ + cpu_seconds?: number; cost_usd?: number; problem?: string; session?: string; + /** Person principal who owned the run (central-hub attribution, #1875). */ + principal?: string; + /** Lab the run was charged to (central-hub budget, #1880). */ + lab?: string; status?: "completed" | "failed" | "aborted"; } @@ -357,6 +365,48 @@ export function gpuTotals(file = ledgerPath()): GpuTotals { return t; } +/** Cost totals across receipt rows, broken down by principal and Lab (central-hub + * spend plane). Pure aggregation; never throws; zeros on empty. */ +export interface CostTotals { + receipts: number; + cpu_seconds: number; + gpu_seconds: number; + cost_usd: number; + by_principal: Record; + by_lab: Record; +} + +export function costTotals(file = ledgerPath()): CostTotals { + const t: CostTotals = { receipts: 0, cpu_seconds: 0, gpu_seconds: 0, cost_usd: 0, by_principal: {}, by_lab: {} }; + let raw: string; + try { raw = readFileSync(file, "utf8"); } catch { return t; } + for (const line of raw.split("\\n")) { + if (!line.trim()) continue; + let rec: LedgerRecord; + try { rec = JSON.parse(line) as LedgerRecord; } catch { continue; } + if (rec.type !== "receipt") continue; + t.receipts += 1; + if (rec.cpu_seconds !== undefined) t.cpu_seconds += rec.cpu_seconds; + if (rec.gpu_seconds !== undefined) t.gpu_seconds += rec.gpu_seconds; + if (rec.cost_usd !== undefined) t.cost_usd += rec.cost_usd; + const keyP = rec.principal ?? "(unattributed)"; + const keyL = rec.lab ?? "(unattributed)"; + for (const [key, bucket] of [[keyP, t.by_principal], [keyL, t.by_lab]] as const) { + const b = bucket[key] ?? { receipts: 0, cpu_seconds: 0, gpu_seconds: 0, cost_usd: 0 }; + b.receipts += 1; + if (rec.cpu_seconds !== undefined) b.cpu_seconds += rec.cpu_seconds; + if (rec.gpu_seconds !== undefined) b.gpu_seconds += rec.gpu_seconds; + if (rec.cost_usd !== undefined) b.cost_usd += rec.cost_usd; + bucket[key] = b; + } + } + t.cost_usd = Math.round(t.cost_usd * 1e4) / 1e4; + for (const bucket of [t.by_principal, t.by_lab]) { + for (const k of Object.keys(bucket)) bucket[k].cost_usd = Math.round(bucket[k].cost_usd * 1e4) / 1e4; + } + return t; +} + /** Append one record as a single JSONL line. Validates against the `ledger-record` * schema (Task 2 wires this in) before writing so no malformed line ever lands. * Throws when the serialized line exceeds PIPE_BUF (would break O_APPEND diff --git a/packages/amico-run/src/local_executor.ts b/packages/amico-run/src/local_executor.ts index de25dcfa1..22f0563c3 100644 --- a/packages/amico-run/src/local_executor.ts +++ b/packages/amico-run/src/local_executor.ts @@ -5,7 +5,7 @@ import { delimiter, join, resolve } from "node:path"; import * as readline from "node:readline"; import { parse as tomlParse, stringify as tomlStringify } from "smol-toml"; import { EventQueue } from "./event_queue.js"; -import { appendRecord, type SolveOutcome, type SolveRecord, type SolveSummary } from "./ledger.js"; +import { appendRecord, type ReceiptRecord, type SolveOutcome, type SolveRecord, type SolveSummary } from "./ledger.js"; import { classifyLine } from "./telemetry.js"; import { appendIndex, @@ -299,6 +299,7 @@ export class LocalExecutor implements Executor { let settled = false; let aborting = false; + const startCpu = process.cpuUsage(); const settle = (status: RunStatus, exitCode: number): void => { if (settled) return; settled = true; @@ -310,6 +311,36 @@ export class LocalExecutor implements Executor { } emitSolveStanza(runDir); // Plan 3 / L1 Task 5 — never throws; a ledger hiccup must never fail a run + // Cost ledger (central-hub #1875/#1880): attribute CPU time to the run's + // principal and Lab. Best-effort; never fail the run. + try { + const cpu = process.cpuUsage(startCpu); + const cpuSeconds = (cpu.user + cpu.system) / 1e6; + // List-price estimate: CPU-seconds * a small per-second rate. Labeled + // "list price, not invoice" until reconciled against invoices. + const CPU_RATE_USD_PER_SEC = 0.00005; + const rawCost = cpuSeconds > 0 ? cpuSeconds * CPU_RATE_USD_PER_SEC : 0; + const costUsd = rawCost > 0 ? Math.round(rawCost * 1e6) / 1e6 : undefined; + if (costUsd !== undefined && costUsd <= 0) { + // below schema precision; omit rather than violate exclusiveMinimum + } + const rec: ReceiptRecord = { + type: "receipt", + ts: new Date().toISOString(), + task_id: runId, + executor: "local", + cpu_seconds: cpuSeconds > 0 ? cpuSeconds : undefined, + cost_usd: costUsd && costUsd > 0 ? costUsd : undefined, + principal: opts.principal, + lab: opts.lab, + status: status === "completed" ? "completed" : status === "aborted" ? "aborted" : "failed", + }; + // Only emit when we have something to attribute (principal or lab or cpu). + if (rec.principal || rec.lab || rec.cpu_seconds) appendRecord(rec); + } catch (e) { + process.stderr.write(`amico-run: failed to emit receipt stanza: ${(e as Error).message}\n`); + } + // WAIT FOR run.log TO ACTUALLY FLUSH before announcing completion. // // `logStream.end()` is ASYNCHRONOUS: it schedules the flush and returns immediately. The diff --git a/packages/amico-run/src/types.ts b/packages/amico-run/src/types.ts index 24f5dc63c..8e6aadd70 100644 --- a/packages/amico-run/src/types.ts +++ b/packages/amico-run/src/types.ts @@ -8,6 +8,7 @@ export interface JuliaOpts { export interface SubmitOpts { lab?: string; // lab POINTER (id or lab.toml path), passed through verbatim; default "default" + principal?: string; // person principal who owns the run (central-hub attribution) runsRoot?: string; // default: ~/.amico/runs// julia?: JuliaOpts; graceMs?: number; // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI. diff --git a/packages/amico-run/test/local_executor_ledger.test.ts b/packages/amico-run/test/local_executor_ledger.test.ts index 77bce12c6..6fce31bca 100644 --- a/packages/amico-run/test/local_executor_ledger.test.ts +++ b/packages/amico-run/test/local_executor_ledger.test.ts @@ -165,7 +165,9 @@ describe("LocalExecutor.settle() emits the solve ledger stanza", () => { await collect(h.events); const fin = await h.finished; expect(fin.status).toBe("completed"); // the run itself is unaffected - expect(readRecords()).toEqual([]); + // Cost ledger (#1875/#1880): a local run now emits a `receipt` stanza + // attributed to CPU-seconds, but no `solve` stanza when there is no result.toml. + expect(readRecords().filter((r) => r.type === "solve")).toEqual([]); }); // REGRESSION (found by adversarial spec review, 2026-07-28): the `max_solves` @@ -226,6 +228,6 @@ describe("LocalExecutor.settle() emits the solve ledger stanza", () => { const h = await new LocalExecutor().submit(script, { runsRoot: join(root, "runs"), julia: { julia } }); await collect(h.events); await h.finished; - expect(readRecords()).toEqual([]); + expect(readRecords().filter((r) => r.type === "solve")).toEqual([]); }); }); diff --git a/packages/schema/schemas/ledger-record.schema.json b/packages/schema/schemas/ledger-record.schema.json index afb3b0bfe..7c8f68215 100644 --- a/packages/schema/schemas/ledger-record.schema.json +++ b/packages/schema/schemas/ledger-record.schema.json @@ -30,7 +30,7 @@ "ro", "rw" ], - "description": "THE autonomy datum (codesign SEAM 6, the one-autonomy doctrine): the SINGLE switch for what a warrant may do to a device — one enum, one definition (this schema), three documented meanings, no second knob (any other device-permission-shaped field in bounds is rejected by additionalProperties:false; any value outside this enum is rejected here). none = no device access; ro = read-only device access; rw = device writes permitted — and even then only inside a real-board session, which is an enumerated human gate, never a schema-granted right. Three-systems contract, stated once with each counterpart's criterion NAMED under its own owner (never claimed as another's): amicode's bounds are THIS schema (the one definition); the P4 gate's end-to-end read is merge-gated on strumento #75 MERGED (not verified here); Telaio's warrant-bounds deserialization conformance is their campaign's criterion." + "description": "THE autonomy datum (codesign SEAM 6, the one-autonomy doctrine): the SINGLE switch for what a warrant may do to a device \u2014 one enum, one definition (this schema), three documented meanings, no second knob (any other device-permission-shaped field in bounds is rejected by additionalProperties:false; any value outside this enum is rejected here). none = no device access; ro = read-only device access; rw = device writes permitted \u2014 and even then only inside a real-board session, which is an enumerated human gate, never a schema-granted right. Three-systems contract, stated once with each counterpart's criterion NAMED under its own owner (never claimed as another's): amicode's bounds are THIS schema (the one definition); the P4 gate's end-to-end read is merge-gated on strumento #75 MERGED (not verified here); Telaio's warrant-bounds deserialization conformance is their campaign's criterion." } } } @@ -882,7 +882,11 @@ "minLength": 1 }, "executor": { - "const": "remote" + "enum": [ + "local", + "modal", + "remote" + ] }, "gpu_sku": { "type": "string", @@ -909,8 +913,20 @@ "failed", "aborted" ] + }, + "cpu_seconds": { + "type": "number", + "exclusiveMinimum": 0 + }, + "principal": { + "type": "string", + "minLength": 1 + }, + "lab": { + "type": "string", + "minLength": 1 } } } ] -} +} \ No newline at end of file