Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/grapher/LLMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 7 additions & 2 deletions packages/grapher/src/block/gif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;

Expand Down
25 changes: 25 additions & 0 deletions packages/grapher/src/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeGifView>(["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))", {
Expand Down
8 changes: 8 additions & 0 deletions packages/grapher/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 13 additions & 5 deletions packages/grapher/src/sidebyside-gif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/node/LLMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
13 changes: 12 additions & 1 deletion packages/node/src/graph-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,17 +39,19 @@ export async function runGraphMain(argv: string[]): Promise<void> {
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 <file.metta> [-o out.gif] [--view blocks|graph|side-by-side] [--width N] [--max-steps N]\n",
"usage: metta graph <file.metta> [-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;
Expand All @@ -57,6 +67,7 @@ export async function runGraphMain(argv: string[]): Promise<void> {
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`;
Expand Down
15 changes: 15 additions & 0 deletions packages/node/src/metta-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion packages/node/src/metta-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ usage:
metta run <file.metta> [options] run a program, printing each !-query's results
metta check <file.metta> [options] statically analyze a program (--json, --undefined-symbols)
metta debug (--file <p> | --source '<m>') <why|eval|run> [--llm] debug the engine
metta graph <file.metta> [-o out.gif] [--view blocks|graph|side-by-side] render a reduction GIF
metta graph <file.metta> [-o out.gif] [--view blocks|graph|side-by-side] [--width N] [--scale N] [--max-steps N] render a reduction GIF
metta fuzz <file.metta> [--exhaustive] [--json] run declared (FuzzTest ...) properties
metta reach <file.metta> [id] run declared (FuzzReachTest ...) searches
metta --version | --help
Expand Down