From 7383881bd2c559753f559564fe7d9d83769aef05 Mon Sep 17 00:00:00 2001 From: Nil Geisweiller Date: Thu, 20 Aug 2026 21:08:23 +0300 Subject: [PATCH 1/4] Add scale option to GifOptions and apply it in all three SVG generators scale is a multiplier on the output width and height, applied after width is resolved from the view's default. It composes with an explicit width (scale: 2 with width: 240 produces 480 wide). The side-by-side view had a rounding drift: each panel and the gap were rounded independently, so cellW + gap + cellW could differ from the requested width by a pixel, and labelH + panelH could drift the same way. With scale that drift showed up as the doubled GIF being one pixel wider than 2x the base, and its height two pixels short. Derive the gap from the requested width (width - cellW * 2) and the labelH from the natural-scaled total height (totalH - panelH) so the output dimensions match the requested size exactly and scale produces linear dimensions. --- packages/grapher/src/block/gif.ts | 9 +++++++-- packages/grapher/src/sidebyside-gif.ts | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/grapher/src/block/gif.ts b/packages/grapher/src/block/gif.ts index b552e3ee..59a46696 100644 --- a/packages/grapher/src/block/gif.ts +++ b/packages/grapher/src/block/gif.ts @@ -31,8 +31,13 @@ export interface GifEncoderLib { /** How to render the GIF. */ export interface GifOptions { - /** Output width in pixels (height follows the aspect ratio). Default 720. */ + /** Output width in pixels (height follows the aspect ratio). Default 720 for blocks, 880 for graph, the + * computed natural width for side-by-side. */ width?: number; + /** Multiplier applied to the output width and height. The view's default width is multiplied by this + * when `width` is unset, so `--scale 2` ups a thumbnail to a high-resolution render without forcing the + * caller to recompute the view's natural width. Default 1. */ + scale?: number; /** Morph frames per reduction step. Default `morphMs / stepMs`, reduced to keep under `maxFrames`. */ framesPerStep?: number; /** Cap on the total number of frames. Default 180. */ @@ -135,7 +140,7 @@ export function blockReductionSvgsWithSettings( opts: GifOptions = {}, ): SvgAnimation { if (states.length === 0) throw new Error("no reduction states to export"); - const width = opts.width ?? 720; + const width = Math.max(1, Math.round((opts.width ?? 720) * (opts.scale ?? 1))); const holdMs = opts.holdMs ?? 260; const stepMs = opts.stepMs ?? 40; diff --git a/packages/grapher/src/sidebyside-gif.ts b/packages/grapher/src/sidebyside-gif.ts index af2e67e5..520490cc 100644 --- a/packages/grapher/src/sidebyside-gif.ts +++ b/packages/grapher/src/sidebyside-gif.ts @@ -135,13 +135,21 @@ export function sideBySideReductionSvgs( const naturalLabelH = 30; const naturalCellW = Math.max(1, Math.round(naturalPanelH * cellAspect)); const naturalWidth = naturalCellW * 2 + naturalGap; - const scale = (opts.width ?? naturalWidth) / naturalWidth; + const baseWidth = opts.width ?? naturalWidth; + const outScale = opts.scale ?? 1; + const width = Math.max(1, Math.round(baseWidth * outScale)); + const scale = width / naturalWidth; const panelH = Math.max(1, Math.round(naturalPanelH * scale)); - const gap = Math.max(1, Math.round(naturalGap * scale)); - const labelH = Math.max(1, Math.round(naturalLabelH * scale)); const cellW = Math.max(1, Math.round(naturalCellW * scale)); + // Derive the gap from the requested width and the cell sizes, so `cellW + gap + cellW` always equals + // `width` exactly. Without this, the per-component rounding can drift `totalW` by a pixel, which makes + // `scale: 2` produce a GIF one pixel wider than the doubled base. + const gap = Math.max(1, width - cellW * 2); const totalW = cellW + gap + cellW; - const totalH = panelH + labelH; + // Round the total height directly from the natural aspect so it scales linearly with `width`; absorb any + // rounding drift between `panelH + labelH` and the natural total into the (smaller) label band. + const totalH = Math.max(1, Math.round((naturalPanelH + naturalLabelH) * scale)); + const labelH = Math.max(1, totalH - panelH); const background = opts.background ?? s.canvas; const fontSize = Math.max(8, Math.round(13 * scale)); const frames: SvgFrame[] = []; @@ -183,7 +191,7 @@ export function graphReductionSvgs(states: readonly Atom[][], opts: GifOptions = const bg = opts.background ?? CANVAS_BG; const holdMs = opts.holdMs ?? 260; const stepMs = opts.stepMs ?? 40; - const width = opts.width ?? 880; + const width = Math.max(1, Math.round((opts.width ?? 880) * (opts.scale ?? 1))); const height = Math.round(width * 0.5); // Each state gets its own viewport: fit it to the frame, but never zoom out past a floor nor in past From 7f0a38d02456d3411e3adc5d01dfb3ac3222d22d Mon Sep 17 00:00:00 2001 From: Nil Geisweiller Date: Thu, 20 Aug 2026 21:08:40 +0300 Subject: [PATCH 2/4] Validate scale in renderReductionGif and test the new option scale must be a positive finite number. It is capped at 16 to keep the raster-pixel budget in check (width * height * numFrames must stay under the safety limit). The new tests verify that scale multiplies the output width by the requested factor across all three views (blocks, graph, side-by-side), and that it composes with an explicit width. Unsafe values (zero, negative, out of range) are rejected with a clear message. --- packages/grapher/src/node.test.ts | 25 +++++++++++++++++++++++++ packages/grapher/src/node.ts | 8 ++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/grapher/src/node.test.ts b/packages/grapher/src/node.test.ts index a0ee570b..63e29141 100644 --- a/packages/grapher/src/node.test.ts +++ b/packages/grapher/src/node.test.ts @@ -114,10 +114,35 @@ describe("Node reduction GIFs", () => { [{ framesPerStep: 0 }, "framesPerStep"], [{ maxFrames: 0 }, "maxFrames"], [{ maxSteps: 0 }, "maxSteps"], + [{ scale: 0 }, "scale"], + [{ scale: -1 }, "scale"], + [{ scale: 17 }, "scale"], ])("rejects unsafe options %j", async (options, message) => { await expect(renderReductionGif("(+ 1 2)", { ...QUICK, ...options })).rejects.toThrow(message); }); + it.each(["blocks", "graph", "side-by-side"])( + "scales the output width by the scale option (%s view)", + async (view) => { + const baseBytes = await renderReductionGif("(+ 10 (* 25 2))", { ...QUICK, view }); + const baseMeta = await gifMetadata(baseBytes); + const scaledBytes = await renderReductionGif("(+ 10 (* 25 2))", { + ...QUICK, + view, + scale: 2, + }); + const scaledMeta = await gifMetadata(scaledBytes); + expect(scaledMeta.width).toBe(baseMeta.width! * 2); + expect(scaledMeta.height).toBe(baseMeta.height! * 2); + }, + ); + + it("scale multiplies an explicit width by the requested factor", async () => { + const bytes = await renderReductionGif("(+ 1 2)", { width: 100, scale: 3 }); + const metadata = await gifMetadata(bytes); + expect(metadata.width).toBe(300); + }); + it("rejects a generated animation that exceeds maxFrames", async () => { await expect( renderReductionGif("(+ 10 (* 25 2))", { diff --git a/packages/grapher/src/node.ts b/packages/grapher/src/node.ts index bdc3c349..a6bd6fc2 100644 --- a/packages/grapher/src/node.ts +++ b/packages/grapher/src/node.ts @@ -41,6 +41,14 @@ function validateOptions(opts: NodeGifOptions): void { ) throw new Error(`view must be "blocks", "graph", or "side-by-side", got ${String(opts.view)}`); if (opts.width !== undefined) positiveInteger("width", opts.width, MAX_DIMENSION); + if (opts.scale !== undefined) { + if (!Number.isFinite(opts.scale) || opts.scale <= 0) + throw new Error(`scale must be a positive number, got ${opts.scale}`); + if (opts.scale > 16) + throw new Error( + `scale must be at most 16 (the raster-pixel safety limit), got ${opts.scale}`, + ); + } if (opts.framesPerStep !== undefined) positiveInteger("framesPerStep", opts.framesPerStep, MAX_FRAMES); if (opts.maxFrames !== undefined) positiveInteger("maxFrames", opts.maxFrames, MAX_FRAMES); From a42f52941ae2a23e35c78986d6a14aafb1e30f45 Mon Sep 17 00:00:00 2001 From: Nil Geisweiller Date: Thu, 20 Aug 2026 21:09:03 +0300 Subject: [PATCH 3/4] Add --scale flag to metta graph CLI and test it metta graph already accepted --width for output resolution; --scale N is a multiplier on the same value, defaulting to 1. Without --width, scale multiplies the view's natural width (720 for blocks, 880 for graph, the computed natural width for side-by-side). With --width, scale multiplies that explicit width. The CLI validates the argument (positive finite number) before reaching the grapher so the user gets a clear error message. Usage: metta graph --scale 2 program.metta -o out.gif # 2x resolution metta graph --scale 0.5 program.metta -o out.gif # half resolution metta graph --width 240 --scale 3 program.metta # 240 x 3 = 720 wide Closes https://github.com/MesTTo/MeTTaScript/issues/7 --- packages/node/src/graph-main.ts | 13 ++++++++++++- packages/node/src/metta-cli.test.ts | 15 +++++++++++++++ packages/node/src/metta-cli.ts | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/node/src/graph-main.ts b/packages/node/src/graph-main.ts index 35d89fa3..d82f8a33 100644 --- a/packages/node/src/graph-main.ts +++ b/packages/node/src/graph-main.ts @@ -19,6 +19,14 @@ function parseView(value: string | undefined): GifView | undefined { throw new Error(`--view must be blocks, graph, or side-by-side, got ${value}`); } +function parseScale(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) + throw new Error(`--scale must be a positive number, got ${value}`); + return n; +} + /** The `metta graph` command. `argv` is the argument list after `graph`. Reads the file, renders its * reduction (the last non-definition atom, `!`-marker accepted) to GIF bytes, and writes them. May * `process.exit(2)` on a usage error; throws (for the dispatcher to report) if the grapher is not @@ -31,17 +39,19 @@ export async function runGraphMain(argv: string[]): Promise { out: { type: "string", short: "o" }, view: { type: "string" }, width: { type: "string" }, + scale: { type: "string" }, "max-steps": { type: "string" }, }, }); const file = positionals[0]; if (file === undefined) { process.stderr.write( - "usage: metta graph [-o out.gif] [--view blocks|graph|side-by-side] [--width N] [--max-steps N]\n", + "usage: metta graph [-o out.gif] [--view blocks|graph|side-by-side] [--width N] [--scale N] [--max-steps N]\n", ); process.exit(2); } const view = parseView(values.view); + const scale = parseScale(values.scale); const src = readFileSync(resolve(file), "utf8"); let renderReductionGif: typeof import("@mettascript/grapher/node").renderReductionGif; @@ -57,6 +67,7 @@ export async function runGraphMain(argv: string[]): Promise { const gif = await renderReductionGif(src, { ...(view !== undefined ? { view } : {}), ...(values.width !== undefined ? { width: Number(values.width) } : {}), + ...(scale !== undefined ? { scale } : {}), ...(values["max-steps"] !== undefined ? { maxSteps: Number(values["max-steps"]) } : {}), }); const out = values.out ?? `${basename(file, extname(file))}.gif`; diff --git a/packages/node/src/metta-cli.test.ts b/packages/node/src/metta-cli.test.ts index 19b7af29..b19df464 100644 --- a/packages/node/src/metta-cli.test.ts +++ b/packages/node/src/metta-cli.test.ts @@ -94,4 +94,19 @@ describe("unified metta CLI", () => { expect(bytes.subarray(0, 3).toString("latin1")).toBe("GIF"); rmSync(out, { force: true }); }); + + it("graph --scale upscales the rendered resolution", () => { + const file = mettaFixture("metta-graph-scale-", "!(+ 10 (* 25 2))\n"); + const out = `${file}.gif`; + run(METTA, ["graph", file, "-o", out, "--scale", "0.5", "--max-steps", "60"]); + expect(existsSync(out)).toBe(true); + const small = readFileSync(out); + expect(small.subarray(0, 3).toString("latin1")).toBe("GIF"); + rmSync(out, { force: true }); + }); + + it("graph --scale 0 rejects a non-positive scale", () => { + const file = mettaFixture("metta-graph-scale-bad-", "!(+ 1 2)\n"); + expect(status(METTA, ["graph", file, "--scale", "0"])).toBe(1); + }); }); diff --git a/packages/node/src/metta-cli.ts b/packages/node/src/metta-cli.ts index af21a82d..d8c74d8d 100644 --- a/packages/node/src/metta-cli.ts +++ b/packages/node/src/metta-cli.ts @@ -23,7 +23,7 @@ usage: metta run [options] run a program, printing each !-query's results metta check [options] statically analyze a program (--json, --undefined-symbols) metta debug (--file

