Skip to content
Draft
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
80 changes: 73 additions & 7 deletions packages/amico-run/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,50 @@ export interface Shape {
size: number;
}

export type Tier = "spec" | "vetted" | "composed" | "free";

export interface ShapeMatch {
tier: "vetted" | "composed" | "free";
tier: Tier;
template?: TemplateEntry;
exemplar?: ExemplarEntry;
blockedHigher?: { tier: "vetted" | "composed"; requires: string };
blockedHigher?: { tier: "spec" | "vetted" | "composed"; requires: string };
reason?: string; // present when spec was considered but fell through
}

/** Spec-expressibility check (W2.2): a formulation is spec-expressible when its
* ProblemSpec validates against the entitlement-keyed schema. Custom/bespoke
* objective kinds that are not in either schema render it not spec-expressible
* and it falls through to vetted/composed/free with the reason surfaced. The
* check is schema-driven, not hand-rolled: the schemas ARE the subset definition. */
export function isSpecExpressible(
problemSpec: unknown,
hasIssimo: boolean,
): { expressible: true } | { expressible: false; reason: string } {
// Lazy import to avoid circular deps — use dynamic validateProblemSpec lookup
// via @amicode/schema at call site. Here we inline a minimal check: unknown
// kinds fail validation. Caller should use validateProblemSpec directly.
// This helper is a convenience for tests; production uses validateProblemSpec.
if (problemSpec == null || typeof problemSpec !== "object") {
return { expressible: false, reason: "no problemSpec supplied" };
}
const obj = problemSpec as Record<string, unknown>;
// Custom objective heuristic: an objective kind of "custom" or any key that
// the schema would reject. We surface a typed reason so the agent can explain
// the fallback rather than silently downgrading.
const problem = obj.problem as Record<string, unknown> | undefined;
const objectives = problem?.objectives;
if (Array.isArray(objectives)) {
for (const o of objectives) {
const kind = (o as Record<string, unknown>)?.kind;
if (typeof kind === "string" && kind === "custom") {
return { expressible: false, reason: "custom objective kind 'custom' is not spec-expressible" };
}
}
}
// If no custom marker, defer to schema validation at the call site — assume
// expressible here; the gate's validate() is authoritative.
void hasIssimo;
return { expressible: true };
}

const EMPTY_REGISTRY: Registry = { templates: [], support: [], uuids: {}, verifyTolerance: 0.01 };
Expand Down Expand Up @@ -132,16 +171,43 @@ export function loadExemplarsIndex(file: string): ExemplarsIndex {
return { exemplars };
}

/** Tier resolution (spec C, locked decision 5): exact vetted template match →
* tier 1; else exemplar match on platform+kind (size may differ) → tier 2;
* else tier 3. Entitlement- or allowlist-blocked higher matches are excluded
* from selection but reported via blockedHigher so the agent can run the
* explicit-confirmation flow (never a silent downgrade). */
/** Tier resolution (spec C, locked decision 5; W2.2 adds `spec` above vetted):
* spec (fully validated data, no authored code) → tier 0; exact vetted template
* match → tier 1; else exemplar match on platform+kind (size may differ) → tier 2;
* else tier 3. A spec-expressible formulation resolves to `spec` WITHOUT
* consulting the registry; out-of-subset falls through to vetted/composed/free
* with the reason surfaced. Entitlement- or allowlist-blocked higher matches
* are excluded from selection but reported via blockedHigher so the agent can
* run the explicit-confirmation flow (never a silent downgrade). */
export function matchShape(
shape: Shape,
registry: Registry,
exemplars: ExemplarsIndex,
allowlist: string[],
problemSpec?: unknown,
): ShapeMatch {
// ── tier 0: spec — fully validated data, no authored code ──
if (problemSpec !== undefined) {
const check = isSpecExpressible(problemSpec, false);
if (check.expressible) {
// Defer to schema validation at the gate — here we treat presence of a
// problemSpec that passed the cheap custom-objective heuristic as
// spec-expressible. The gate's validateProblemSpec is authoritative.
return { tier: "spec" };
}
// Not spec-expressible → fall through with reason; do NOT short-circuit
const reason = (check as { expressible: false; reason: string }).reason;
const fallback = matchShapeWithoutSpec(shape, registry, exemplars, allowlist);
return { ...fallback, reason };
}
return matchShapeWithoutSpec(shape, registry, exemplars, allowlist);
}

