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

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/README.md CHANGED
@@ -38,13 +38,14 @@ It reports the cost class of every circuit in your contract.
38
38
  ```text
39
39
  $ nite-zk profile Sample.compact
40
40
 
41
- circuit rows k capacity cost
42
- bump 24 5 32 1x
43
- balanceOf 305 9 512 16x
44
- register 368 9 512 16x
45
- insert32 2299 13 8192 256x
46
-
47
- 4 circuits, toolchain 0.31.1, zkir 2.1.0, 0.4s
41
+ circuit rows k capacity cost est. prove
42
+ bump 24 5 32 1x ~10ms
43
+ balanceOf 305 9 512 16x ~165ms
44
+ register 368 9 512 16x ~165ms
45
+ insert32 2299 13 8192 256x ~2.6s
46
+
47
+ 4 circuits, toolchain 0.31.1, midnight-zkir 2.1.0, 0.9s
48
+ est. prove is modelled as 2^k on an uncalibrated default. Run `nite-zk calibrate` to anchor it.
48
49
  ```
49
50
 
50
51
  `k` is the number that matters. `cost` is `2^k` expressed relative to the cheapest circuit in the contract, so you can see at a glance which circuits dominate your proving budget.
@@ -370,15 +371,114 @@ Anything outside this range is rejected with a clear error naming the version fo
370
371
  ## Command line surface
371
372
 
372
373
  ```text
373
- nite-zk profile <source> Report rows, k and relative cost per circuit
374
- nite-zk save <source> Write zk-budget.json from current measurements
375
- nite-zk check [<source>] Measure and compare against zk-budget.json
374
+ nite-zk profile <source...> Report rows, k, cost and estimated proving time
375
+ nite-zk save <source...> Write zk-budget.json from current measurements
376
+ nite-zk check [<source...>] Compare against zk-budget.json
377
+ nite-zk diff <ref> [<source>] Compare a contract against a git ref
378
+ nite-zk calibrate --observed <ms> --at-k <k>
379
+ Anchor proving estimates to a real proof
380
+
381
+ --no-estimate Hide the modelled proving time column
382
+ --deep Also generate real proving keys, and report
383
+ measured setup time and prover key size
384
+ --json Machine readable output
385
+ --out <dir> Compile into a specific directory
386
+ --budget <file> Use a different baseline path
387
+ --strict check: fail on circuits missing from the budget
388
+ --no-color Plain output (NO_COLOR is honoured too)
389
+ --no-cache Ignore cached measurements for this run
390
+ +VERSION Pin the Compact toolchain, e.g. +0.31.1
391
+ ```
392
+
393
+ ### Several contracts at once
394
+
395
+ Pass more than one entry point, and one budget file covers them all:
396
+
397
+ ```text
398
+ $ nite-zk save packages/pool/src/lending.compact packages/mint/src/mint.compact
399
+ Wrote zk-budget.json: 2 contracts, 16 circuits
400
+ ```
401
+
402
+ `check` then reads every contract back out of the budget, so CI stays one step
403
+ whether the repository holds one contract or ten.
404
+
405
+ ### Comparing against a branch
406
+
407
+ ```text
408
+ $ nite-zk diff main
376
409
 
377
- --json Machine readable output
378
- --out <dir> Compile into a specific directory
379
- --budget <file> Use a different baseline path
410
+ transferFunds k 15 -> 16 +1, about 2x more expensive
411
+ proveMembership k 16 -> 16 unchanged
412
+ newHelper k - -> 9 new circuit
413
+
414
+ 1 circuit more expensive than main
380
415
  ```
381
416
 
