@nite-framework/nite-zk-profiler 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,33 +1,56 @@
1
1
  #!/usr/bin/env node
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
2
3
  import { analyze } from "./analyze.js";
3
- import { DEFAULT_BUDGET_PATH, budgetFrom, check, readBudget, writeBudget, } from "./budget.js";
4
+ import { DEFAULT_BUDGET_PATH, budgetFrom, budgetSources, check, readBudget, writeBudget, } from "./budget.js";
5
+ import { cacheKey, readCache, writeCache } from "./cache.js";
6
+ import { setColorEnabled } from "./colors.js";
4
7
  import { compileSkipZk } from "./compile.js";
8
+ import { measureDeep } from "./deep.js";
9
+ import { diffCosts, materialise, pathWithinRef, repoRoot } from "./diff.js";
5
10
  import { ProfilerError } from "./errors.js";
6
- import { measure } from "./measure.js";
7
- import { checkJson, formatCheck, formatProfile, profileJson } from "./report.js";
11
+ import { calibrationFrom, readCalibration, writeCalibration } from "./estimate.js";
12
+ import { measureParallel } from "./measure.js";
13
+ import { Progress } from "./progress.js";
14
+ import { checkJson, diffJson, formatCheck, formatDiff, formatProfile, profileJson, } from "./report.js";
8
15
  import { SUPPORTED_RANGES, resolveToolchain } from "./toolchain.js";
16
+ import { toolVersion } from "./version.js";
9
17
  const USAGE = `nite-zk - see what a Compact circuit costs to prove
10
18
 
11
19
  Usage:
12
- nite-zk profile <source> Report rows, k and relative cost per circuit
13
- nite-zk save <source> Write zk-budget.json from current measurements
14
- nite-zk check <source> Measure and compare against zk-budget.json
20
+ nite-zk profile <source...> Report rows, k and relative cost per circuit
21
+ nite-zk save <source...> Write zk-budget.json from current measurements
22
+ nite-zk check [<source...>] Compare against zk-budget.json
23
+ Sources are optional once saved, since the
24
+ budget records which contracts it describes.
25
+ nite-zk diff <ref> [<source>] Compare a contract against a git ref
26
+ nite-zk calibrate --observed <ms> --at-k <k>
27
+ Anchor proving estimates to a real proof
15
28
 
16
29
  Options:
17
- --json Machine readable output
18
- --out <dir> Compile into a specific directory (kept)
19
- --budget <file> Budget path (default: ${DEFAULT_BUDGET_PATH})
20
- --strict check: fail on circuits missing from the budget
21
- +VERSION Pin the Compact toolchain, e.g. +0.31.1
22
- -h, --help Show this message
23
- -v, --version Show the tool version
30
+ --estimate Show modelled proving time per circuit
31
+ --deep Also generate real proving keys and report
32
+ measured setup time and prover key size
33
+ --json Machine readable output
34
+ --out <dir> Compile into a specific directory (kept)
35
+ --budget <file> Budget path (default: ${DEFAULT_BUDGET_PATH})
36
+ --strict check: fail on circuits missing from the budget
37
+ --no-color Plain output (also honours NO_COLOR)
38
+ --no-cache Ignore cached measurements for this run
39
+ +VERSION Pin the Compact toolchain, e.g. +0.31.1
40
+ -h, --help Show this message
41
+ -v, --version Show the tool version
24
42
 
25
43
  Supported Compact toolchains: ${SUPPORTED_RANGES.join(", ")}
26
44
  `;