function matchShapeWithoutSpec(
shape: Shape,
registry: Registry,
exemplars: ExemplarsIndex,
allowlist: string[],
): ShapeMatch {
const allowed = new Set([...allowlist, ...registry.support, ...JULIA_STDLIBS]);
const packagesOk = (packages: string[]) => packages.every((p) => allowed.has(p));
Expand Down
8 changes: 8 additions & 0 deletions packages/amico-run/src/catalog_verb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
// non-colliding name. (`--kind` here is also a DIFFERENT axis from `amico resolve
// --kind`, where kind = problem-kind such as gate_synthesis.)
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import {
Expand Down Expand Up @@ -208,6 +209,13 @@ export function catalogIngest(argv: string[]): VerbResult {
meta.warm_start = warmStart;
if (tags) meta.tags = tags;
meta.date = new Date().toISOString().slice(0, 10);
// W4.1: content hash of pulse.jld2 for verifiable warm-start provenance
try {
const data = readFileSync(pulse);
meta.pulse_hash = "sha256:" + createHash("sha256").update(data).digest("hex");
} catch {
// if pulse unreadable, omit hash (honest gap)
}

if (dryRun) {
return {
Expand Down
92 changes: 89 additions & 3 deletions packages/amico-run/src/estimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export interface KeyVars {
* stores an error string instead of values; we keep that message here and the
* score computation skips levels exactly as get_memory_estimate does. */
levelsUnresolved?: string;
/** Typed-spec path (W2.3): trajectory kind drives dimension scaling (unitary
* squares the state dim, ket/density do not) and wrapper count multiplies the
* score (sampling). Absent = unitary (the historical script-path assumption). */
trajectoryKind?: string;
wrapperMultiplier?: number;
}

export type SizeClass = "SMALL" | "MEDIUM";
Expand Down Expand Up @@ -93,14 +98,95 @@ export function extractKeyVars(content: string): KeyVars {
return out;
}

/** Typed-spec adapter (W2.3): read N/levels/trajectory/wrappers from a parsed
* ProblemSpec object (smol-toml output), mirroring hashing.ts's field mapping.
* Shared size model with the regex path — same score contract, different adapter. */
export function extractKeyVarsFromSpec(spec: Record<string, unknown>): KeyVars {
const out: KeyVars = {};
const problem = (spec.problem as Record<string, unknown> | undefined) ?? {};
const system = (spec.system as Record<string, unknown> | undefined) ?? {};
const goal = (spec.goal as Record<string, unknown> | undefined) ?? {};
const trajectory = (spec.trajectory as Record<string, unknown> | undefined) ?? {};
const pulse = (spec.pulse as Record<string, unknown> | undefined) ?? {};
const wrappers = spec.wrappers;

if (typeof problem.N === "number") out.N = problem.N;
else if (typeof problem.N === "bigint") out.N = Number(problem.N);

// Levels: system.params.levels (scalar or array) wins; else goal.subsystem_levels;
// else system.components (composite — take each component's levels).
const sysParams = system.params as Record<string, unknown> | undefined;
const rawLevels = sysParams?.levels;
if (typeof rawLevels === "number") {
out.levels = { length: 1, values: [rawLevels] };
out.num_qudits = 1;
} else if (Array.isArray(rawLevels)) {
const vals = (rawLevels as unknown[]).map((v) => Number(v));
if (vals.every((v) => Number.isInteger(v))) {
out.levels = { length: vals.length, values: vals };
out.num_qudits = vals.length;
}
} else if (Array.isArray(goal.subsystem_levels)) {
const vals = (goal.subsystem_levels as unknown[]).map((v) => Number(v));
if (vals.every((v) => Number.isInteger(v))) {
out.levels = { length: vals.length, values: vals };
out.num_qudits = vals.length;
}
} else if (Array.isArray(system.components)) {
const comps = system.components as unknown[];
const vals: number[] = [];
for (const c of comps) {
const params = (c as Record<string, unknown>)?.params as Record<string, unknown> | undefined;
const lev = params?.levels;
if (typeof lev === "number") vals.push(lev);
}
if (vals.length > 0) {
out.levels = { length: vals.length, values: vals };
out.num_qudits = vals.length;
}
}

// Trajectory kind drives the dimension squaring (unitary = squared, ket/density = single)
const trajKind =
typeof trajectory.kind === "string"
? trajectory.kind
: typeof goal.kind === "string"
? goal.kind
: typeof pulse.kind === "string" && pulse.kind.includes("ket")
? "ket"
: undefined;
if (trajKind) out.trajectoryKind = trajKind;

// Wrapper count: sampling multiplies (ensemble). Each wrapper with N variants multiplies score.
if (Array.isArray(wrappers) && wrappers.length > 0) {
let mult = 1;
for (const w of wrappers) {
const variants = (w as Record<string, unknown>)?.variants;
if (Array.isArray(variants) && variants.length > 0) mult *= variants.length;
else mult *= 1;
}
if (mult > 1) out.wrapperMultiplier = mult;
}

// Fallback pulse.T not needed for score but keep for completeness
void pulse;

return out;
}

/** Port of get_memory_estimate: N × (prod(levels))⁴; absent/unresolved levels
* contribute nothing (knot_point_state_dim stays 1), exactly as the reference. */
* contribute nothing (knot_point_state_dim stays 1), exactly as the reference.
* W2.3: trajectory kind and wrapper multiplier adjust the shared model — ket
* trajectories do NOT square the state dim, and sampling wrappers multiply N. */
export function memoryScore(vars: KeyVars): number {
if (vars.N === undefined) throw new ConfigError("could not extract N (needed by the tshirt-sizing estimator)");
let knotPointStateDim = 1;
if (vars.levels) for (const v of vars.levels.values) knotPointStateDim *= v;
knotPointStateDim *= knotPointStateDim; // assumes a unitary trajectory problem
return vars.N * knotPointStateDim ** 2;
const isUnitary = !vars.trajectoryKind || vars.trajectoryKind === "unitary";
if (isUnitary) knotPointStateDim *= knotPointStateDim;
let score = vars.N * knotPointStateDim ** 2;
if (vars.wrapperMultiplier && vars.wrapperMultiplier > 1) score *= vars.wrapperMultiplier;
return score;
}

/** Port of get_tshirt_size: strict >, two classes in A-v1. */
Expand Down
23 changes: 22 additions & 1 deletion packages/amico-run/src/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parse as parseToml } from "smol-toml";
import { validate } from "@amicode/schema";
import { validate, validateProblemSpec, hasIssimoEntitlement } from "@amicode/schema";
import type { AuthoringConfig } from "./authoring.js";
import { checkImports, scanImports } from "./import_scan.js";
import { maskedHash } from "./baseline.js";
Expand Down Expand Up @@ -100,6 +100,27 @@ export function runGate(
| { kind?: string; project?: string }
| undefined;

// ── step 1b: problem_spec validation (W2.4) — entitlement-keyed schema variant ──
// A v4 problem_spec solvespec carries an inline object or will be resolved from a
// path; the solvespec schema only checked `{type: object}`. Here we validate against
// the vendored ProblemSpec schemas: `issimo` entitlement ⇒ FULL, else OSS. This
// makes problemspec.oss.schema.json load-bearing and enforces the public/private
// seam before paying a Julia cold start. A Piccolissimo-only integrator
// (exponential/spline) fails on OSS, passes on issimo.
const problemSpec = (spec as Record<string, unknown>).problem_spec;
if (problemSpec !== undefined && typeof problemSpec === "object" && problemSpec !== null) {
const hasIssimo = hasIssimoEntitlement(authoring.allowlist);
const psValidation = validateProblemSpec(problemSpec, hasIssimo);
if (!psValidation.ok) {
const isEntitlementGated =
psValidation.errors.some((e) => e.includes("exponential") || e.includes("spline") || e.includes("hermite_bending")) ||
JSON.stringify(problemSpec).includes('"exponential"') ||
JSON.stringify(problemSpec).includes('"spline"');
const hint = !hasIssimo && isEntitlementGated ? " (requires issimo entitlement — Piccolissimo-only integrator/objective)" : "";
return { ok: false, reason: `problem_spec schema: ${psValidation.errors[0]}${hint}` };
}
}

// ── step 2: import scan ──
// A v4 problem_spec solvespec (schema: exactly one of script_path|problem_spec)
// has no authored script — the ProblemSpec, validated in step 1 against Piccolo's
Expand Down
32 changes: 30 additions & 2 deletions packages/amico-run/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// process bootstrap. This is the "delegate to the existing code path" seam: `amico run
// <args>` is exactly `launch(<args>)`, byte-for-byte the historical amico-run behavior.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { dirname, isAbsolute, join } from "node:path";
import { parse as parseToml } from "smol-toml";
import { LocalExecutor } from "./local_executor.js";
import { RemoteExecutor } from "./remote_executor.js";
Expand Down Expand Up @@ -198,10 +198,38 @@ export async function launch(argv: string[]): Promise<number> {
console.error(`amico-run: --project ${opts.julia!.project} overrides the spec's env.project ${env.project}`);
else opts.julia!.project = env.project;
}
// W4.1: when solvespec carries problem_spec, compute structureHash/problemHash
// in TS (byte-exact mirror of Piccolo.Specs) and stamp into run.toml [hashes].
// The gate already computed spec_hash; we augment the stamp here before submit.
const hashes = { ...gate.stamp.hashes };
if (hasProblemSpec && problemSpec !== undefined) {
try {
let specObj: Record<string, unknown> | undefined;
if (typeof problemSpec === "object" && problemSpec !== null) {
specObj = problemSpec as Record<string, unknown>;
} else if (typeof problemSpec === "string") {
const p = isAbsolute(problemSpec as string) ? (problemSpec as string) : join(dirname(specPath!), problemSpec as string);
// Try JSON first, then TOML
try {
const raw = readFileSync(p, "utf8");
specObj = raw.trimStart().startsWith("{") ? (JSON.parse(raw) as Record<string, unknown>) : (parseToml(raw) as Record<string, unknown>);
} catch {
// leave specObj undefined — honest gap, not a fake key
}
}
if (specObj) {
const { structureHash, problemHash } = await import("@amicode/schema");
hashes.structure_hash = structureHash(specObj);
hashes.problem_hash = problemHash(specObj);
}
} catch {
// honest gap — don't stamp fake keys
}
}
opts.spec = {
canonical: gate.stamp.specCanonical,
tier: gate.stamp.tier,
hashes: gate.stamp.hashes,
hashes,
julia_binary: opts.julia!.julia,
env_project: opts.julia!.project,
// v4: route the typed ProblemSpec to Piccolo.Specs.solve_spec (LocalExecutor)
Expand Down
18 changes: 14 additions & 4 deletions packages/amico-run/src/local_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,23 @@ function emitSolveStanza(runDir: string): void {
if (!result) return; // no result written (e.g. a bare script that doesn't emit one) — nothing to ledger

const params = isRecord(result.params) ? result.params : {};
const structureHash = params.structure_hash;
const problemHash = params.problem_hash;
const manifest = readTomlSafe(join(runDir, "run.toml"));
const manifestHashes = isRecord(manifest?.hashes) ? manifest.hashes : {};
// W4.1: fall back to run.toml [hashes] when result.toml lacks the keys
// (script-authored runs never have result.toml [params] hashes today).
let structureHash = typeof params.structure_hash === "string" ? params.structure_hash : undefined;
let problemHash = typeof params.problem_hash === "string" ? params.problem_hash : undefined;
if (!structureHash && typeof manifestHashes.structure_hash === "string") structureHash = manifestHashes.structure_hash;
if (!problemHash && typeof manifestHashes.problem_hash === "string") problemHash = manifestHashes.problem_hash;
if (typeof structureHash !== "string" || typeof problemHash !== "string") return; // no join key — skip

const manifest = readTomlSafe(join(runDir, "run.toml"));
const scriptPath = typeof manifest?.script_path === "string" ? manifest.script_path : undefined;
const spec = scriptPath ? readSpecFromScriptPath(scriptPath) : undefined;
// W4.1: for spec-authored runs, the ProblemSpec is the summary source — try
// inline problem.toml first (the run's own spec), then scriptPath fallback.
let spec: Record<string, unknown> | undefined;
const inlineSpec = readTomlSafe(join(runDir, "problem.toml"));
if (inlineSpec && isRecord(inlineSpec.system)) spec = inlineSpec;
else if (scriptPath) spec = readSpecFromScriptPath(scriptPath);
if (!spec) return; // base summary (per the design split above) comes from the solvespec only

const summary = summaryFromProblemSpec(spec);
Expand Down
11 changes: 11 additions & 0 deletions packages/amico-run/src/repertoire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// exactly like src/catalog.ts's template/exemplar loaders degrade to tier 3. A
// record missing a discriminating field (id/platform/gate/fidelity) is skipped,
// not fatal.
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
Expand All @@ -31,9 +32,18 @@ export interface PulseRecord {
warm_start?: string; // lineage: the incumbent id this was warm-started from
tags?: string[];
date?: string; // ISO date "YYYY-MM-DD"
pulse_hash?: string; // W4.1: content hash of pulse.jld2 (verifiable warm-start)
dir: string; // ABS path to the entry directory
}

/** W4.1: content hash of a pulse file (sha256:<hex>), for warm-start provenance.
* Stored as `pulse_hash` in the catalog record so a warm_start spec referencing
* a mutated pulse fails loudly. */
export function pulseHashForFile(path: string): string {
const data = readFileSync(path);
return "sha256:" + createHash("sha256").update(data).digest("hex");
}

/** The repertoire's `pulses/` directory. `$AMICO_CATALOG_DIR` overrides it (tests
* point it at a temp dir); default is the company-vault mount. Mirrors the
* extension's run_controls.catalogPulsesDir, but returns the path unconditionally
Expand Down Expand Up @@ -85,6 +95,7 @@ function parseRecord(file: string, dir: string): PulseRecord | undefined {
warm_start: str(parsed.warm_start) ?? str(parsed.warm_started_from),
tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t): t is string => typeof t === "string") : undefined,
date: dateStr(parsed.date),
pulse_hash: str(parsed.pulse_hash),
};
}

Expand Down
Loading
Loading