417
+ The ref is exported with `git archive` into a temporary directory, so the
418
+ working tree and the index are never touched. Exits nonzero when a circuit that
419
+ already existed got more expensive, which makes it usable as a pull request
420
+ check without committing a budget file first.
421
+
422
+ ### Estimated proving time
423
+
424
+ `profile` shows an estimated proving time by default, modelled as
425
+ `time = rate * 2^k`, since a Halo2 proof is dominated by work over the full
426
+ `2^k` domain. `--no-estimate` hides the column.
427
+
428
+ The rate is machine specific, so the shipped default is only an order of
429
+ magnitude. Time one real proof and record it, and every estimate becomes
430
+ anchored to your own prover:
431
+
432
+ ```text
433
+ $ nite-zk calibrate --observed 9000 --at-k 16
434
+ Calibrated: 0.1373 ms per domain row, from 9000ms at k=16.
435
+ ```
436
+
437
+ Two limits worth stating plainly. Proving delegated to a remote proof server is
438
+ often dominated by network round trip and server load rather than by circuit
439
+ size, and no model of `k` can see that. And the same gate degree effect that
440
+ makes key size unpredictable, described below, puts a factor of about two around
441
+ any figure derived from `k` alone. Treat it as a ratio you can reason with, not
442
+ a stopwatch.
443
+
444
+ `save` records which contracts the budget describes, so `check` needs no
445
+ arguments afterwards. That is what makes it a one line CI step. It also records
446
+ rows, capacity and relative cost per circuit, so a reviewer reading the diff can
447
+ see what changed without running anything. Only `maxK` is enforced.
448
+
449
+ ### Measuring the real thing
450
+
451
+ `--deep` stops mocking and generates actual proving keys. It answers two
452
+ questions the fast path cannot, and both are measured rather than modelled:
453
+
454
+ ```text
455
+ circuit rows k capacity cost setup prover key
456
+ k05 24 5 32 1x 156ms 14 KB
457
+ k13 4189 13 8192 256x 2.3s 2.7 MB
458
+ k14 11965 14 16384 512x 4.6s 5.0 MB
459
+ k15 19657 15 32768 1024x 9.6s 9.5 MB
460
+ ```
461
+
462
+ Setup time and prover key size both roughly double per step in `k`, which is
463
+ the same 2^k curve proving time follows. The prover key size is worth attention
464
+ on its own: it is what users download and hold in memory, and a k=15 circuit
465
+ carries a 9.5 MB key against 14 KB at k=5.
466
+
467
+ This mode is slow by design, since it does the work `--skip-zk` exists to avoid.
468
+ Use it when choosing between designs, not in an edit loop.
469
+
470
+ **Why key size is measured rather than predicted.** It looks predictable from
471
+ `k`: three structurally unrelated circuits at k=13 agreed within 0.11%, and a
472
+ synthetic k=15 circuit matched a production one to 0.08%. It does not hold. At
473
+ k=16 a production circuit produced 19,524,757 bytes while a synthetic one
474
+ produced 38,513,181, a factor of two apart, with identical verifier key sizes.
475
+
476
+ The likely cause is the extended evaluation domain, which Halo2 sizes by maximum
477
+ gate degree, and which `mock-compile` does not report. From `k` alone there is
478
+ no way to tell which case a circuit is in, and a figure that is exact at one `k`
479
+ and twice wrong at the next is worse than no figure. So key size comes only from
480
+ `--deep`, where it is measured.
481
+
382
482
  Designed to work as a single CI step:
383
483
 
