motely-wasm 20.0.2 → 20.1.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.
@@ -0,0 +1,58 @@
1
+ // Temporary v20 smoke test — boots the EMBEDDED build and runs a real bounded
2
+ // search end to end. Delete after use. Run: node .smoke-test.mjs
3
+ import bootsharp from "./dist/index.mjs";
4
+ import { Program } from "./dist/generated/modules/motely/wasm.g.mjs";
5
+
6
+ const t0 = Date.now();
7
+ const log = (m) => console.log(`[+${((Date.now() - t0) / 1000).toFixed(2)}s] ${m}`);
8
+
9
+ let progressTicks = 0;
10
+ let scored = 0;
11
+ let firstMatch = null;
12
+
13
+ Program.onProgress.subscribe(() => progressTicks++);
14
+ Program.onSeedMatch.subscribe((seed) => {
15
+ if (!firstMatch) firstMatch = seed;
16
+ });
17
+ Program.onScoredResult.subscribe((r) => {
18
+ scored++;
19
+ if (scored <= 3) log(` scored seed=${r.seed} score=${r.score} tallies=[${r.tallies}]`);
20
+ });
21
+
22
+ log("booting embedded WASM (no args)...");
23
+ await bootsharp.boot();
24
+ log(`booted. status=${bootsharp.getStatus()} (expect 2=Booted)`);
25
+
26
+ // 1. JAML parse — throws on invalid.
27
+ const JAML = `must:
28
+ - joker: Blueprint
29
+ antes: [1, 2, 3, 4, 5, 6, 7, 8]
30
+ deck: Red
31
+ stake: White
32
+ `;
33
+ const config = Program.parseJaml(JAML);
34
+ log(`parseJaml OK: must=${config.must?.length ?? "?"} deck=${config.deck} stake=${config.stake}`);
35
+
36
+ // 2. JAML <-> JSON roundtrip.
37
+ const json = Program.jamlToJson(JAML);
38
+ const backToJaml = Program.jsonToJaml(json);
39
+ log(`jamlToJson OK (${json.length} chars), jsonToJaml OK (${backToJaml.length} chars)`);
40
+
41
+ // 3. A real bounded random search (small N — NOT a full sweep).
42
+ log("running runRandomSearch(config, 25000)...");
43
+ const search = Program.runRandomSearch(config, 25000);
44
+ log(
45
+ `search done: searched=${search.totalSeedsSearched} matches=${search.matchingSeeds} ` +
46
+ `completed=${search.isCompleted}`
47
+ );
48
+ log(`events fired: progress=${progressTicks} scored=${scored} firstMatch=${firstMatch ?? "(none)"}`);
49
+
50
+ // Verdict.
51
+ const ok =
52
+ bootsharp.getStatus() === 2 &&
53
+ config.deck === "Red" &&
54
+ json.length > 0 &&
55
+ search.isCompleted &&
56
+ search.totalSeedsSearched > 0n;
57
+ log(ok ? "✅ SMOKE TEST PASSED" : "❌ SMOKE TEST FAILED");
58
+ process.exit(ok ? 0 : 1);
package/README.md CHANGED
@@ -1,113 +1,200 @@
1
1
  # motely-wasm
2
2
 
