@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/README.md CHANGED
@@ -370,15 +370,113 @@ Anything outside this range is rejected with a clear error naming the version fo
370
370
  ## Command line surface
371
371
 
372
372
  ```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
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...>] Compare against zk-budget.json
376
+ nite-zk diff <ref> [<source>] Compare a contract against a git ref
377
+ nite-zk calibrate --observed <ms> --at-k <k>
378
+ Anchor proving estimates to a real proof
379
+
380
+ --estimate Show modelled proving time per circuit
381
+ --deep Also generate real proving keys, and report
382
+ measured setup time and prover key size
383
+ --json Machine readable output
384
+ --out <dir> Compile into a specific directory
385
+ --budget <file> Use a different baseline path
386
+ --strict check: fail on circuits missing from the budget
387
+ --no-color Plain output (NO_COLOR is honoured too)
388
+ --no-cache Ignore cached measurements for this run
389
+ +VERSION Pin the Compact toolchain, e.g. +0.31.1
390
+ ```
391
+
392
+ ### Several contracts at once
393
+
394
+ Pass more than one entry point, and one budget file covers them all:
395
+
396
+ ```text
397
+ $ nite-zk save packages/pool/src/lending.compact packages/mint/src/mint.compact
398
+ Wrote zk-budget.json: 2 contracts, 16 circuits
399
+ ```
400
+
401
+ `check` then reads every contract back out of the budget, so CI stays one step
402
+ whether the repository holds one contract or ten.
403
+
404
+ ### Comparing against a branch
405
+
406
+ ```text
407
+ $ nite-zk diff main
408
+
409
+ transferFunds k 15 -> 16 +1, about 2x more expensive
410
+ proveMembership k 16 -> 16 unchanged
411
+ newHelper k - -> 9 new circuit
412
+
413
+ 1 circuit more expensive than main
414
+ ```
376
415
 
377
- --json Machine readable output
378
- --out <dir> Compile into a specific directory
379
- --budget <file> Use a different baseline path
416
+ The ref is exported with `git archive` into a temporary directory, so the
417
+ working tree and the index are never touched. Exits nonzero when a circuit that
418
+ already existed got more expensive, which makes it usable as a pull request
419
+ check without committing a budget file first.
420
+
421
+ ### Estimated proving time
422
+
423
+ `--estimate` models proving time as `time = rate * 2^k`, since a Halo2 proof is
424
+ dominated by work over the full `2^k` domain.
425
+
426
+ The rate is machine specific, so the shipped default is only an order of
427
+ magnitude. Time one real proof and record it, and every estimate becomes
428
+ anchored to your own prover:
429
+
430
+ ```text
431
+ $ nite-zk calibrate --observed 9000 --at-k 16
432
+ Calibrated: 0.1373 ms per domain row, from 9000ms at k=16.
380
433
  ```
381
434
 
435
+ Two limits worth stating plainly. Proving delegated to a remote proof server is
436
+ often dominated by network round trip and server load rather than by circuit
437
+ size, and no model of `k` can see that. And the same gate degree effect that
438
+ makes key size unpredictable, described below, puts a factor of about two around
439
+ any figure derived from `k` alone. Treat it as a ratio you can reason with, not
440
+ a stopwatch.
441
+
442
+ `save` records which contracts the budget describes, so `check` needs no
443
+ arguments afterwards. That is what makes it a one line CI step. It also records
444
+ rows, capacity and relative cost per circuit, so a reviewer reading the diff can
445
+ see what changed without running anything. Only `maxK` is enforced.
446
+
447
+ ### Measuring the real thing
448
+
449
+ `--deep` stops mocking and generates actual proving keys. It answers two
450
+ questions the fast path cannot, and both are measured rather than modelled:
451
+
452
+ ```text
453
+ circuit rows k capacity cost setup prover key
454
+ k05 24 5 32 1x 156ms 14 KB
455
+ k13 4189 13 8192 256x 2.3s 2.7 MB
456
+ k14 11965 14 16384 512x 4.6s 5.0 MB
457
+ k15 19657 15 32768 1024x 9.6s 9.5 MB
458
+ ```
459
+
460
+ Setup time and prover key size both roughly double per step in `k`, which is
461
+ the same 2^k curve proving time follows. The prover key size is worth attention
462
+ on its own: it is what users download and hold in memory, and a k=15 circuit
463
+ carries a 9.5 MB key against 14 KB at k=5.
464
+
465
+ This mode is slow by design, since it does the work `--skip-zk` exists to avoid.
466
+ Use it when choosing between designs, not in an edit loop.
467
+
468
+ **Why key size is measured rather than predicted.** It looks predictable from
469
+ `k`: three structurally unrelated circuits at k=13 agreed within 0.11%, and a
470
+ synthetic k=15 circuit matched a production one to 0.08%. It does not hold. At
471
+ k=16 a production circuit produced 19,524,757 bytes while a synthetic one
472
+ produced 38,513,181, a factor of two apart, with identical verifier key sizes.
473
+
474
+ The likely cause is the extended evaluation domain, which Halo2 sizes by maximum
475
+ gate degree, and which `mock-compile` does not report. From `k` alone there is
476
+ no way to tell which case a circuit is in, and a figure that is exact at one `k`
477
+ and twice wrong at the next is worse than no figure. So key size comes only from
478
+ `--deep`, where it is measured.
479
+
382
480
  Designed to work as a single CI step:
383
481
 
384
482
  ```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 {};