384
484
  ```yaml
package/dist/budget.d.ts CHANGED
@@ -1,14 +1,33 @@
1
1
  import type { CircuitCost } from "./analyze.ts";
2
2
  export declare const DEFAULT_BUDGET_PATH = "zk-budget.json";
3
+ /**
4
+ * Per circuit entry.
5
+ *
6
+ * `maxK` is the only enforced field. The rest is a snapshot recorded at save
7
+ * time so a reviewer reading the diff can see what changed and by how much,
8
+ * without running the tool. Snapshot drift never fails a check.
9
+ */
10
+ export interface CircuitBudget {
11
+ maxK: number;
12
+ rows?: number;
13
+ capacity?: number;
14
+ relativeCost?: number;
15
+ }
16
+ export interface ContractBudget {
17
+ circuits: Record<string, CircuitBudget>;
18
+ }
3
19
  export interface Budget {
20
+ /** Which release wrote this, so an old file can be recognised. */
21
+ tool?: string;
4
22
  /** Toolchain line the ceilings were established against. */
5
23
  toolchain: string;
6
- circuits: Record<string, {
7
- maxK: number;
8
- }>;
24
+ generated?: string;
25
+ /** Keyed by source path, relative to the budget file. */
26
+ contracts: Record<string, ContractBudget>;
9
27
  }
10
28
  export type Status = "under" | "at" | "over" | "undeclared" | "stale";
11
29
  export interface CheckRow {
30
+ contract: string;
12
31
  circuit: string;
13
32
  status: Status;
14
33
  k?: number;
@@ -18,16 +37,23 @@ export interface CheckResult {
18
37
  rows: CheckRow[];
19
38
  failed: boolean;
20
39
  }
21
- /** Build a budget that grants every circuit exactly what it currently costs. */
22
- export declare function budgetFrom(costs: CircuitCost[], toolchainLine: string): Budget;
40
+ /** Measurements for one contract, as the CLI collects them. */
41
+ export interface ContractCosts {
42
+ source: string;
43
+ costs: CircuitCost[];
44
+ }
45
+ /** Build a budget granting every circuit exactly what it currently costs. */
46
+ export declare function budgetFrom(contracts: ContractCosts[], toolchainLine: string): Budget;
23
47
  export declare function writeBudget(path: string, budget: Budget): void;
24
48
  export declare function readBudget(path: string): Budget;
49
+ /** Every contract the budget describes. */
50
+ export declare function budgetSources(budget: Budget): string[];
25
51
  /**
26
52
  * Compare measurements against declared ceilings.
27
53
  *
28
- * The gate is a ceiling, not a diff against the last run. A circuit's `k` rising
29
- * is often intentional, so only exceeding a ceiling the project has committed to
30
- * is a failure. Raising a ceiling deliberately is a one line change a reviewer
31
- * sees, which is the point.
54
+ * The gate is a ceiling, not a diff against the last run. A circuit's `k`
55
+ * rising is often intentional, so only exceeding a ceiling the project has
56
+ * committed to is a failure. Raising a ceiling deliberately is a one line
57
+ * change a reviewer sees, which is the point.
32
58
  */
33
- export declare function check(costs: CircuitCost[], budget: Budget, strict: boolean): CheckResult;
59
+ export declare function check(measured: ContractCosts[], budget: Budget, strict: boolean): CheckResult;
package/dist/budget.js CHANGED
@@ -1,17 +1,49 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { ProfilerError } from "./errors.js";
3
+ import { toolVersion } from "./version.js";
3
4
  export const DEFAULT_BUDGET_PATH = "zk-budget.json";
4
- /** Build a budget that grants every circuit exactly what it currently costs. */
5
- export function budgetFrom(costs, toolchainLine) {
6
- const circuits = {};
7
- for (const c of [...costs].sort((a, b) => a.circuit.localeCompare(b.circuit))) {
8
- circuits[c.circuit] = { maxK: c.k };
5
+ /** Build a budget granting every circuit exactly what it currently costs. */
6
+ export function budgetFrom(contracts, toolchainLine) {
7
+ const out = {};
8
+ for (const { source, costs } of [...contracts].sort((a, b) => a.source.localeCompare(b.source))) {
9
+ const circuits = {};
10
+ for (const c of [...costs].sort((a, b) => a.circuit.localeCompare(b.circuit))) {
11
+ circuits[c.circuit] = {
12
+ maxK: c.k,
13
+ rows: c.rows,
14
+ capacity: c.capacity,
15
+ relativeCost: c.relativeCost,
16
+ };
17
+ }
18
+ out[source] = { circuits };
9
19
  }
10
- return { toolchain: toolchainLine, circuits };
20
+ return {
21
+ tool: `nite-zk-profiler ${toolVersion()}`,
22
+ toolchain: toolchainLine,
23
+ generated: new Date().toISOString(),
24
+ contracts: out,
25
+ };
11
26
  }
12
27
  export function writeBudget(path, budget) {
13
28
  writeFileSync(path, `${JSON.stringify(budget, null, 2)}\n`, "utf8");
14
29
  }
30
+ /** Bring the pre multi contract shape forward, so old files keep working. */
31
+ function normalise(parsed, path) {
32
+ if ("contracts" in parsed && parsed.contracts)
33
+ return parsed;
34
+ const legacy = parsed;
35
+ if (!legacy.circuits) {
36
+ throw new ProfilerError(`Malformed budget file: ${path}`, 'Expected a "contracts" map. Rewrite it with `nite-zk save <source>`.');
37
+ }
38
+ return {
39
+ toolchain: legacy.toolchain,
40
+ contracts: {
41
+ [legacy.source ?? ""]: {
42
+ circuits: Object.fromEntries(Object.entries(legacy.circuits).map(([n, e]) => [n, { maxK: e.maxK }])),
43
+ },
44
+ },
45
+ };
46
+ }
15
47
  export function readBudget(path) {
16
48
  let raw;
17
49
  try {
@@ -27,50 +59,75 @@ export function readBudget(path) {
27
59
  catch (e) {
28
60
  throw new ProfilerError(`Could not parse ${path}`, String(e));
29
61
  }
30
- const budget = parsed;
31
- if (!budget || typeof budget !== "object" || typeof budget.circuits !== "object") {
32
- throw new ProfilerError(`Malformed budget file: ${path}`, 'Expected {"toolchain": "...", "circuits": {"name": {"maxK": N}}}.');
62
+ if (!parsed || typeof parsed !== "object") {
63
+ throw new ProfilerError(`Malformed budget file: ${path}`, "Expected a JSON object.");
33
64
  }
34
- for (const [name, entry] of Object.entries(budget.circuits)) {
35
- if (!entry || typeof entry.maxK !== "number" || !Number.isInteger(entry.maxK)) {
36
- throw new ProfilerError(`Malformed budget entry for "${name}" in ${path}`, 'Each circuit needs an integer "maxK".');
65
+ const budget = normalise(parsed, path);
66
+ for (const [source, contract] of Object.entries(budget.contracts)) {
67
+ for (const [name, entry] of Object.entries(contract.circuits ?? {})) {
68
+ if (!entry || typeof entry.maxK !== "number" || !Number.isInteger(entry.maxK)) {
69
+ throw new ProfilerError(`Malformed budget entry for "${name}" in ${source || path}`, 'Each circuit needs an integer "maxK".');
70
+ }
37
71
  }
38
72
  }
39
73
  return budget;
40
74
  }
75
+ /** Every contract the budget describes. */
76
+ export function budgetSources(budget) {
77
+ return Object.keys(budget.contracts).filter((s) => s.length > 0);
78
+ }
41
79
  /**
42
80
  * Compare measurements against declared ceilings.
43
81
  *
44
- * The gate is a ceiling, not a diff against the last run. A circuit's `k` rising
45
- * is often intentional, so only exceeding a ceiling the project has committed to
46
- * is a failure. Raising a ceiling deliberately is a one line change a reviewer
47
- * sees, which is the point.
82
+ * The gate is a ceiling, not a diff against the last run. A circuit's `k`
83
+ * rising is often intentional, so only exceeding a ceiling the project has
84
+ * committed to is a failure. Raising a ceiling deliberately is a one line
85
+ * change a reviewer sees, which is the point.
48
86
  */
49
- export function check(costs, budget, strict) {
87
+ export function check(measured, budget, strict) {
50
88
  const rows = [];
51
89
  let failed = false;
52
- for (const cost of costs) {
53
- const declared = budget.circuits[cost.circuit];
54
- if (!declared) {
55
- rows.push({ circuit: cost.circuit, status: "undeclared", k: cost.k });
56
- if (strict)
90
+ for (const { source, costs } of measured) {
91
+ const declared = budget.contracts[source]?.circuits ??
92
+ // A legacy budget stores its single contract under "", so fall back to it
93
+ // when only one contract is declared and the path does not match.
94
+ (Object.keys(budget.contracts).length === 1
95
+ ? Object.values(budget.contracts)[0].circuits
96
+ : {});
97
+ for (const cost of costs) {
98
+ const entry = declared[cost.circuit];
99
+ if (!entry) {
100
+ rows.push({
101
+ contract: source,
102
+ circuit: cost.circuit,
103
+ status: "undeclared",
104
+ k: cost.k,
105
+ });
106
+ if (strict)
107
+ failed = true;
108
+ continue;
109
+ }
110
+ let status = "under";
111
+ if (cost.k > entry.maxK) {
112
+ status = "over";
57
113
  failed = true;
58
- continue;
114
+ }
115
+ else if (cost.k === entry.maxK) {
116
+ status = "at";
117
+ }
118
+ rows.push({
119
+ contract: source,
120
+ circuit: cost.circuit,
121
+ status,
122
+ k: cost.k,
123
+ maxK: entry.maxK,
124
+ });
59
125
  }
60
- let status = "under";
61
- if (cost.k > declared.maxK) {
62
- status = "over";
63
- failed = true;
64
- }
65
- else if (cost.k === declared.maxK) {
66
- status = "at";
67
- }
68
- rows.push({ circuit: cost.circuit, status, k: cost.k, maxK: declared.maxK });
69
- }
70
- const measured = new Set(costs.map((c) => c.circuit));
71
- for (const name of Object.keys(budget.circuits)) {
72
- if (!measured.has(name)) {
73
- rows.push({ circuit: name, status: "stale", maxK: budget.circuits[name].maxK });
126
+ const seen = new Set(costs.map((c) => c.circuit));
127
+ for (const [name, entry] of Object.entries(declared)) {
128
+ if (!seen.has(name)) {
129
+ rows.push({ contract: source, circuit: name, status: "stale", maxK: entry.maxK });
130
+ }
74
131
  }
75
132
  }
76
133
  return { rows, failed };
@@ -0,0 +1,16 @@
1
+ import type { Measurement } from "./measure.ts";
2
+ import type { Toolchain } from "./toolchain.ts";
3
+ /**
4
+ * Measurement cache, keyed on the emitted IR rather than on the source.
5
+ *
6
+ * Compilation is the cheap half: on a nine circuit contract it took 1.1s
7
+ * against 17s to measure. So the compile always runs, and its output is used as
8
+ * the cache key. Identical IR provably yields identical constraint counts, so
9
+ * this cannot go stale in a way that produces a wrong answer, which a source
10
+ * timestamp or a partial import scan could.
11
+ */
12
+ export declare function cacheDir(): string;
13
+ /** Hash every emitted IR file, plus the toolchain that produced and reads it. */
14
+ export declare function cacheKey(zkirDir: string, toolchain: Toolchain): string;
15
+ export declare function readCache(key: string): Measurement[] | undefined;
16
+ export declare function writeCache(key: string, measurements: Measurement[]): void;
package/dist/cache.js ADDED
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { toolVersion } from "./version.js";
6
+ /**
7
+ * Measurement cache, keyed on the emitted IR rather than on the source.
8
+ *
9
+ * Compilation is the cheap half: on a nine circuit contract it took 1.1s
10
+ * against 17s to measure. So the compile always runs, and its output is used as
11
+ * the cache key. Identical IR provably yields identical constraint counts, so
12
+ * this cannot go stale in a way that produces a wrong answer, which a source
13
+ * timestamp or a partial import scan could.
14
+ */
15
+ export function cacheDir() {
16
+ return process.env.NITE_ZK_CACHE || join(tmpdir(), "nite-zk-profiler-cache");
17
+ }
18
+ /** Hash every emitted IR file, plus the toolchain that produced and reads it. */
19
+ export function cacheKey(zkirDir, toolchain) {
20
+ const hash = createHash("sha256");
21
+ // The profiler's own version is part of the key. Without it, upgrading the
22
+ // tool would keep serving results produced by the previous measurement code,
23
+ // which looks exactly like the upgrade having no effect.
24
+ hash.update(toolVersion());
25
+ hash.update(toolchain.version);
26
+ hash.update(toolchain.zkirVersion);
27
+ for (const file of readdirSync(zkirDir).filter((f) => f.endsWith(".zkir")).sort()) {
28
+ hash.update(file);
29
+ hash.update(readFileSync(join(zkirDir, file)));
30
+ }
31
+ return hash.digest("hex").slice(0, 32);
32
+ }
33
+ export function readCache(key) {
34
+ const file = join(cacheDir(), `${key}.json`);
35
+ if (!existsSync(file))
36
+ return undefined;
37
+ try {
38
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
39
+ return Array.isArray(parsed) ? parsed : undefined;
40
+ }
41
+ catch {
42
+ // A corrupt entry is not worth failing over; measure again instead.
43
+ return undefined;
44
+ }
45
+ }
46
+ export function writeCache(key, measurements) {
47
+ try {
48
+ mkdirSync(cacheDir(), { recursive: true });
49
+ writeFileSync(join(cacheDir(), `${key}.json`), JSON.stringify(measurements), "utf8");
50
+ }
51
+ catch {
52
+ // Caching is an optimisation. Failing to write one must never fail a run.
53
+ }
54
+ }
package/dist/cli.d.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  interface Options {
3
3
  command?: string;
4
- source?: string;
4
+ sources: string[];
5
+ ref?: string;
5
6
  json: boolean;
6
7
  strict: boolean;
8
+ deep: boolean;
9
+ estimate: boolean;
10
+ noColor: boolean;
11
+ noCache: boolean;
7
12
  out?: string;
8
13
  budget: string;
9
14
  versionArg?: string;
15
+ observedMs?: number;
16
+ atK?: number;
10
17
  help: boolean;
11
18
  version: boolean;
12
19
  }
13
20
  export declare function parseArgs(argv: string[]): Options;
14
- export declare function run(argv: string[]): number;
15
- export declare function main(argv: string[]): number;
21
+ export declare function run(argv: string[]): Promise<number>;
22
+ export declare function main(argv: string[]): Promise<number>;
16
23
  export {};