| --source '') [--llm] debug the engine - metta graph [-o out.gif] [--view blocks|graph|side-by-side] render a reduction GIF + metta graph [-o out.gif] [--view blocks|graph|side-by-side] [--width N] [--scale N] [--max-steps N] render a reduction GIF metta fuzz [--exhaustive] [--json] run declared (FuzzTest ...) properties metta reach [id] run declared (FuzzReachTest ...) searches metta --version | --help From 8a03fa659ea8eec33e0bbd332da87669feedad52 Mon Sep 17 00:00:00 2001 From: Nil Geisweiller Date: Thu, 20 Aug 2026 21:09:33 +0300 Subject: [PATCH 4/4] Document --scale and the scale option The node README picks up a one-line example of --scale 2. The node LLMS note mentions --scale=N next to the metta graph entry. The grapher LLMS note adds a 'scale multiplies the output pixel size' trap so callers who discover the option through the docs know it caps at 16. --- packages/grapher/LLMS.md | 2 +- packages/node/LLMS.md | 2 +- packages/node/README.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/grapher/LLMS.md b/packages/grapher/LLMS.md index b0cb76f9..190ac558 100644 --- a/packages/grapher/LLMS.md +++ b/packages/grapher/LLMS.md @@ -15,5 +15,5 @@ view.destroy(); // on unmount `grapher(target, options?)` takes a CSS selector or `HTMLElement`. `GrapherOptions`: `source?: string`, `metta?: MeTTa` — pass an existing runner when the editor should share its space. Fluent handle: `load atoms graph blocks palette fit evaluate play source gif destroy`; `.grapher` is the underlying `MeTTaGrapher`. **Views** `graph()` connected node graph · `blocks()` nested blocks. Same atoms, same state — switch freely. **Trace** `play()` initialises a reduction trace at its first state; the host drives it: `view.grapher.traceForward()` step · `traceBack()` step back · `stopTrace()` leave. -**Traps** *The host element needs an explicit height* — without it the SVG collapses and you see nothing; the single most common "it didn't render" cause. · *Call `destroy()` on unmount* — the view holds listeners and timers. · *Pass your own `MeTTa` to share state* — without `options.metta` the editor makes its own runner and atoms you added elsewhere are invisible to it. · *GIF deps are optional and separate* — `gifenc` (+ `sharp` on Node) are not installed with the package; `metta graph` needs them too. · *Reading a space visually ≠ reading it semantically* — `get-atoms` evaluates what it returns, so a stored `(fact 5)` can display as `120`; use `(match &space $x (quote $x))` for the atom as stored. +**Traps** *The host element needs an explicit height* — without it the SVG collapses and you see nothing; the single most common "it didn't render" cause. · *Call `destroy()` on unmount* — the view holds listeners and timers. · *Pass your own `MeTTa` to share state* — without `options.metta` the editor makes its own runner and atoms you added elsewhere are invisible to it. · *GIF deps are optional and separate* — `gifenc` (+ `sharp` on Node) are not installed with the package; `metta graph` needs them too. · *Reading a space visually ≠ reading it semantically* — `get-atoms` evaluates what it returns, so a stored `(fact 5)` can display as `120`; use `(match &space $x (quote $x))` for the atom as stored. · *`scale` multiplies the output pixel size* — pass `scale: 2` to upscale past the view's natural width without recomputing it; `scale` is capped at 16 by the raster-pixel safety limit. **Next** `hyperon` the runner it evaluates with · `node` `metta graph program.metta -o out.gif` · `browser` mounting in a page. diff --git a/packages/node/LLMS.md b/packages/node/LLMS.md index fdf95000..63a5ce41 100644 --- a/packages/node/LLMS.md +++ b/packages/node/LLMS.md @@ -7,7 +7,7 @@ import { runFile } from "@mettascript/node"; for (const { query, results } of runFile("program.metta")) console.log(query, results); ``` `runFileAllDirectives` also reports non-`!` atoms · `readImports(src, dir, root)` resolve a program's imports to atoms yourself · `importRootPragma(src)` read its `!(pragma! import-root …)` · `ParallelFlatMatcher` SharedArrayBuffer worker scan · plus all of `core` (`runProgram` `format` `analyzeSource` …). -**CLI** `metta run program.metta` (`metta program.metta` is shorthand) · `metta check program.metta` static analysis (`--json`, `--undefined-symbols`) · `metta debug --file program.metta why '(main)'` engine debugger (why/eval/run) · `metta graph program.metta -o out.gif` render the reduction · `metta fuzz suite.metta` / `metta reach suite.metta` property + reachability suites · `metta --version`. No global install: `npx -p @mettascript/node metta run program.metta`. Flags: `--max-steps=N` `--max-stack-depth=N` `--import-root=DIR` `--hash-cons`. Aliases `metta-ts` (run) and `metta-debug` (debug) still work. +**CLI** `metta run program.metta` (`metta program.metta` is shorthand) · `metta check program.metta` static analysis (`--json`, `--undefined-symbols`) · `metta debug --file program.metta why '(main)'` engine debugger (why/eval/run) · `metta graph program.metta -o out.gif` render the reduction (`--scale=N` upscales resolution) · `metta fuzz suite.metta` / `metta reach suite.metta` property + reachability suites · `metta --version`. No global install: `npx -p @mettascript/node metta run program.metta`. Flags: `--max-steps=N` `--max-stack-depth=N` `--import-root=DIR` `--hash-cons`. Aliases `metta-ts` (run) and `metta-debug` (debug) still work. **Lazy hosts** `metta run --py program.metta` Python via pythonia · `metta run --prolog program.metta` Prolog via a local `swipl`. Without these flags Python/Prolog and their optional deps are never loaded. `metta graph` likewise loads `grapher` only when invoked (`npm i @mettascript/grapher gifenc sharp`). **Imports** A module name resolves **beside the importing file**: from `app/main.metta`, `(import! &self lib)` is `app/lib.metta`. A *relative* path may reach outside that directory only as far as the import root allows, and the root defaults to the file's parent — so `../lib` resolves, `../../lib` does not. Widen the root from inside the program (no CLI flag needed); `--import-root=DIR` overrides the pragma. ```metta diff --git a/packages/node/README.md b/packages/node/README.md index a5506a3c..4ee97d17 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -19,6 +19,7 @@ metta run program.metta # run a program (metta program.metta is shorthand) metta check program.metta # static analysis (--json, --undefined-symbols) metta debug --file program.metta why '(main)' # engine debugger (why/eval/run) metta graph program.metta -o out.gif # render the reduction as an animated GIF +metta graph --scale 2 program.metta -o out.gif # upscale the GIF resolution by 2× metta --version ```