sphere-cli 0.1.40 → 0.1.41

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/bin/sphere.js CHANGED
@@ -4,15 +4,12 @@
4
4
  /**
5
5
  * sphere — CLI entry point.
6
6
  *
7
- * This wrapper handles argument parsing, progress display, and license checks,
8
- * then delegates generation to sphere-node.jsc (V8 bytecode, bytenode-loaded).
9
- *
10
7
  * Commands:
11
8
  * sphere generate <input.csv> -o <output.csv> [options]
12
- * sphere license activate|status|clear
9
+ * sphere evaluate <real.csv> <synth.csv> [options]
10
+ * sphere certify <real.csv> <synth.csv> -o <cert.html> [options]
13
11
  * sphere demo
14
- * sphere evaluate — not available in this version (requires Python sidecar)
15
- * sphere certify — not available in this version (requires Python sidecar)
12
+ * sphere license activate|status|clear
16
13
  */
17
14
 
18
15
  const { spawn } = require('child_process');
@@ -23,6 +20,8 @@ const os = require('os');
23
20
 
24
21
  const PKG_DIR = path.join(__dirname, '..');
25
22
  const SPHERE_NODE_JS = path.join(PKG_DIR, 'sphere-node.js');
23
+ const EVALUATE_PY = path.join(PKG_DIR, 'scripts', 'evaluate.py');
24
+ const CERTIFY_PY = path.join(PKG_DIR, 'scripts', 'certify.py');
26
25
  const VERSION = require(path.join(PKG_DIR, 'package.json')).version;
27
26
 
28
27
  // ── License ───────────────────────────────────────────────────────────────────
@@ -150,6 +149,132 @@ function clearLine() {
150
149
  process.stderr.write(`\r${' '.repeat(BAR_WIDTH + 60)}\r`);
151
150
  }
152
151
 