27
45
  export function parseArgs(argv) {
28
46
  const opts = {
47
+ sources: [],
29
48
  json: false,
30
49
  strict: false,
50
+ deep: false,
51
+ estimate: false,
52
+ noColor: false,
53
+ noCache: false,
31
54
  budget: DEFAULT_BUDGET_PATH,
32
55
  help: false,
33
56
  version: false,
@@ -38,6 +61,14 @@ export function parseArgs(argv) {
38
61
  opts.json = true;
39
62
  else if (arg === "--strict")
40
63
  opts.strict = true;
64
+ else if (arg === "--deep")
65
+ opts.deep = true;
66
+ else if (arg === "--estimate")
67
+ opts.estimate = true;
68
+ else if (arg === "--no-color")
69
+ opts.noColor = true;
70
+ else if (arg === "--no-cache")
71
+ opts.noCache = true;
41
72
  else if (arg === "-h" || arg === "--help")
42
73
  opts.help = true;
43
74
  else if (arg === "-v" || arg === "--version")
@@ -46,70 +77,200 @@ export function parseArgs(argv) {
46
77
  opts.out = argv[++i];
47
78
  else if (arg === "--budget")
48
79
  opts.budget = argv[++i] ?? DEFAULT_BUDGET_PATH;
80
+ else if (arg === "--observed")
81
+ opts.observedMs = Number(argv[++i]);
82
+ else if (arg === "--at-k")
83
+ opts.atK = Number(argv[++i]);
49
84
  else if (arg.startsWith("+"))
50
85
  opts.versionArg = arg;
51
86
  else if (!opts.command)
52
87
  opts.command = arg;
53
- else if (!opts.source)
54
- opts.source = arg;
88
+ else if (opts.command === "diff" && opts.ref === undefined)
89
+ opts.ref = arg;
90
+ else
91
+ opts.sources.push(arg);
55
92
  }
56
93
  return opts;
57
94
  }
58
- /** Compile and measure. Shared by all three commands. */
59
- function profileSource(source, opts) {
60
- // Monotonic, so a wall clock adjustment mid run cannot produce a negative
61
- // or wildly wrong duration.
62
- const started = performance.now();
95
+ /** Compile and measure one contract. */
96
+ async function profileOne(source, opts, progress, label) {
97
+ progress.update(`${label}compiling`);
63
98
  const toolchain = resolveToolchain(opts.versionArg);
64
- const compiled = compileSkipZk(source, toolchain, opts.out);
99
+ const compiled = await compileSkipZk(source, toolchain, opts.out);
65
100
  try {
66
- const costs = analyze(measure(compiled.zkirDir, toolchain, source));
67
- return { costs, toolchain, elapsedMs: performance.now() - started };
101
+ // The compile is the cheap half, so it always runs and its output keys the
102
+ // cache. Identical IR cannot produce different constraint counts.
103
+ const key = opts.noCache ? undefined : cacheKey(compiled.zkirDir, toolchain);
104
+ let measurements = key ? readCache(key) : undefined;
105
+ const cached = measurements !== undefined;
106
+ if (!measurements) {
107
+ measurements = await measureParallel(compiled.zkirDir, toolchain, source, (d, t) => progress.update(`${label}measuring ${d}/${t}`));
108
+ if (key)
109
+ writeCache(key, measurements);
110
+ }
111
+ let deep;
112
+ if (opts.deep) {
113
+ const results = await measureDeep(measurements, compiled.zkirDir, toolchain, (c, i, n) => progress.update(`${label}generating proving keys ${i + 1}/${n} ${c}`));
114
+ deep = new Map(results.map((r) => [r.circuit, r]));
115
+ }
116
+ return { costs: analyze(measurements), toolchain, deep, cached };
68
117
  }
69
118
  finally {
70
119
  compiled.cleanup();
71
120
  }
72
121
  }
73
- export function run(argv) {
122
+ /** Compile and measure every requested contract. */
123
+ async function profileAll(sources, opts) {
124
+ const started = performance.now();
125
+ const progress = new Progress(!opts.json);
126
+ progress.start("resolving toolchain");
127
+ try {
128
+ const contracts = [];
129
+ let toolchain;
130
+ let deep;
131
+ let cached = true;
132
+ for (const [i, source] of sources.entries()) {
133
+ const label = sources.length > 1 ? `[${i + 1}/${sources.length}] ` : "";
134
+ const r = await profileOne(source, opts, progress, label);
135
+ contracts.push({ source, costs: r.costs });
136
+ toolchain = r.toolchain;
137
+ if (r.deep)
138
+ deep = new Map([...(deep ?? []), ...r.deep]);
139
+ if (!r.cached)
140
+ cached = false;
141
+ }
142
+ progress.stop();
143
+ return { contracts, toolchain, deep, cached, elapsedMs: performance.now() - started };
144
+ }
145
+ finally {
146
+ progress.stop();
147
+ }
148
+ }
149
+ /** Paths recorded in a budget are relative to the budget file. */
150
+ function sourcesFromBudget(budgetPath, recorded) {
151
+ const base = dirname(resolve(budgetPath));
152
+ return recorded.map((s) => (isAbsolute(s) ? s : resolve(base, s)));
153
+ }
154
+ function profileOptions(opts, run) {
155
+ return {
156
+ toolchain: run.toolchain,
157
+ elapsedMs: run.elapsedMs,
158
+ deep: run.deep,
159
+ cached: run.cached,
160
+ calibration: readCalibration(opts.budget),
161
+ showEstimate: opts.estimate,
162
+ };
163
+ }
164
+ async function runDiff(opts) {
165
+ if (!opts.ref) {
166
+ throw new ProfilerError("diff needs a git ref", " nite-zk diff main src/Main.compact");
167
+ }
168
+ const root = repoRoot();
169
+ const sources = opts.sources.length
170
+ ? opts.sources
171
+ : sourcesFromBudget(opts.budget, budgetSources(readBudget(opts.budget)));
172
+ if (sources.length !== 1) {
173
+ throw new ProfilerError("diff compares one contract at a time", `Got ${sources.length} contracts. Name the one to compare:\n nite-zk diff ${opts.ref} src/Main.compact`);
174
+ }
175
+ const source = sources[0];
176
+ const after = await profileAll([source], opts);
177
+ const exported = await materialise(opts.ref);
178
+ try {
179
+ const basePath = pathWithinRef(source, root, exported.dir);
180
+ const before = await profileAll([basePath], opts);
181
+ const result = diffCosts(opts.ref, before.contracts[0].costs, after.contracts[0].costs);
182
+ process.stdout.write(opts.json ? `${diffJson(result)}\n` : formatDiff(result));
183
+ return result.regressed ? 1 : 0;
184
+ }
185
+ finally {
186
+ exported.cleanup();
187
+ }
188
+ }
189
+ export async function run(argv) {
74
190
  const opts = parseArgs(argv);
191
+ if (opts.noColor || opts.json)
192
+ setColorEnabled(false);
193
+ // Checked before the no-command case, since `nite-zk -v` carries no command
194
+ // and would otherwise fall through to the usage text.
195
+ if (opts.version) {
196
+ process.stdout.write(`nite-zk-profiler ${toolVersion()}\n`);
197
+ return 0;
198
+ }
75
199
  if (opts.help || !opts.command) {
76
200
  process.stdout.write(USAGE);
77
201
  return opts.command ? 0 : 1;
78
202
  }
79
- if (opts.version) {
80
- process.stdout.write("nite-zk-profiler 0.1.0\n");
81
- return 0;
82
- }
83
- if (!["profile", "save", "check"].includes(opts.command)) {
203
+ const known = ["profile", "save", "check", "diff", "calibrate"];
204
+ if (!known.includes(opts.command)) {
84
205
  process.stderr.write(`Unknown command: ${opts.command}\n\n${USAGE}`);
85
206
  return 1;
86
207
  }
87
- if (!opts.source) {
88
- process.stderr.write(`${opts.command} needs a source file\n\n${USAGE}`);
89
- return 1;
90
- }
91
- const { costs, toolchain, elapsedMs } = profileSource(opts.source, opts);
92
- if (opts.command === "profile") {
93
- process.stdout.write(opts.json
94
- ? `${profileJson(costs, toolchain)}\n`
95
- : formatProfile(costs, toolchain, elapsedMs));
208
+ if (opts.command === "calibrate") {
209
+ if (!opts.observedMs || !opts.atK) {
210
+ throw new ProfilerError("calibrate needs an observed proof", "Time one real proof, then record it:\n" +
211
+ " nite-zk calibrate --observed 9000 --at-k 16\n" +
212
+ "where --observed is milliseconds and --at-k is that circuit's k.");
213
+ }
214
+ const calibration = calibrationFrom(opts.observedMs, opts.atK);
215
+ writeCalibration(opts.budget, calibration);
216
+ process.stdout.write(`Calibrated: ${calibration.msPerDomainRow.toFixed(4)} ms per domain row, ` +
217
+ `from ${opts.observedMs}ms at k=${opts.atK}.\n` +
218
+ "Estimates now use this machine's rate. Re-run `nite-zk calibrate` if the prover changes.\n");
96
219
  return 0;
97
220
  }
98
- if (opts.command === "save") {
99
- // Record the supported line rather than the exact patch version, so a
100
- // routine toolchain bump inside 0.31.x does not invalidate the budget.
101
- const budget = budgetFrom(costs, `${toolchain.version.split(".").slice(0, 2).join(".")}.x`);
102
- writeBudget(opts.budget, budget);
103
- process.stdout.write(`Wrote ${opts.budget} with ${costs.length} circuit${costs.length === 1 ? "" : "s"}\n`);
221
+ if (opts.command === "diff")
222
+ return runDiff(opts);
223
+ if (opts.command !== "check" && opts.sources.length === 0) {
224
+ throw new ProfilerError(`${opts.command} needs at least one source file`, ` nite-zk ${opts.command} src/Main.compact`);
225
+ }
226
+ if (opts.command === "check") {
227
+ // Read the budget first, so a missing one is reported before spending time
228
+ // on a compile, and so it can supply the source paths.
229
+ const budget = readBudget(opts.budget);
230
+ const recorded = budgetSources(budget);
231
+ const sources = opts.sources.length
232
+ ? opts.sources
233
+ : sourcesFromBudget(opts.budget, recorded);
234
+ if (sources.length === 0) {
235
+ throw new ProfilerError("check needs a source file", `${opts.budget} does not record which contracts it describes.\n` +
236
+ "Either pass them:\n nite-zk check src/Main.compact\n" +
237
+ "or rewrite the budget so it remembers:\n nite-zk save src/Main.compact");
238
+ }
239
+ const run_ = await profileAll(sources, opts);
240
+ // Compare using the paths as the budget records them.
241
+ const base = dirname(resolve(opts.budget));
242
+ const keyed = run_.contracts.map((c, i) => ({
243
+ source: opts.sources.length ? relative(base, resolve(c.source)) : recorded[i] ?? c.source,
244
+ costs: c.costs,
245
+ }));
246
+ const result = check(keyed, budget, opts.strict);
247
+ process.stdout.write(opts.json ? `${checkJson(result)}\n` : formatCheck(result));
248
+ return result.failed ? 1 : 0;
249
+ }
250
+ const run_ = await profileAll(opts.sources, opts);
251
+ if (opts.command === "profile") {
252
+ const o = profileOptions(opts, run_);
253
+ process.stdout.write(opts.json ? `${profileJson(run_.contracts, o)}\n` : formatProfile(run_.contracts, o));
104
254
  return 0;
105
255
  }
106
- const result = check(costs, readBudget(opts.budget), opts.strict);
107
- process.stdout.write(opts.json ? `${checkJson(result)}\n` : formatCheck(result));
108
- return result.failed ? 1 : 0;
256
+ // save
257
+ //
258
+ // Record the supported line rather than the exact patch version, so a routine
259
+ // toolchain bump inside 0.31.x does not invalidate the budget. Sources are
260
+ // stored relative to the budget file so the pair stays portable.
261
+ const base = dirname(resolve(opts.budget));
262
+ const line = `${run_.toolchain.version.split(".").slice(0, 2).join(".")}.x`;
263
+ const budget = budgetFrom(run_.contracts.map((c) => ({ source: relative(base, resolve(c.source)), costs: c.costs })), line);
264
+ writeBudget(opts.budget, budget);
265
+ const circuits = run_.contracts.reduce((n, c) => n + c.costs.length, 0);
266
+ process.stdout.write(`Wrote ${opts.budget}: ${run_.contracts.length} contract${run_.contracts.length === 1 ? "" : "s"}, ` +
267
+ `${circuits} circuit${circuits === 1 ? "" : "s"}\n` +
268
+ "Check it in, then run `nite-zk check` in CI.\n");
269
+ return 0;
109
270
  }
110
- export function main(argv) {
271
+ export async function main(argv) {
111
272
  try {
112
- return run(argv);
273
+ return await run(argv);
113
274
  }
114
275
  catch (e) {
115
276
  if (e instanceof ProfilerError) {
@@ -126,5 +287,5 @@ export function main(argv) {
126
287
  const invokedDirectly = process.argv[1] !== undefined &&
127
288
  /(?:^|[\\/])(?:cli\.(?:ts|js)|nite-zk)$/.test(process.argv[1]);
128
289
  if (invokedDirectly) {
129
- process.exit(main(process.argv.slice(2)));
290
+ main(process.argv.slice(2)).then((code) => process.exit(code));
130
291
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Minimal ANSI styling.
3
+ *
4
+ * Deliberately not a dependency. A profiler that prints a table needs eight
5
+ * escape codes, and a zero dependency CLI is easier to trust and audit than one
6
+ * that pulls a package in to add colour.
7
+ */
8
+ /** Test seam, and lets the CLI force plain output for `--json`. */
9
+ export declare function setColorEnabled(value: boolean): void;
10
+ declare const CODES: {
11
+ readonly reset: 0;
12
+ readonly bold: 1;
13
+ readonly dim: 2;
14
+ readonly red: 31;
15
+ readonly green: 32;
16
+ readonly yellow: 33;
17
+ readonly blue: 34;
18
+ readonly magenta: 35;
19
+ readonly cyan: 36;
20
+ readonly gray: 90;
21
+ };
22
+ export type Style = keyof typeof CODES;
23
+ export declare const bold: (t: string) => string;
24
+ export declare const dim: (t: string) => string;
25
+ export declare const red: (t: string) => string;
26
+ export declare const green: (t: string) => string;
27
+ export declare const yellow: (t: string) => string;
28
+ export declare const cyan: (t: string) => string;
29
+ export declare const gray: (t: string) => string;
30
+ export declare const boldRed: (t: string) => string;
31
+ export declare const boldYellow: (t: string) => string;
32
+ /**
33
+ * Colour a circuit by how much it costs relative to the cheapest one here.
34
+ * The thresholds are deliberately coarse: the point is to make the expensive
35
+ * circuits findable at a glance, not to encode a precise scale.
36
+ */
37
+ export declare function costColor(relativeCost: number): (t: string) => string;
38
+ /** Width of a string once escape sequences are discounted. */
39
+ export declare function visibleLength(text: string): number;
40
+ export {};
package/dist/colors.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Minimal ANSI styling.
3
+ *
4
+ * Deliberately not a dependency. A profiler that prints a table needs eight
5
+ * escape codes, and a zero dependency CLI is easier to trust and audit than one
6
+ * that pulls a package in to add colour.
7
+ */
8
+ /**
9
+ * Honours the NO_COLOR convention, FORCE_COLOR, and whether stdout is a
10
+ * terminal, so piping to a file or a CI log produces clean text.
11
+ */
12
+ function colorEnabled() {
13
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "")
14
+ return false;
15
+ if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0")
16
+ return true;
17
+ return process.stdout.isTTY === true;
18
+ }
19
+ let enabled = colorEnabled();
20
+ /** Test seam, and lets the CLI force plain output for `--json`. */
21
+ export function setColorEnabled(value) {
22
+ enabled = value;
23
+ }
24
+ const CODES = {
25
+ reset: 0,
26
+ bold: 1,
27
+ dim: 2,
28
+ red: 31,
29
+ green: 32,
30
+ yellow: 33,
31
+ blue: 34,
32
+ magenta: 35,
33
+ cyan: 36,
34
+ gray: 90,
35
+ };
36
+ function wrap(text, ...styles) {
37
+ if (!enabled || styles.length === 0)
38
+ return text;
39
+ const open = styles.map((s) => `[${CODES[s]}m`).join("");
40
+ return `${open}${text}[${CODES.reset}m`;
41
+ }
42
+ export const bold = (t) => wrap(t, "bold");
43
+ export const dim = (t) => wrap(t, "dim");
44
+ export const red = (t) => wrap(t, "red");
45
+ export const green = (t) => wrap(t, "green");
46
+ export const yellow = (t) => wrap(t, "yellow");
47
+ export const cyan = (t) => wrap(t, "cyan");
48
+ export const gray = (t) => wrap(t, "gray");
49
+ export const boldRed = (t) => wrap(t, "bold", "red");
50
+ export const boldYellow = (t) => wrap(t, "bold", "yellow");
51
+ /**
52
+ * Colour a circuit by how much it costs relative to the cheapest one here.
53
+ * The thresholds are deliberately coarse: the point is to make the expensive
54
+ * circuits findable at a glance, not to encode a precise scale.
55
+ */
56
+ export function costColor(relativeCost) {
57
+ if (relativeCost >= 32)
58
+ return boldRed;
59
+ if (relativeCost >= 8)
60
+ return red;
61
+ if (relativeCost >= 2)
62
+ return yellow;
63
+ return green;
64
+ }
65
+ /** Width of a string once escape sequences are discounted. */
66
+ export function visibleLength(text) {
67
+ // eslint-disable-next-line no-control-regex
68
+ return text.replace(/\[[0-9;]*m/g, "").length;
69
+ }
package/dist/compile.d.ts CHANGED
@@ -13,4 +13,4 @@ export interface CompileResult {
13
13
  * `--skip-zk` is what makes profiling fast enough to sit in an edit loop: it
14
14
  * emits the IR and skips key generation, which is the slow part.
15
15
  */
16
- export declare function compileSkipZk(source: string, toolchain: Toolchain, outDir?: string): CompileResult;
16
+ export declare function compileSkipZk(source: string, toolchain: Toolchain, outDir?: string): Promise<CompileResult>;
package/dist/compile.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
@@ -9,7 +9,7 @@ import { ProfilerError } from "./errors.js";
9
9
  * `--skip-zk` is what makes profiling fast enough to sit in an edit loop: it
10
10
  * emits the IR and skips key generation, which is the slow part.
11
11
  */
12
- export function compileSkipZk(source, toolchain, outDir) {
12
+ export async function compileSkipZk(source, toolchain, outDir) {
13
13
  const temporary = outDir === undefined;
14
14
  const target = temporary
15
15
  ? mkdtempSync(join(tmpdir(), "nite-zk-"))
@@ -18,21 +18,30 @@ export function compileSkipZk(source, toolchain, outDir) {
18
18
  if (toolchain.versionArg)
19
19
  args.push(toolchain.versionArg);
20
20
  args.push("--skip-zk", resolve(source), target);
21
- const res = spawnSync("compact", args, { encoding: "utf8" });
22
21
  const cleanup = () => {
23
22
  if (temporary)
24
23
  rmSync(target, { recursive: true, force: true });
25
24
  };
26
- if (res.error) {
25
+ // Spawned asynchronously rather than with spawnSync so the event loop stays
26
+ // free. A synchronous spawn blocks timers, which freezes the progress
27
+ // display for the whole compile.
28
+ const { status, output, error } = await new Promise((resolvePromise) => {
29
+ const child = spawn("compact", args);
30
+ let out = "";
31
+ child.stdout.on("data", (d) => (out += d));
32
+ child.stderr.on("data", (d) => (out += d));
33
+ child.on("error", (e) => resolvePromise({ status: null, output: out, error: e }));
34
+ child.on("close", (code) => resolvePromise({ status: code, output: out }));
35
+ });
36
+ if (error) {
27
37
  cleanup();
28
- throw new ProfilerError("Could not run `compact compile`", String(res.error));
38
+ throw new ProfilerError("Could not run `compact compile`", String(error));
29
39
  }
30
- if (res.status !== 0) {
40
+ if (status !== 0) {
31
41
  // The compiler's own diagnostics are better than anything worth inventing
32
42
  // here, so they are passed through unchanged.
33
- const diagnostics = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
34
43
  cleanup();
35
- throw new ProfilerError(`Compilation failed for ${source}`, diagnostics || `compact exited with status ${res.status}`);
44
+ throw new ProfilerError(`Compilation failed for ${source}`, output.trim() || `compact exited with status ${status}`);
36
45
  }
37
46
  return { outDir: target, zkirDir: join(target, "zkir"), cleanup };
38
47
  }
package/dist/deep.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { Measurement } from "./measure.ts";
2
+ import type { Toolchain } from "./toolchain.ts";
3
+ export interface DeepMeasurement extends Measurement {
4
+ /** Wall clock time to generate the proving and verifying keys, in ms. */
5
+ setupMs: number;
6
+ /** Size of the generated prover key, in bytes. */
7
+ proverKeyBytes: number;
8
+ }
9
+ /**
10
+ * Generate real proving keys and measure how long it takes.
11
+ *
12
+ * This is the expensive counterpart to `mock-compile`. It is opt in because it
13
+ * does the work the fast path deliberately skips, which is minutes rather than
14
+ * seconds on a contract full of high `k` circuits.
15
+ *
16
+ * Two things come out of it that cannot be derived from `k` alone: the actual
17
+ * setup time on this machine, and the prover key size, which is what users end
18
+ * up downloading and holding in memory.
19
+ */
20
+ export declare function measureDeep(measurements: Measurement[], zkirDir: string, toolchain: Toolchain, onProgress?: (circuit: string, index: number, total: number) => void): Promise<DeepMeasurement[]>;
21
+ /** Human readable byte size. */
22
+ export declare function formatBytes(bytes: number): string;
23
+ /** Human readable duration. */
24
+ export declare function formatMs(ms: number): string;
package/dist/deep.js ADDED
@@ -0,0 +1,60 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ProfilerError } from "./errors.js";
6
+ /**
7
+ * Generate real proving keys and measure how long it takes.
8
+ *
9
+ * This is the expensive counterpart to `mock-compile`. It is opt in because it
10
+ * does the work the fast path deliberately skips, which is minutes rather than
11
+ * seconds on a contract full of high `k` circuits.
12
+ *
13
+ * Two things come out of it that cannot be derived from `k` alone: the actual
14
+ * setup time on this machine, and the prover key size, which is what users end
15
+ * up downloading and holding in memory.
16
+ */
17
+ export async function measureDeep(measurements, zkirDir, toolchain, onProgress) {
18
+ const workDir = mkdtempSync(join(tmpdir(), "nite-zk-keys-"));
19
+ const results = [];
20
+ try {
21
+ for (const [i, m] of measurements.entries()) {
22
+ onProgress?.(m.circuit, i, measurements.length);
23
+ const irFile = join(zkirDir, `${m.circuit}.zkir`);
24
+ const pk = join(workDir, `${m.circuit}.pk`);
25
+ const vk = join(workDir, `${m.circuit}.vk`);
26
+ const started = performance.now();
27
+ // Async so the progress display keeps animating. Key generation is the
28
+ // longest wait this tool has, so a frozen spinner here reads as a hang.
29
+ const { status, err } = await new Promise((resolvePromise) => {
30
+ const child = spawn(toolchain.zkirPath, ["compile", irFile, pk, vk]);
31
+ let captured = "";
32
+ child.stderr.on("data", (d) => (captured += d));
33
+ child.on("close", (code) => resolvePromise({ status: code, err: captured }));
34
+ });
35
+ const setupMs = performance.now() - started;
36
+ if (status !== 0) {
37
+ throw new ProfilerError(`Key generation failed for ${m.circuit}`, err.trim() || `zkir compile exited with ${status}`);
38
+ }
39
+ results.push({ ...m, setupMs, proverKeyBytes: statSync(pk).size });
40
+ }
41
+ }
42
+ finally {
43
+ rmSync(workDir, { recursive: true, force: true });
44
+ }
45
+ return results;
46
+ }
47
+ /** Human readable byte size. */
48
+ export function formatBytes(bytes) {
49
+ if (bytes < 1024)
50
+ return `${bytes} B`;
51
+ if (bytes < 1024 * 1024)
52
+ return `${(bytes / 1024).toFixed(0)} KB`;
53
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
54
+ }
55
+ /** Human readable duration. */
56
+ export function formatMs(ms) {
57
+ if (ms < 1000)
58
+ return `${ms.toFixed(0)}ms`;
59
+ return `${(ms / 1000).toFixed(1)}s`;
60
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { CircuitCost } from "./analyze.ts";
2
+ export interface DiffRow {
3
+ circuit: string;
4
+ /** Absent when the circuit does not exist on the base ref. */
5
+ before?: number;
6
+ /** Absent when the circuit was removed. */
7
+ after?: number;
8
+ }
9
+ export interface DiffResult {
10
+ ref: string;
11
+ rows: DiffRow[];
12
+ /** True when any circuit costs more than it did on the base ref. */
13
+ regressed: boolean;
14
+ }
15
+ /** Repository root, so ref paths can be resolved the way git sees them. */
16
+ export declare function repoRoot(): string;
17
+ /**
18
+ * Materialise a ref into a temporary directory.
19
+ *
20
+ * `git archive` is used rather than a worktree or a checkout, because it never
21
+ * touches the working tree or the index. Profiling a branch must not disturb
22
+ * uncommitted work.
23
+ */
24
+ export declare function materialise(ref: string): Promise<{
25
+ dir: string;
26
+ cleanup: () => void;
27
+ }>;
28
+ /** Where a working tree path lives inside the exported ref. */
29
+ export declare function pathWithinRef(source: string, root: string, refDir: string): string;
30
+ export declare function diffCosts(ref: string, before: CircuitCost[], after: CircuitCost[]): DiffResult;