3
- WebAssembly build of [MotelyJAML](https://github.com/OptimusPi/MotelyJAML) the SIMD Balatro seed search engine with JAML filter support. Powers [seedfinder.app](https://seedfinder.app).
3
+ > Balatro seed search + per-seed analysis in the browser a vectorized SIMD engine compiled to WebAssembly.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/motely-wasm.svg)](https://www.npmjs.com/package/motely-wasm)
6
+ [![license](https://img.shields.io/npm/l/motely-wasm.svg)](./package.json)
7
+ [![types](https://img.shields.io/badge/types-included-blue.svg)](#subpath-exports--where-the-types-live)
8
+ [![WASM](https://img.shields.io/badge/runtime-embedded%20WASM-purple.svg)](#boot)
9
+
10
+ The C# Motely seed engine, AOT/SIMD-compiled to WebAssembly via [Bootsharp](https://github.com/elringus/bootsharp). Author filters in **JAML** (Jimbo's Ante Markup Language), run real searches over Balatro's seed space, and analyze any single seed — all client-side, no server.
11
+
12
+ - **Embedded** — the `.wasm` is inlined into the module. `boot()` takes no args, nothing to copy or serve.
13
+ - **Typed** — full TypeScript declarations, emitted per C# namespace, served on subpath imports.
14
+ - **Streaming** — subscribe to progress/match/scored events, or filter live with a JS predicate (`jimmolate`).
4
15
 
5
16
  ## Install
6
17
 
7
18
  ```sh
8
19
  npm install motely-wasm
20
+ # or
21
+ pnpm add motely-wasm
9
22
  ```
10
23
 
24
+ Requires a host with WASM + ES modules: modern browsers, Node 18+, Deno, Bun. For browser UIs, run searches in a **Web Worker** — every `run*` call blocks its thread until the search completes (WASM has no pthreads).
25
+
11
26
  ## Boot
12
27
 
13
- The WASM binary is **embedded** in the JS module no extra files to serve.
28
+ The WASM binary is **embedded** no files to copy, no path to serve, no `bin/` to wire up. `boot()` takes no arguments.
14
29
 
15
- ```js
30
+ ```ts
16
31
  import bootsharp from "motely-wasm";
17
32
 
18
- await bootsharp.boot();
33
+ await bootsharp.boot(); // embedded: no args
19
34
  ```
20
35
 
21
- Guard re-boots with `bootsharp.getStatus() === bootsharp.BootStatus.Standby`.
36
+ > **`bootsharp` is the _default_ export.** `boot`, `exit`, `getStatus`, `BootStatus`, and `manifest` hang off it. Do **not** write `import * as bootsharp` — `boot` is not a named export and `bootsharp.boot` would be `undefined`.
37
+
38
+ > **`[Import]` bindings are snapshotted at `boot()`.** Assign `Program.jimmolatePredicate` (and any other `[Import]`) **before** calling `boot()`. You may reassign the predicate after boot to swap dispatch logic.
22
39
 
23
- ## Imports
40
+ ## Quick start — run a search
24
41
 
25
- ```js
26
- // Main API — the C# Program class, renamed to Motely
27
- import { Program as Motely } from "motely-wasm/dist/generated/modules/motely/wasm.g.mjs";
42
+ ```ts
43
+ import bootsharp from "motely-wasm";
44
+ import { Program } from "motely-wasm/motely/wasm";
45
+ import type { MotelyScoredSeedResult, MotelyProgress } from "motely-wasm/motely";
46
+
47
+ await bootsharp.boot();
28
48
 
29
- // EnumsMotelyDeck, MotelyStake, etc.
30
- import * as enums from "motely-wasm/dist/generated/modules/motely/enums.g.mjs";
49
+ // 1. Parse JAML throws on invalid input.
50
+ const config = Program.parseJaml(`
51
+ must:
52
+ - joker: Blueprint
53
+ antes: [1, 2, 3, 4, 5, 6, 7, 8]
54
+ deck: Red
55
+ stake: White
56
+ `);
31
57
 
32
- // Types MotelyProgress, IMotelySearch, MotelyScoredSeedResult, etc.
33
- import * as types from "motely-wasm/dist/generated/modules/motely.g.mjs";
58
+ // 2. Stream results as the engine scores seeds.
59
+ Program.onProgress.subscribe((p: MotelyProgress) =>
60
+ console.log(`${p.percentComplete.toFixed(1)}% (${p.matchingSeeds} hits)`)
61
+ );
62
+ Program.onScoredResult.subscribe((r: MotelyScoredSeedResult) =>
63
+ console.log(`MATCH ${r.seed} score=${r.score}`)
64
+ );
65
+
66
+ // 3. Run. Blocks until done (call from a Web Worker for a live UI).
67
+ const search = Program.runRandomSearch(config, 100_000);
68
+ console.log(`done: ${search.matchingSeeds}/${search.totalSeedsSearched} seeds`);
34
69
  ```
35
70
 
36
- ## Quick start
71
+ ### Search entry points
37
72
 
38
- ```js
39
- // Optional [Import] hook — surface WASM-side errors. Bind BEFORE boot:
40
- // Bootsharp snapshots [Import] bindings at boot().
41
- Motely.reportWasmError = (msg) => console.error("[WASM]", msg);
73
+ | Method | Searches |
74
+ |--------|----------|
75
+ | `runSequentialSearch(config, startBatch?, endBatch?, batchChars?, intervalMs?)` | The seed space in order, batch by batch |
76
+ | `runRandomSearch(config, count)` | `count` random seeds |
77
+ | `runSeedListSearch(config)` | The seeds listed in `config.seeds` |
78
+ | `runAestheticSearch(config, aesthetic)` | Seeds matching a `JamlAesthetic` lens |
79
+ | `runNativeListSearch(filterName, seeds)` | A list through a built-in native filter |
80
+ | `runPassthroughListSearch(seeds)` | A list with no filtering (decode/inspect) |
42
81
 
43
- // Subscribe to events
44
- Motely.onSeedMatch.subscribe(seed => console.log("match:", seed));
45
- Motely.onProgress.subscribe(p => console.log(`${p.percentComplete.toFixed(1)}%`));
82
+ Each returns an `IMotelySearch` whose counters (`matchingSeeds`, `totalSeedsSearched`, `isCompleted`, …) are ready to read on return.
46
83
 
47
- // Parse a JAML filter. JAML is YAML, and YAML is a JSON superset — a JSON
48
- // object string parses too, which is what makes it round-trip cleanly over MCP.
49
- const config = Motely.parseJaml(`
50
- name: WeeMonday
51
- deck: Erratic
52
- stake: Black
53
- must:
54
- - joker: WeeJoker
55
- antes: [1]
56
- `);
84
+ ## Jimmolate filter live with a JS predicate
85
+
86
+ `jimmolate` is the original Immolate `filter(seed) => keep?` model, in the browser. The C# engine does the SIMD work; your JS predicate decides which **scored** results survive. A seed reaches your handler only if the predicate keeps it.
87
+
88
+ ```ts
89
+ import bootsharp from "motely-wasm";
90
+ import { Program } from "motely-wasm/motely/wasm";
91
+ import type { MotelyScoredSeedResult } from "motely-wasm/motely";
92
+
93
+ // Assign the predicate BEFORE boot (it's an [Import], snapshotted at boot).
94
+ Program.jimmolatePredicate = (r: MotelyScoredSeedResult) => r.score >= 20;
57
95
 
58
- // Run a search (blocks until complete — run in a Worker for non-blocking UI).
59
- const search = Motely.runSequentialSearch(config);
60
- console.log(search.matchingSeeds, "matches out of", search.totalSeedsSearched);
96
+ await bootsharp.boot();
97
+
98
+ Program.jimmolateEnabled = true; // gate on — without this, every scored seed is reported
99
+ Program.onScoredResult.subscribe((r) => keep(r)); // fires only for kept seeds
100
+
101
+ Program.runRandomSearch(Program.parseJaml(jaml), 1_000_000);
61
102
  ```
62
103
 
63
- ## API
64
-
65
- ```js
66
- Motely.parseJaml(jaml) // string → JamlConfig (throws on invalid JAML)
67
- Motely.explainJaml(config) // human-readable plan summary
68
- Motely.createPlan(config) // JamlSearchPlan (tally columns, CSV header)
69
- Motely.jamlToJson(jaml) // JAML stringJSON string
70
- Motely.jsonToJaml(json) // JSON string → JAML string
71
- Motely.jamlyzer(seed, lens) // analyze ONE seed through a JamlConfig lens → JamlyzerSnapshot
72
- Motely.nativeFilterNames() // built-in native filter names
73
-
74
- Motely.runSequentialSearch(config, ...) // sequential seed search
75
- Motely.runRandomSearch(config, count) // random seed sample
76
- Motely.runSeedListSearch(config) // search only config.seeds
77
- Motely.runAestheticSearch(config, aesthetic) // aesthetic / scored search
78
- Motely.runNativeListSearch(name, seeds) // named native filter over a seed list
79
- Motely.runPassthroughListSearch(seeds) // no filter, just iterate seeds
104
+ Leave `jimmolateEnabled` false to receive **every** scored seed (no filtering).
105
+
106
+ JAML ⇄ JSON conversion is available too: `Program.jamlToJson(jaml)` and `Program.jsonToJaml(json)`.
107
+
108
+ ## Subpath exports — where the types live
109
+
110
+ Bootsharp emits one declaration file **per C# namespace** ([Bootsharp · Type Declarations](https://github.com/elringus/bootsharp) "One `.g.d.mts` file is emitted per C# namespace, colocated with the matching `.g.mjs` binding"). Members in a namespace land on a matching **subpath**; the bare top-level import is intentionally empty. **Import types from the subpath, not the package root.**
111
+
112
+ | Import | Contents |
113
+ |--------|----------|
114
+ | `motely-wasm` | Default export: `boot()`, `exit()`, `getStatus()`, `BootStatus`, `manifest` |
115
+ | `motely-wasm/motely/wasm` | `Program` — the engine API (search, JAML parse, file I/O) |
116
+ | `motely-wasm/motely` | Core types: `MotelyScoredSeedResult`, `MotelyProgress`, `IMotelySearch`, `MotelyItem`, `MotelyMatchSource` |
117
+ | `motely-wasm/motely/enums` | Engine enums: `MotelyDeck`, `MotelyStake`, `MotelyVoucher`, `MotelyTag`, `MotelyBossBlind`, `MotelyBoosterPack`, joker enums, … |
118
+ | `motely-wasm/motely/filters/jaml` | `JamlConfig`, `JamlAesthetic` |
119
+ | `motely-wasm/motely/filters` | `JamlSearchPlan` |
120
+ | `motely-wasm/bootsharp/file-system` | `IFileSystem`, `IFileMounter`, `Change`, `MountOptions`, `PickOptions` |
121
+
122
+ ```ts
123
+ import { Program } from "motely-wasm/motely/wasm";
124
+ import type { IMotelySearch, MotelyProgress, MotelyScoredSeedResult } from "motely-wasm/motely";
125
+ import { MotelyDeck, MotelyStake } from "motely-wasm/motely/enums";
126
+ import { JamlAesthetic } from "motely-wasm/motely/filters/jaml";
80
127
  ```
81
128
 
82
129
  ## Events
83
130
 
84
- ```js
85
- Motely.onSeedMatch.subscribe(seed => {}) // string — each matching seed
86
- Motely.onScoredResult.subscribe(result => {}) // MotelyScoredSeedResult { seed, score, tallies }
87
- Motely.onProgress.subscribe(p => {}) // MotelyProgress
88
- Motely.onFileChanges.subscribe(changes => {}) // file system changes (browser OPFS)
89
- ```
131
+ Subscribe before running a search.
90
132
 
91
- ## File system (browser)
133
+ | Event | Payload | Fires |
134
+ |-------|---------|-------|
135
+ | `Program.onProgress` | `MotelyProgress` | On the progress interval during a run |
136
+ | `Program.onSeedMatch` | `string` (seed) | When a seed matches the filter |
137
+ | `Program.onScoredResult` | `MotelyScoredSeedResult` | Per scored seed (post-`jimmolate` filter) |
138
+ | `Program.onFileChanges` | `Change[]` | When a mounted directory changes |
92
139
 
93
- `Motely.pickRoot`, `mountRoot`, `unmountRoot`, `readTextFile`, `writeTextFile` use the browser File System Access API via `Bootsharp.FileSystem`. Initialize the JS extension before booting:
140
+ ## File system (browser File System Access API)
94
141
 
95
- ```js
142
+ The engine reads and writes JAML files from a user-picked directory via the browser File System Access API. This capability ships as a **separate peer package** — [`@rewaffle/bootsharp-file-system`](https://www.npmjs.com/package/@rewaffle/bootsharp-file-system), a sponsor-exclusive Bootsharp extension — because the WASM engine doesn't bundle the mounter itself. Install it and **wire the mounter before `boot()`**: `IFileMounter` is a Bootsharp `[Import]`, so calling `Program.pickRoot()` without `fs.init()` throws.
143
+
144
+ ```sh
145
+ npm install @rewaffle/bootsharp-file-system # peer package
146
+ ```
147
+
148
+ ```ts
149
+ import bootsharp from "motely-wasm";
150
+ import { Program } from "motely-wasm/motely/wasm";
151
+ import { IFileMounter, PermissionMode } from "motely-wasm/bootsharp/file-system";
96
152
  import * as fs from "@rewaffle/bootsharp-file-system";
97
- import { Bootsharp } from "motely-wasm/dist/generated/modules/bootsharp/file-system.g.mjs";
98
153
 
99
- fs.init(Bootsharp.FileSystem.FileMounter);
154
+ // 1. Bind the mounter BEFORE boot (every [Import] is snapshotted at boot()).
155
+ fs.init(IFileMounter);
100
156
  await bootsharp.boot();
157
+
158
+ // 2. Now the file APIs on Program work.
159
+ const root = await Program.pickRoot({ mode: PermissionMode.ReadWrite }); // null if cancelled
160
+ if (root) {
161
+ await Program.mountRoot(root, { mode: PermissionMode.ReadWrite });
162
+ const jaml = await Program.readTextFile(root, "filters/blueprint.jaml");
163
+ await Program.writeTextFile(root, "filters/out.jaml", updated);
164
+ await Program.unmountRoot(root);
165
+ }
166
+ ```
167
+
168
+ The search / JAML / analysis APIs don't route through this — they run whether or not the mounter is wired. The directory APIs (`pickRoot`, `mountRoot`, `readTextFile`, `writeTextFile`) are what need it; unwired, they throw.
169
+
170
+ ### Mobile browsers / single file
171
+
172
+ The directory mount relies on `showDirectoryPicker`, which iOS Safari and most mobile browsers **do not support** — and `Program` exposes only directory-*root* mounting (`pickRoot` / `mountRoot`), no single-file pick. So don't mount on mobile. You don't need to: `parseJaml`, `runRandomSearch`, etc. all take a JAML **string**. Read one file with a plain `<input type="file">` and feed its text straight in — works on every browser, no FS package required:
173
+
174
+ ```ts
175
+ const file = input.files[0]; // <input type="file" accept=".jaml,.yaml">
176
+ const config = Program.parseJaml(await file.text());
177
+ const search = Program.runRandomSearch(config, 100_000);
101
178
  ```
102
179
 
103
- ## Jimmolate
180
+ ## Bundler notes
104
181
 
105
- Scalar predicate filtering after the base SIMD pass. Bind the predicate **before** boot (Bootsharp snapshots `[Import]` bindings at boot), then enable it:
182
+ The `browser` field in `package.json` stubs Node built-ins (`node:fs`, `node:url`, `node:path`, `node:module`, `node:crypto`, `node:process`) to `false`, so Vite/Webpack skip them cleanly. In library wrappers, **externalize** `motely-wasm` (and `motely-wasm/*`) so the consuming app controls WASM resolution.
106
183
 
107
- ```js
108
- Motely.jimmolatePredicate = (result) => {
109
- // result is MotelyScoredSeedResult — { seed, score, tallies }
110
- return result.score > 0; // return true to keep the seed
111
- };
112
- Motely.jimmolateEnabled = true;
184
+ ## Build from source
185
+
186
+ ```sh
187
+ npm run build # dotnet publish ../Motely.Wasm/Motely.Wasm.csproj -c Release
113
188
  ```
189
+
190
+ Release builds use NativeAOT-LLVM (speed-optimized, trimmed). Output lands in `dist/`, which Bootsharp **regenerates wholesale on every publish** — never hand-edit anything under `dist/`.
191
+
192
+ ## Maintainers
193
+
194
+ `package.json` is **hand-maintained and static** — edit it directly. Do **not** point `BootsharpPackageDirectory` at this directory: Bootsharp overwrites `package.json` on every build with a bare template (no version, types, or author), which breaks `npm publish`. The build parks that throwaway template in `obj/` (gitignored) instead. See the comment in `Motely.Wasm.csproj`.
195
+
196
+ Bump versions with `npm version patch` (never hand-edit the `version` field), then `npm publish`.
197
+
198
+ ## License
199
+
200
+ MIT © Nathanial P. Howard
package/__boot.html ADDED
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html><html><head><meta charset="utf-8"></head><body><pre id="o">booting…</pre>
2
+ <script type="module">
3
+ const o=document.getElementById("o");
4
+ const S="name: t\ndeck: Red\nmust:\n - joker: Blueprint\n";
5
+ try{
6
+ const bs=(await import("./dist/index.mjs")).default;
7
+ const {Program}=await import("./dist/generated/modules/motely/wasm.g.mjs");
8
+ await bs.boot();
9
+ const json=Program.jamlToJson(S);
10
+ const cfg=Program.parseJaml(S);
11
+ window.__R={ok:true,json:json.slice(0,120),cfg:cfg!=null};
12
+ o.textContent="OK "+JSON.stringify(window.__R);
13
+ }catch(e){window.__R={ok:false,err:String(e&&e.stack||e)};o.textContent="FAIL "+window.__R.err;}
14
+ </script></body></html>
package/cli.mjs ADDED
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // motely-wasm CLI — boot the embedded engine and DO something from the terminal.
3
+ // Doubles as the v20 end-to-end test nobody wrote. Exercises the full Program surface.
4
+ //
5
+ // node cli.mjs search <filter.jaml> --random <N> bounded random search
6
+ // node cli.mjs search <filter.jaml> --seeds A,B,C seed-list search
7
+ // node cli.mjs jimmolate <filter.jaml> --random <N> --min <s> JS predicate keeps score>=s
8
+ // node cli.mjs sequential <filter.jaml> [--start N] [--end M] ORDERED sweep (bounded!)
9
+ // node cli.mjs aesthetic <filter.jaml> <aestheticIndex> aesthetic-lens search of --seeds/--random
10
+ // node cli.mjs native [<name> --seeds A,B,C] list or run built-in native filters
11
+ // node cli.mjs explain <filter.jaml> print the search plan in English
12
+ // node cli.mjs convert <file.jaml> JAML -> JSON -> JAML roundtrip
13
+ //
14
+ import bootsharp from "./dist/index.mjs";
15
+ import { Program } from "./dist/generated/modules/motely/wasm.g.mjs";
16
+ import { readFileSync } from "node:fs";
17
+
18
+ const t0 = Date.now();
19
+ const secs = () => ((Date.now() - t0) / 1000).toFixed(2);
20
+ const die = (m) => { console.error(`✗ ${m}`); process.exit(1); };
21
+ const readJaml = (p) => { try { return readFileSync(p, "utf8"); } catch { die(`cannot read JAML: ${p}`); } };
22
+
23
+ const [cmd, ...rest] = process.argv.slice(2);
24
+ const flag = (name, def) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : def; };
25
+ const arg0 = () => (rest[0] && !rest[0].startsWith("--") ? rest[0] : null);
26
+
27
+ if (!cmd || cmd === "--help" || cmd === "-h") {
28
+ console.log(readFileSync(new URL(import.meta.url)).toString().split("\n").slice(1, 16).join("\n").replace(/^\/\/ ?/gm, ""));
29
+ process.exit(0);
30
+ }
31
+
32
+ // ── jimmolate predicate MUST be wired before boot (Bootsharp [Import], snapshotted at boot). ──
33
+ let scoredTotal = 0, keptTotal = 0;
34
+ const MIN = Number(flag("--min", "0"));
35
+ if (cmd === "jimmolate") {
36
+ Program.jimmolatePredicate = (r) => {
37
+ scoredTotal++;
38
+ const keep = r.score >= MIN;
39
+ if (keep) keptTotal++;
40
+ return keep;
41
+ };
42
+ }
43
+
44
+ console.log(`[+${secs()}s] booting embedded WASM (no args)…`);
45
+ await bootsharp.boot();
46
+ console.log(`[+${secs()}s] booted (status ${bootsharp.getStatus()}).`);
47
+
48
+ // ── commands that don't need a search ──
49
+ if (cmd === "native" && !arg0()) {
50
+ console.log("Built-in native filters:");
51
+ for (const n of Program.nativeFilterNames()) console.log(` • ${n}`);
52
+ process.exit(0);
53
+ }
54
+ if (cmd === "explain") {
55
+ const cfg = Program.parseJaml(readJaml(arg0() ?? die("usage: explain <filter.jaml>")));
56
+ console.log(`\n${Program.explainJaml(cfg) || "(no must/should/mustNot clauses to explain)"}`);
57
+ process.exit(0);
58
+ }
59
+ if (cmd === "convert") {
60
+ const src = readJaml(arg0() ?? die("usage: convert <file.jaml>"));
61
+ const json = Program.jamlToJson(src);
62
+ const back = Program.jsonToJaml(json);
63
+ console.log(`JAML -> JSON (${json.length} chars):\n${json}\n\nJSON -> JAML (${back.length} chars):\n${back}`);
64
+ process.exit(0);
65
+ }
66
+ // ── search-family commands ──
67
+ const wireStreams = () => {
68
+ let shown = 0;
69
+ Program.onScoredResult.subscribe((r) => {
70
+ if (shown++ < 15) console.log(` ★ ${r.seed} score=${r.score} tallies=[${r.tallies}]`);
71
+ });
72
+ Program.onProgress.subscribe((p) => {
73
+ process.stdout.write(`\r[+${secs()}s] ${p.percentComplete.toFixed(0)}% hits=${p.matchingSeeds} `);
74
+ });
75
+ };
76
+ const report = (search) => {
77
+ console.log(`\n[+${secs()}s] done: searched=${search.totalSeedsSearched} matches=${search.matchingSeeds} completed=${search.isCompleted}`);
78
+ process.exit(search.isCompleted ? 0 : 1);
79
+ };
80
+
81
+ if (cmd === "native") {
82
+ const seeds = (flag("--seeds") ?? die("usage: native <name> --seeds A,B,C")).split(",");
83
+ wireStreams();
84
+ report(Program.runNativeListSearch(arg0(), seeds));
85
+ }
86
+
87
+ const cfgFile = arg0() ?? die(`usage: ${cmd} <filter.jaml> …`);
88
+ const config = Program.parseJaml(readJaml(cfgFile));
89
+ console.log(`[+${secs()}s] parsed: must=${config.must?.length ?? 0} should=${config.should?.length ?? 0} deck=${config.deck} stake=${config.stake}`);
90
+ wireStreams();
91
+
92
+ if (cmd === "jimmolate") {
93
+ Program.jimmolateEnabled = true;
94
+ console.log(`[+${secs()}s] jimmolate ON — predicate keeps score >= ${MIN}`);
95
+ }
96
+
97
+ let search;
98
+ if (cmd === "sequential") {
99
+ // SAFETY RAIL: never default to an unbounded sweep (the forbidden ~2.3T run).
100
+ const start = BigInt(flag("--start", "0"));
101
+ const end = BigInt(flag("--end", String(start + 1n))); // default: exactly one batch
102
+ if (end <= start) die("--end must be greater than --start");
103
+ if (end - start > 64n) die(`refusing ${end - start} batches — that's a huge sweep. Cap it (<=64 batches) for the CLI.`);
104
+ console.log(`[+${secs()}s] sequential sweep: batches [${start}, ${end}) …`);
105
+ search = Program.runSequentialSearch(config, start, end);
106
+ } else if (cmd === "aesthetic") {
107
+ const aesthetic = Number(rest[1] ?? die("usage: aesthetic <filter.jaml> <aestheticIndex> --seeds …/--random …"));
108
+ const seeds = flag("--seeds"); const random = flag("--random");
109
+ if (seeds) config.seeds = seeds.split(",");
110
+ // aesthetic search reads its seed source from the config/settings the same way;
111
+ // run it and let the engine drive.
112
+ search = Program.runAestheticSearch(config, aesthetic);
113
+ } else if (cmd === "search" || cmd === "jimmolate") {
114
+ const random = flag("--random"); const seeds = flag("--seeds");
115
+ search = random
116
+ ? Program.runRandomSearch(config, Number(random))
117
+ : seeds
118
+ ? (() => { config.seeds = seeds.split(","); return Program.runSeedListSearch(config); })()
119
+ : die("specify --random <N> or --seeds A,B,C");
120
+ } else {
121
+ die(`unknown command "${cmd}" — try --help`);
122
+ }
123
+
124
+ if (cmd === "jimmolate")
125
+ console.log(`\n[+${secs()}s] jimmolate: predicate saw ${scoredTotal} scored seeds, KEPT ${keptTotal} (score >= ${MIN}).`);
126
+ report(search);