Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion packages/amico-run/src/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down Expand Up @@ -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<string, { receipts: number; cpu_seconds: number; gpu_seconds: number; cost_usd: number }>;
by_lab: Record<string, { receipts: number; cpu_seconds: number; gpu_seconds: number; cost_usd: number }>;
}

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
Expand Down
33 changes: 32 additions & 1 deletion packages/amico-run/src/local_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/amico-run/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lab-id>/
julia?: JuliaOpts;
graceMs?: number; // abort SIGTERM→SIGKILL grace; default 5000. Test knob, NOT exposed in the CLI.
Expand Down
6 changes: 4 additions & 2 deletions packages/amico-run/test/local_executor_ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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([]);
});
});
22 changes: 19 additions & 3 deletions packages/schema/schemas/ledger-record.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down Expand Up @@ -882,7 +882,11 @@
"minLength": 1
},
"executor": {
"const": "remote"
"enum": [
"local",
"modal",
"remote"
]
},
"gpu_sku": {
"type": "string",
Expand All @@ -909,8 +913,20 @@
"failed",
"aborted"
]
},
"cpu_seconds": {
"type": "number",
"exclusiveMinimum": 0
},
"principal": {
"type": "string",
"minLength": 1
},
"lab": {
"type": "string",
"minLength": 1
}
}
}
]
}
}
Loading