152
+ // ── Score bar (for evaluate output) ──────────────────────────────────────────
153
+
154
+ function scoreBar(score, width = 20) {
155
+ const filled = Math.round(width * score / 100);
156
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
157
+ }
158
+
159
+ function printEvalResults(result) {
160
+ const fid = result.fidelity;
161
+ const priv = result.privacy;
162
+ const sep = '─'.repeat(36);
163
+
164
+ process.stdout.write('\n');
165
+ process.stdout.write(' Fidelity\n');
166
+ process.stdout.write(` ${sep}\n`);
167
+ for (const [label, key] of [
168
+ ['Mean', 'meanScore'],
169
+ ['Variance', 'varScore'],
170
+ ['Correlation', 'corScore'],
171
+ ['KS', 'ksScore'],
172
+ ]) {
173
+ const v = fid[key];
174
+ process.stdout.write(` ${label.padEnd(14)} ${v.toFixed(1).padStart(5)} ${scoreBar(v)}\n`);
175
+ }
176
+ process.stdout.write(` ${sep}\n`);
177
+ const fc = fid.composite;
178
+ process.stdout.write(` ${'Composite'.padEnd(14)} ${fc.toFixed(1).padStart(5)} ${scoreBar(fc)}\n`);
179
+
180
+ if (priv) {
181
+ process.stdout.write('\n');
182
+ process.stdout.write(' Privacy\n');
183
+ process.stdout.write(` ${sep}\n`);
184
+ for (const [label, key] of [
185
+ ['Singling Out', 'singlingOut'],
186
+ ['Linkability', 'linkability'],
187
+ ['Inference', 'inference'],
188
+ ]) {
189
+ const v = priv[key].score;
190
+ process.stdout.write(` ${label.padEnd(14)} ${v.toFixed(1).padStart(5)} ${scoreBar(v)}\n`);
191
+ }
192
+ process.stdout.write(` ${sep}\n`);
193
+ const pc = priv.composite;
194
+ process.stdout.write(` ${'Composite'.padEnd(14)} ${pc.toFixed(1).padStart(5)} ${scoreBar(pc)}\n`);
195
+ } else if (result.privacy === null) {
196
+ process.stdout.write('\n Privacy: skipped (--skip-privacy)\n');
197
+ }
198
+
199
+ process.stdout.write('\n');
200
+ const excluded = result.idColsExcluded || [];
201
+ const exclNote = excluded.length ? ` (${excluded.length} ID col${excluded.length > 1 ? 's' : ''} excluded)` : '';
202
+ process.stdout.write(
203
+ ` ${(result.nReal || 0).toLocaleString()} real rows \xd7 ${result.pOrig || '?'} cols` +
204
+ ` vs ${(result.nSynth || 0).toLocaleString()} synthetic rows${exclNote}\n`,
205
+ );
206
+ process.stdout.write('\n');
207
+ }
208
+
209
+ // ── Python runner (shared by evaluate + certify) ──────────────────────────────
210
+
211
+ function findPython() {
212
+ // Try python3 first, then python
213
+ for (const bin of ['python3', 'python']) {
214
+ try {
215
+ const { execFileSync } = require('child_process');
216
+ const out = execFileSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
217
+ if (out.includes('Python 3')) return bin;
218
+ } catch { /* try next */ }
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function runPythonScript(script, scriptArgs, { json: jsonMode = false } = {}) {
224
+ return new Promise((resolve) => {
225
+ const python = findPython();
226
+ if (!python) {
227
+ process.stderr.write(
228
+ 'Error: Python 3 not found. Evaluation requires Python 3.10+.\n' +
229
+ ' Install Python from https://python.org and retry.\n',
230
+ );
231
+ process.exit(1);
232
+ }
233
+
234
+ const proc = spawn(python, [script, ...scriptArgs], { stdio: ['ignore', 'pipe', 'pipe'] });
235
+
236
+ let resultLine = '';
237
+ let errorLine = '';
238
+
239
+ proc.stdout.on('data', (d) => { resultLine += d.toString(); });
240
+
241
+ proc.stderr.on('data', (d) => {
242
+ for (const line of d.toString().split('\n')) {
243
+ const s = line.trim();
244
+ if (!s) continue;
245
+ try {
246
+ const msg = JSON.parse(s);
247
+ if (typeof msg.progress === 'number' && !jsonMode) {
248
+ progressBar(msg.progress, msg.msg || '');
249
+ } else if (msg.error) {
250
+ errorLine = msg.error;
251
+ }
252
+ } catch { /* ignore non-JSON lines */ }
253
+ }
254
+ });
255
+
256
+ proc.on('close', (code) => {
257
+ if (!jsonMode) clearLine();
258
+ if (code !== 0) {
259
+ process.stderr.write(`Error: ${errorLine || 'evaluation process exited with code ' + code}\n`);
260
+ process.exit(1);
261
+ }
262
+ let result;
263
+ try { result = JSON.parse(resultLine.trim()); }
264
+ catch {
265
+ process.stderr.write('Error: could not parse evaluation output\n');
266
+ process.exit(1);
267
+ }
268
+ resolve(result);
269
+ });
270
+
271
+ proc.on('error', (err) => {
272
+ process.stderr.write(`Error: failed to start Python: ${err.message}\n`);
273
+ process.exit(1);
274
+ });
275
+ });
276
+ }
277
+
153
278
  // ── Argument parsing helpers ──────────────────────────────────────────────────
154
279
 
155
280
  function parseArgs(argv) {
@@ -197,7 +322,6 @@ async function cmdGenerate(pos, flags) {
197
322
  process.stderr.write(`Generating synthetic data from ${path.basename(input)} …\n`);
198
323
  }
199
324
 
200
- // Build args for sphere-node.js
201
325
  const nodeArgs = [SPHERE_NODE_JS, input, output];
202
326
  if (flags.k) nodeArgs.push('--k', String(flags.k));
203
327
  if (flags.theta) nodeArgs.push('--theta', String(flags.theta));
@@ -245,7 +369,7 @@ async function cmdGenerate(pos, flags) {
245
369
 
246
370
  if (flags.json) {
247
371
  process.stdout.write(JSON.stringify(result) + '\n');
248
- resolve();
372
+ resolve(result);
249
373
  return;
250
374
  }
251
375
 
@@ -257,7 +381,7 @@ async function cmdGenerate(pos, flags) {
257
381
  if (result.idColDetected) {
258
382
  process.stdout.write(` ID columns excluded: ${result.idColName}\n`);
259
383
  }
260
- resolve();
384
+ resolve(result);
261
385
  });
262
386
 
263
387
  proc.on('error', (err) => {
@@ -267,6 +391,90 @@ async function cmdGenerate(pos, flags) {
267
391
  });
268
392
  }
269
393
 
394
+ // ── sphere evaluate ───────────────────────────────────────────────────────────
395
+
396
+ async function cmdEvaluate(pos, flags) {
397
+ await checkLicense();
398
+
399
+ const real = pos[0];
400
+ const synth = pos[1];
401
+
402
+ if (!real) { process.stderr.write('Error: missing real CSV\n'); process.exit(1); }
403
+ if (!synth) { process.stderr.write('Error: missing synth CSV\n'); process.exit(1); }
404
+ if (!fs.existsSync(real)) { process.stderr.write(`Error: file not found: ${real}\n`); process.exit(1); }
405
+ if (!fs.existsSync(synth)) { process.stderr.write(`Error: file not found: ${synth}\n`); process.exit(1); }
406
+
407
+ if (!flags.json) {
408
+ process.stderr.write(`Evaluating ${path.basename(real)} vs ${path.basename(synth)} …\n`);
409
+ }
410
+
411
+ const scriptArgs = [real, synth];
412
+ if (flags['skip-privacy']) scriptArgs.push('--skip-privacy');
413
+ if (flags['n-attacks']) scriptArgs.push('--n-attacks', String(flags['n-attacks']));
414
+ if (flags['n-secrets']) scriptArgs.push('--n-secrets', String(flags['n-secrets']));
415
+ if (flags['n-atk-cap']) scriptArgs.push('--n-atk-cap', String(flags['n-atk-cap']));
416
+ if (flags['n-neighbors']) scriptArgs.push('--n-neighbors', String(flags['n-neighbors']));
417
+ if (flags['n-aux-cols']) scriptArgs.push('--n-aux-cols', String(flags['n-aux-cols']));
418
+ if (flags.seed) scriptArgs.push('--seed', String(flags.seed));
419
+
420
+ const result = await runPythonScript(EVALUATE_PY, scriptArgs, { json: !!flags.json });
421
+
422
+ if (flags.json) {
423
+ process.stdout.write(JSON.stringify(result) + '\n');
424
+ return result;
425
+ }
426
+
427
+ const evalS = ((result.runMs || 0) / 1000).toFixed(1);
428
+ process.stdout.write(`✓ Evaluation complete (${evalS} s)\n`);
429
+ printEvalResults(result);
430
+ return result;
431
+ }
432
+
433
+ // ── sphere certify ────────────────────────────────────────────────────────────
434
+
435
+ async function cmdCertify(pos, flags) {
436
+ await checkLicense();
437
+
438
+ const real = pos[0];
439
+ const synth = pos[1];
440
+ const output = flags.output;
441
+
442
+ if (!real) { process.stderr.write('Error: missing real CSV\n'); process.exit(1); }
443
+ if (!synth) { process.stderr.write('Error: missing synth CSV\n'); process.exit(1); }
444
+ if (!output) { process.stderr.write('Error: missing -o / --output path\n'); process.exit(1); }
445
+ if (!fs.existsSync(real)) { process.stderr.write(`Error: file not found: ${real}\n`); process.exit(1); }
446
+ if (!fs.existsSync(synth)) { process.stderr.write(`Error: file not found: ${synth}\n`); process.exit(1); }
447
+
448
+ if (!flags.json) {
449
+ process.stderr.write(`Certifying ${path.basename(real)} vs ${path.basename(synth)} …\n`);
450
+ }
451
+
452
+ const scriptArgs = [real, synth, '-o', output];
453
+ if (flags['skip-privacy']) scriptArgs.push('--skip-privacy');
454
+ if (flags['n-attacks']) scriptArgs.push('--n-attacks', String(flags['n-attacks']));
455
+ if (flags['n-secrets']) scriptArgs.push('--n-secrets', String(flags['n-secrets']));
456
+ if (flags['n-atk-cap']) scriptArgs.push('--n-atk-cap', String(flags['n-atk-cap']));
457
+ if (flags['n-neighbors']) scriptArgs.push('--n-neighbors', String(flags['n-neighbors']));
458
+ if (flags['n-aux-cols']) scriptArgs.push('--n-aux-cols', String(flags['n-aux-cols']));
459
+ if (flags.seed) scriptArgs.push('--seed', String(flags.seed));
460
+ if (flags.k) scriptArgs.push('--k', String(flags.k));
461
+ if (flags.theta) scriptArgs.push('--theta', String(flags.theta));
462
+ if (flags.delta) scriptArgs.push('--delta', String(flags.delta));
463
+ if (flags['mix-prob']) scriptArgs.push('--mix-prob', String(flags['mix-prob']));
464
+ if (flags['seed-gen']) scriptArgs.push('--seed-gen', String(flags['seed-gen']));
465
+ if (flags['generated-at']) scriptArgs.push('--generated-at', flags['generated-at']);
466
+
467
+ const result = await runPythonScript(CERTIFY_PY, scriptArgs, { json: !!flags.json });
468
+
469
+ if (flags.json) {
470
+ process.stdout.write(JSON.stringify(result) + '\n');
471
+ return;
472
+ }
473
+
474
+ const elapsedS = ((result.elapsedMs || 0) / 1000).toFixed(1);
475
+ process.stdout.write(`✓ ${output} (${elapsedS} s)\n`);
476
+ }
477
+
270
478
  // ── sphere demo ───────────────────────────────────────────────────────────────
271
479
 
272
480
  async function cmdDemo() {
@@ -280,17 +488,35 @@ async function cmdDemo() {
280
488
  const tmpOut = path.join(os.tmpdir(), `sphere-demo-${randomUUID()}.csv`);
281
489
 
282
490
  process.stdout.write('SPHERE demo — built-in NHANES dataset (4,899 rows \xd7 18 cols)\n');
283
- process.stdout.write('─'.repeat(52) + '\n\n');
491
+ process.stdout.write('─'.repeat(52) + '\n');
284
492
 
285
493
  try {
494
+ // ── Generate ──────────────────────────────────────────────────────────────
495
+ process.stdout.write('\n');
286
496
  await cmdGenerate([exampleCsv], { output: tmpOut });
497
+
498
+ // ── Evaluate ──────────────────────────────────────────────────────────────
499
+ process.stdout.write('\n');
500
+ process.stderr.write(`Evaluating ${path.basename(exampleCsv)} vs synthetic …\n`);
501
+
502
+ const scriptArgs = [exampleCsv, tmpOut, '--n-attacks', '200', '--n-atk-cap', '1000'];
503
+ const result = await runPythonScript(EVALUATE_PY, scriptArgs);
504
+
505
+ const evalS = ((result.runMs || 0) / 1000).toFixed(1);
506
+ process.stdout.write(`✓ Evaluation complete (${evalS} s)\n`);
507
+ printEvalResults(result);
508
+
509
+ } catch (err) {
510
+ process.stderr.write(`\nError during demo: ${err.message}\n`);
287
511
  } finally {
288
512
  try { fs.unlinkSync(tmpOut); } catch {}
289
513
  try { fs.unlinkSync(tmpOut + '.sphere.json'); } catch {}
290
514
  }
291
515
 
292
- process.stdout.write('\nTry it on your own data:\n');
293
- process.stdout.write(' sphere generate your_data.csv -o synthetic.csv\n\n');
516
+ process.stdout.write('Try it on your own data:\n');
517
+ process.stdout.write(' sphere generate your_data.csv -o synthetic.csv\n');
518
+ process.stdout.write(' sphere evaluate your_data.csv synthetic.csv\n');
519
+ process.stdout.write(' sphere certify your_data.csv synthetic.csv -o report.html\n\n');
294
520
  }
295
521
 
296
522
  // ── sphere license ────────────────────────────────────────────────────────────
@@ -299,7 +525,6 @@ async function cmdLicense(sub, pos, flags) {
299
525
  if (sub === 'activate') {
300
526
  let key = (pos[0] || flags.key || '').trim();
301
527
  if (!key) {
302
- // Simple stdin prompt (no readline dependency)
303
528
  process.stdout.write('SPHERE license key (sphere_…): ');
304
529
  key = await new Promise((resolve) => {
305
530
  let buf = '';
@@ -395,9 +620,11 @@ async function main() {
395
620
 
396
621
  if (!cmd || flags.help || flags.h) {
397
622
  process.stdout.write(
398
- `SPHERE CLI v${VERSION} — synthetic data generation\n\n` +
623
+ `SPHERE CLI v${VERSION} — synthetic data generation, evaluation & certification\n\n` +
399
624
  'Usage:\n' +
400
- ' sphere generate <input.csv> -o <output.csv> [options]\n' +
625
+ ' sphere generate <input.csv> -o <output.csv> [options]\n' +
626
+ ' sphere evaluate <real.csv> <synth.csv> [options]\n' +
627
+ ' sphere certify <real.csv> <synth.csv> -o <cert.html> [options]\n' +
401
628
  ' sphere license activate|status|clear\n' +
402
629
  ' sphere demo\n\n' +
403
630
  'Generate options:\n' +
@@ -406,6 +633,12 @@ async function main() {
406
633
  ' --delta <float> Per-pair angle jitter (default: 5° ≈ 0.087)\n' +
407
634
  ' --mix-prob <float> Privacy/utility trade-off (default: 0.75)\n' +
408
635
  ' --seed <int> Integer seed for reproducible output\n' +
636
+ ' --json Machine-readable JSON output\n\n' +
637
+ 'Evaluate / Certify options:\n' +
638
+ ' --skip-privacy Fidelity only — skip privacy attacks (faster)\n' +
639
+ ' --n-attacks <int> Attack draws per metric (default: 500)\n' +
640
+ ' --n-atk-cap <int> Row subsample cap for attacks (default: 2000)\n' +
641
+ ' --seed <int> RNG seed for reproducible scores\n' +
409
642
  ' --json Machine-readable JSON output\n',
410
643
  );
411
644
  process.exit(0);
@@ -416,6 +649,16 @@ async function main() {
416
649
  return;
417
650
  }
418
651
 
652
+ if (cmd === 'evaluate') {
653
+ await cmdEvaluate(pos.slice(1), flags);
654
+ return;
655
+ }
656
+
657
+ if (cmd === 'certify') {
658
+ await cmdCertify(pos.slice(1), flags);
659
+ return;
660
+ }
661
+
419
662
  if (cmd === 'demo') {
420
663
  await cmdDemo();
421
664
  return;
@@ -426,14 +669,6 @@ async function main() {
426
669
  return;
427
670
  }
428
671
 
429
- if (cmd === 'evaluate' || cmd === 'certify') {
430
- process.stderr.write(
431
- `'sphere ${cmd}' requires the Python evaluation engine, which is not included\n` +
432
- 'in this version. Coming soon.\n',
433
- );
434
- process.exit(1);
435
- }
436
-
437
672
  process.stderr.write(`Unknown command: ${cmd}\nRun 'sphere --help' for usage.\n`);
438
673
  process.exit(1);
439
674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphere-cli",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "SPHERE CLI — synthetic data generation, evaluation, and certification",
5
5
  "keywords": ["synthetic-data", "privacy", "cli", "data-science"],
6
6
  "homepage": "https://github.com/statzihuai/sphere-cli",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "license": "SEE LICENSE IN LICENSE",
13
13
  "bin": {
14
- "sphere": "./bin/sphere.js"
14
+ "sphere": "bin/sphere.js"
15
15
  },
16
16
  "scripts": {
17
17
  "postinstall": "node scripts/postinstall.js"
@@ -23,6 +23,9 @@
23
23
  "bin/",
24
24
  "examples/",
25
25
  "scripts/postinstall.js",
26
+ "scripts/evaluate.py",
27
+ "scripts/certify.py",
28
+ "sphere_cli/",
26
29
  "sphere-node.js"
27
30
  ],
28
31
  "engines": {
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """Thin subprocess wrapper — evaluate and produce an HTML certificate.
3
+
4
+ Called by sphere.js as:
5
+ python3 <this-file> real.csv synth.csv -o cert.html [options]
6
+
7
+ Streams {"progress": f, "msg": "..."} JSON lines to stderr.
8
+ Writes {"ok": true, "output": "cert.html", "elapsedMs": N} to stdout on success,
9
+ or {"error": "..."} on failure.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ _PKG = Path(__file__).resolve().parent.parent
20
+ sys.path.insert(0, str(_PKG))
21
+
22
+
23
+ def main() -> None:
24
+ p = argparse.ArgumentParser()
25
+ p.add_argument('real')
26
+ p.add_argument('synth')
27
+ p.add_argument('-o', '--output', required=True)
28
+ p.add_argument('--skip-privacy', action='store_true')
29
+ p.add_argument('--n-attacks', type=int, default=500)
30
+ p.add_argument('--n-secrets', type=int, default=5)
31
+ p.add_argument('--n-atk-cap', type=int, default=2000)
32
+ p.add_argument('--n-neighbors', type=int, default=1)
33
+ p.add_argument('--n-aux-cols', type=int, default=20)
34
+ p.add_argument('--seed', type=int, default=None)
35
+ # Generation provenance (optional — marks certificate as SPHERE-generated)
36
+ p.add_argument('--k', type=int, default=None)
37
+ p.add_argument('--theta', type=float, default=None)
38
+ p.add_argument('--delta', type=float, default=None)
39
+ p.add_argument('--mix-prob', type=float, default=None)
40
+ p.add_argument('--seed-gen', type=int, default=None)
41
+ p.add_argument('--generated-at', type=str, default=None)
42
+ args = p.parse_args()
43
+
44
+ def on_progress(frac: float, msg: str) -> None:
45
+ sys.stderr.write(json.dumps({'progress': round(frac, 4), 'msg': msg}) + '\n')
46
+ sys.stderr.flush()
47
+
48
+ try:
49
+ from sphere_cli._evaluate import evaluate # type: ignore
50
+ from sphere_cli._certify import build_certificate_html # type: ignore
51
+ except ImportError as e:
52
+ sys.stderr.write(json.dumps({'error': f'Evaluation engine not available: {e}'}) + '\n')
53
+ sys.exit(1)
54
+
55
+ t0 = time.perf_counter()
56
+
57
+ try:
58
+ result = evaluate(
59
+ real_path = Path(args.real),
60
+ synth_path = Path(args.synth),
61
+ n_attacks = args.n_attacks,
62
+ n_secrets = args.n_secrets,
63
+ n_atk_cap = args.n_atk_cap,
64
+ n_neighbors = args.n_neighbors,
65
+ n_aux_cols = args.n_aux_cols,
66
+ seed = args.seed,
67
+ skip_privacy = args.skip_privacy,
68
+ on_progress = on_progress,
69
+ )
70
+ except ValueError as e:
71
+ sys.stderr.write(json.dumps({'error': str(e)}) + '\n')
72
+ sys.exit(1)
73
+ except Exception as e:
74
+ import traceback
75
+ sys.stderr.write(json.dumps({'error': f'Unexpected error: {e}\n{traceback.format_exc()}'}) + '\n')
76
+ sys.exit(1)
77
+
78
+ result['elapsedMs'] = int((time.perf_counter() - t0) * 1000)
79
+
80
+ # Auto-load .sphere.json sidecar written by sphere generate
81
+ synth_path = Path(args.synth)
82
+ meta: dict = {}
83
+ meta_path = synth_path.with_suffix('.sphere.json')
84
+ if meta_path.exists():
85
+ try:
86
+ meta = json.loads(meta_path.read_text(encoding='utf-8'))
87
+ except Exception:
88
+ pass
89
+
90
+ # CLI flags win; sidecar fills gaps; defaults last
91
+ _theta = args.theta if args.theta is not None else meta.get('theta')
92
+ _delta = args.delta if args.delta is not None else meta.get('delta')
93
+ _mix_prob = args.mix_prob if args.mix_prob is not None else meta.get('mix_prob')
94
+ _k = args.k if args.k is not None else meta.get('k')
95
+ _seed_gen = args.seed_gen if args.seed_gen is not None else meta.get('seed')
96
+ _gen_at = args.generated_at if args.generated_at is not None else meta.get('generated_at')
97
+
98
+ import math
99
+ gen_params = None
100
+ if any(x is not None for x in [_theta, _delta, _mix_prob, _k, _seed_gen, _gen_at]):
101
+ gen_params = {
102
+ 'theta': _theta if _theta is not None else math.pi / 6,
103
+ 'delta': _delta if _delta is not None else 5 * math.pi / 180,
104
+ 'mix_prob': _mix_prob if _mix_prob is not None else 0.75,
105
+ 'k': _k if _k is not None else 2,
106
+ 'seed': _seed_gen,
107
+ }
108
+
109
+ try:
110
+ html = build_certificate_html(
111
+ result = result,
112
+ real_path = Path(args.real),
113
+ synth_path = synth_path,
114
+ generation_params = gen_params,
115
+ generated_at = _gen_at,
116
+ )
117
+ except Exception as e:
118
+ sys.stderr.write(json.dumps({'error': f'Certificate build failed: {e}'}) + '\n')
119
+ sys.exit(1)
120
+
121
+ out_path = Path(args.output)
122
+ out_path.parent.mkdir(parents=True, exist_ok=True)
123
+ out_path.write_text(html, encoding='utf-8')
124
+
125
+ elapsed_ms = int((time.perf_counter() - t0) * 1000)
126
+ sys.stdout.write(json.dumps({'ok': True, 'output': str(out_path), 'elapsedMs': elapsed_ms}) + '\n')
127
+ sys.exit(0)
128
+
129
+
130
+ if __name__ == '__main__':
131
+ main()
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """Thin subprocess wrapper — evaluate a real/synthetic CSV pair.
3
+
4
+ Called by sphere.js as:
5
+ python3 <this-file> real.csv synth.csv [options]
6
+
7
+ Streams {"progress": f, "msg": "..."} JSON lines to stderr.
8
+ Writes the final result dict as a single JSON line to stdout.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # Make sphere_cli importable from the npm package root (one level up)
18
+ _PKG = Path(__file__).resolve().parent.parent
19
+ sys.path.insert(0, str(_PKG))
20
+
21
+
22
+ def main() -> None:
23
+ p = argparse.ArgumentParser()
24
+ p.add_argument('real')
25
+ p.add_argument('synth')
26
+ p.add_argument('--skip-privacy', action='store_true')
27
+ p.add_argument('--n-attacks', type=int, default=500)
28
+ p.add_argument('--n-secrets', type=int, default=5)
29
+ p.add_argument('--n-atk-cap', type=int, default=2000)
30
+ p.add_argument('--n-neighbors', type=int, default=1)
31
+ p.add_argument('--n-aux-cols', type=int, default=20)
32
+ p.add_argument('--seed', type=int, default=None)
33
+ args = p.parse_args()
34
+
35
+ def on_progress(frac: float, msg: str) -> None:
36
+ sys.stderr.write(json.dumps({'progress': round(frac, 4), 'msg': msg}) + '\n')
37
+ sys.stderr.flush()
38
+
39
+ try:
40
+ from sphere_cli._evaluate import evaluate # type: ignore
41
+ except ImportError as e:
42
+ sys.stderr.write(json.dumps({'error': f'Evaluation engine not available: {e}'}) + '\n')
43
+ sys.exit(1)
44
+
45
+ try:
46
+ result = evaluate(
47
+ real_path = Path(args.real),
48
+ synth_path = Path(args.synth),
49
+ n_attacks = args.n_attacks,
50
+ n_secrets = args.n_secrets,
51
+ n_atk_cap = args.n_atk_cap,
52
+ n_neighbors = args.n_neighbors,
53
+ n_aux_cols = args.n_aux_cols,
54
+ seed = args.seed,
55
+ skip_privacy = args.skip_privacy,
56
+ on_progress = on_progress,
57
+ )
58
+ sys.stdout.write(json.dumps(result) + '\n')
59
+ sys.exit(0)
60
+ except ValueError as e:
61
+ sys.stderr.write(json.dumps({'error': str(e)}) + '\n')
62
+ sys.exit(1)
63
+ except Exception as e:
64
+ import traceback
65
+ sys.stderr.write(json.dumps({'error': f'Unexpected error: {e}\n{traceback.format_exc()}'}) + '\n')
66
+ sys.exit(1)
67
+
68
+
69
+ if __name__ == '__main__':
70
+ main()