diff --git a/packages/grapher/LLMS.md b/packages/grapher/LLMS.md index b0cb76f..190ac55 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/grapher/src/block/gif.ts b/packages/grapher/src/block/gif.ts index b552e3e..59a4669 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/node.test.ts b/packages/grapher/src/node.test.ts index a0ee570..63e2914 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 bdc3c34..a6bd6fc 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); diff --git a/packages/grapher/src/sidebyside-gif.ts b/packages/grapher/src/sidebyside-gif.ts index af2e67e..520490c 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 diff --git a/packages/node/LLMS.md b/packages/node/LLMS.md index fdf9500..63a5ce4 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 a5506a3..4ee97d1 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 ``` diff --git a/packages/node/src/graph-main.ts b/packages/node/src/graph-main.ts index 35d89fa..d82f8a3 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 19b7af2..b19df46 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 af21a82..d8c74d8 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