Skip to content

TypeScript

The gecode-iris npm package runs the Rust reference implementation of Iris, compiled to WebAssembly. Parsing, reading, errors, JSON and dzn all come from the Rust reader, so the package reads every input exactly as the CLI does. The package is ESM only, includes TypeScript declarations, and runs in Node 22.12 or newer, browsers, Cloudflare Workers and other edge runtimes.

The package is on npm. Install it with:

Terminal window
npm install gecode-iris

To use unreleased changes, build the package from an Iris checkout instead, which needs stable Rust with the wasm32-unknown-unknown target and wasm-bindgen-cli 0.2.129; the package README lists the setup. With your npm project beside a checkout named iris, run:

Terminal window
(cd ../iris/typescript && npm ci && npm run build)
npm install ../iris/typescript

npm links the checkout’s package directory, so rebuild it after updating the checkout. Package versions and language versions are separate; the current package is 0.1.0 and implements Iris 0.1.

The examples on this page are in typescript/examples. A test type-checks every one against the built package, runs each one that Node can run, and compares its output. They read files from the format library, so run them from the root of an Iris checkout.

read.ts
import { Spec } from "gecode-iris";
const spec = Spec.parse("n:int row[n]:int", "example.iris");
const data = spec.read("3 10 20 30", "input.txt");
console.log(data.int("n"));
console.log(data.ints("row"));
console.log(data.value("row"));
3
[ 10, 20, 30 ]
[ 10, 20, 30 ]

Spec.parse parses and checks a reader spec given as a string or a Uint8Array. The declaration reads n, then a row of n integers. spec.read reads input from a string or a Uint8Array; the origin names, such as example.iris and input.txt, appear in error messages. A Spec can read any number of inputs.

Every entry point loads the WebAssembly module before an importing module runs, so Spec.parse works at the top level of your module, with no await and no setup call. The package’s export conditions pick the entry point for your runtime.

In Node, import loads the package synchronously, as in the example above. require() works too, in Node 22.12 or newer:

require.cjs
const { Spec } = require("gecode-iris");
const spec = Spec.parse("n:int row[n]:int", "example.iris");
console.log(spec.read("3 10 20 30", "input.txt").ints("row"));
[ 10, 20, 30 ]

Deno and Bun use the same entry point through their node condition.

In a browser, the package fetches its .wasm file with top-level await, so your module’s own top level runs once the module is loaded. This module reads a text area as the user types:

browser.ts
import { IrisError, Spec } from "gecode-iris";
const spec = Spec.parse("n:int row[n]:int", "example.iris");
const input = document.querySelector("textarea");
const output = document.querySelector("output");
if (input === null || output === null) throw new Error("the page needs a textarea and an output");
input.addEventListener("input", () => {
try {
output.textContent = spec.read(input.value, "textarea").toJSON();
} catch (error) {
if (!(error instanceof IrisError)) throw error;
output.textContent = error.diagnostic;
}
});

Vite and webpack 5 emit the .wasm file as an asset. With Vite’s development server, exclude the package from dependency optimization with optimizeDeps: { exclude: ["gecode-iris"] }. With esbuild and other bundlers that do not copy the file, import gecode-iris/inline instead.

A page with a strict Content-Security-Policy must allow WebAssembly compilation with 'wasm-unsafe-eval':

Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'

Without it, loading the package fails with a CompileError.

Wrangler resolves the package’s workerd condition and uploads the .wasm file as a compiled module. This worker parses its spec once, at module scope, and reads each request body:

worker.ts
import { IrisError, Spec } from "gecode-iris";
const spec = Spec.parse("n:int row[n]:int", "example.iris");
export default {
async fetch(request: Request): Promise<Response> {
try {
const data = spec.read(new Uint8Array(await request.arrayBuffer()), "request body");
return new Response(data.toJSON(), { headers: { "Content-Type": "application/json" } });
} catch (error) {
if (!(error instanceof IrisError)) throw error;
return new Response(error.toJSON(), {
status: 422,
headers: { "Content-Type": "application/json" },
});
}
},
};

Other edge runtimes that resolve the edge-light condition use the same entry point.

gecode-iris/inline embeds the module in JavaScript and compiles it synchronously, so it needs no file access, fetch or bundler support. It works wherever WebAssembly can be compiled from bytes, but not in workerd, and it is about a third larger than the .wasm file.

inline.ts
import { Spec } from "gecode-iris/inline";
const spec = Spec.parse("n:int row[n]:int", "example.iris");
console.log(spec.read("3 10 20 30", "input.txt").ints("row"));
[ 10, 20, 30 ]

gecode-iris/manual loads nothing on import. Await initialize with a WebAssembly.Module, the module’s bytes, a Response, a URL to fetch, or a promise of any of these, before the first Spec.parse. This example reads the .wasm file from the installed package:

manual.ts
import { readFile } from "node:fs/promises";
import { initialize, Spec } from "gecode-iris/manual";
const wasm = new URL("wasm/iris_js_bg.wasm", import.meta.resolve("gecode-iris/package.json"));
await initialize(readFile(wasm));
const spec = Spec.parse("n:int row[n]:int", "example.iris");
console.log(spec.read("3 10 20 30", "input.txt").ints("row"));
[ 10, 20, 30 ]

Once a load has started, later calls to initialize return the same promise.

The package reads strings and bytes, not paths, so it runs where there is no file system. In Node, read the files with node:fs and pass the paths as origins:

files.ts
import { readFileSync } from "node:fs";
import { Spec } from "gecode-iris";
const specPath = "formats/jobshop/jobshop.iris";
const inputPath = "formats/jobshop/cases/or-library/input.txt";
const spec = Spec.parse(readFileSync(specPath), specPath);
const input = readFileSync(inputPath);
const catalog = spec.catalog(input, inputPath);
console.log(catalog.toObject());
const data = spec.read(input, inputPath, { params: { instance: "handmade-or" } });
console.log(data.int("n_jobs"), data.int("n_machines"));
{ instances: 1, name: [ 'handmade-or' ] }
3 3

spec.catalog lists the instances in an input as data. The instance parameter selects one instance during a read, by name or by its number, counting from 1. Parameter values are strings, as the Specification defines; pass { instance: "2" }, not { instance: 2 }. params also accepts an iterable of [name, value] pairs, such as a Map.

data.entries lists the entries in the order the reader spec defines them, each with its name, value type and depth. data.entry(name) returns one entry, or undefined, and data.has(name) tests for it.

access.ts
import { readFileSync } from "node:fs";
import { Spec } from "gecode-iris";
const spec = Spec.parse(readFileSync("formats/dimacs-cnf/dimacs-cnf.iris"));
const data = spec.read(readFileSync("formats/dimacs-cnf/cases/three-variables/input.txt"));
for (const entry of data.entries) {
console.log(entry);
}
console.log(data.has("clause"), data.has("model"));
console.log(data.size("clause"), data.size("clause", [1]));
console.log(data.ints("clause", [1]));
console.log(data.value("clause"));
console.log(data.toObject());
{ name: 'vars', valueType: 'int', depth: 0 }
{ name: 'clauses', valueType: 'int', depth: 0 }
{ name: 'clause', valueType: 'int', depth: 2 }
true false
3 3
[ 2, 3, -1 ]
[ [ 1, -2 ], [ 2, 3, -1 ], [ -3 ] ]
{ vars: 3, clauses: 3, clause: [ [ 1, -2 ], [ 2, 3, -1 ], [ -3 ] ] }

A path is an array of indices counted from 0. data.size(name, path) returns the length of the array that the path selects. data.int, data.float and data.string return a scalar, and data.ints, data.floats and data.strings return a row. data.value(name, path) returns a scalar or nested arrays, whose rows can have different lengths, and data.toObject() returns every entry. Each call returns a copy, and Data needs no freeing.

An unknown entry or an index outside its array throws RangeError, and a selection of the wrong value type or depth throws TypeError.

Iris integers are signed 64-bit, and a JavaScript number holds integers exactly only up to Number.MAX_SAFE_INTEGER (253 − 1). The accessors that return number throw RangeError for a larger value instead of rounding it. data.bigInt, data.bigInts, which returns a BigInt64Array, and the { bigint: true } option of value and toObject are always exact. Floats are always number.

integers.ts
import { Spec } from "gecode-iris";
const spec = Spec.parse("big:int row[2]:int");
const data = spec.read("9007199254740993 1 -9223372036854775808");
try {
data.int("big");
} catch (error) {
if (!(error instanceof RangeError)) throw error;
console.log(error.message);
}
console.log(data.bigInt("big"));
console.log(data.bigInts("row"));
console.log(data.toObject({ bigint: true }));
entry 'big': integer 9007199254740993 is outside the safe range of number; use a bigint accessor
9007199254740993n
BigInt64Array(2) [ 1n, -9223372036854775808n ]
{ big: 9007199254740993n, row: [ 1n, -9223372036854775808n ] }

data.toJSON() returns the canonical JSON document and data.toDzn() the MiniZinc data of the dzn appendix. Both end in a newline.

output.ts
import { readFileSync } from "node:fs";
import { Spec } from "gecode-iris";
const spec = Spec.parse(readFileSync("formats/dimacs-cnf/dimacs-cnf.iris"));
const data = spec.read(readFileSync("formats/dimacs-cnf/cases/three-variables/input.txt"));
process.stdout.write(data.toDzn());
process.stdout.write(data.toJSON());
vars = 3;
clauses = 3;
% clause: array[int] of tuple(array[int] of int)
clause = [([1, -2],), ([2, 3, -1],), ([-3],)];
{"iris":"0.1","entries":[{"name":"vars","type":"int","depth":0,"value":3},{"name":"clauses","type":"int","depth":0,"value":3},{"name":"clause","type":"int","depth":2,"value":[[1,-2],[2,3,-1],[-3]]}]}

JSON.stringify(data) calls toJSON and so encodes the document as one JSON string; use data.toJSON() directly.

A language error throws IrisError, a subclass of Error. Its code is the stable error code and message the message without a location. inputLocation and specLocation are each { origin, line, column } or null, and any of their fields can be null. context says what was being read, and recordPath gives the record indices of a failed check over records. diagnostic is the text that the CLI prints, and error.toJSON() returns the canonical error document.

errors.ts
import { readFileSync } from "node:fs";
import { IrisError, Spec } from "gecode-iris";
const specPath = "formats/dimacs-cnf/dimacs-cnf.iris";
const inputPath = "formats/dimacs-cnf/cases/literal-out-of-range/input.txt";
const spec = Spec.parse(readFileSync(specPath), specPath);
try {
spec.read(readFileSync(inputPath), inputPath);
} catch (error) {
if (!(error instanceof IrisError)) throw error;
console.log(error.code, error.message);
console.log(error.inputLocation);
console.log(error.specLocation);
console.log(error.context, error.recordPath);
console.log(error.diagnostic);
}
E-EXPECTED-VALUE expected an int in -3..3, found '4'
{
  origin: 'formats/dimacs-cnf/cases/literal-out-of-range/input.txt',
  line: 2,
  column: 3
}
{ origin: 'formats/dimacs-cnf/dimacs-cnf.iris', line: 7, column: null }
repeat [1 of 1] > clause null
formats/dimacs-cnf/cases/literal-out-of-range/input.txt:2:3: expected an int in -3..3, found '4'
     2 | 1 4 0
       |   ^
while reading: repeat [1 of 1] > clause
spec: formats/dimacs-cnf/dimacs-cnf.iris:7

Host failures throw built-in errors and have no error code. A string with a lone surrogate, a parameter value that is not a string, or a disposed spec throws TypeError. Data beyond the limits of the package’s 32-bit transfer format throws RangeError, and other host failures throw Error.

A trap in the WebAssembly module, which only a bug in the package can cause, throws IrisInternalError. It is not an IrisError, and its cause is the original WebAssembly.RuntimeError. The package replaces the WebAssembly instance after a trap, so later calls work, including calls on specs parsed earlier.

The language’s fixed work budgets bound every read. A read that exhausts one throws IrisError with the code E-BUDGET. The package has no budget settings.

budget.ts
import { IrisError, Spec } from "gecode-iris";
const spec = Spec.parse("n:int repeat n { a[0]:int }", "empty-rows.iris");
try {
spec.read("2000000000", "input.txt");
} catch (error) {
if (!(error instanceof IrisError)) throw error;
console.log(error.code, error.context);
console.log(error.message);
}
E-BUDGET repeat [1048577 of 2000000000]
too many repetitions or empty rows without reading input

Calls into WebAssembly are synchronous and cannot be interrupted on their own thread. For a wall-clock deadline, read in a worker and terminate it when the deadline passes. In Node, use node:worker_threads; in a browser, use a Web Worker the same way.

deadline-worker.ts
import { parentPort, workerData } from "node:worker_threads";
import { Spec } from "gecode-iris";
const { spec, input } = workerData;
parentPort?.postMessage(Spec.parse(spec).read(input).toJSON());
deadline.ts
import { Worker } from "node:worker_threads";
function readWithDeadline(spec: string, input: string, milliseconds: number): Promise<string> {
const worker = new Worker(new URL("./deadline-worker.ts", import.meta.url), {
workerData: { spec, input },
});
const timer = setTimeout(() => void worker.terminate(), milliseconds);
return new Promise<string>((resolve, reject) => {
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", () => reject(new Error("read stopped at the deadline")));
}).finally(() => clearTimeout(timer));
}
process.stdout.write(await readWithDeadline("n:int row[n]:int", "2 5 7", 5000));
{"iris":"0.1","entries":[{"name":"n","type":"int","depth":0,"value":2},{"name":"row","type":"int","depth":1,"value":[5,7]}]}

readWithDeadline starts deadline-worker.ts by its URL. Node 22.18 and newer run TypeScript directly; if you compile to JavaScript, point the URL at the compiled file.

gecode-iris/build writes a reader spec from TypeScript. Every statement that defines an entry returns a typed handle, and read returns each entry with the type of its handle: a number for n_jobs and a number[][] for machine. A builder adds no features of its own: it prints Iris text and parses it as Spec.parse does, so the language’s checks decide what is valid, and source holds the text. Like the package itself, gecode-iris/build has inline and manual entry points.

The builders below are in typescript/examples, one for each format in the format library, each with a main that reads a case when Node runs the file. A test reads every case of each format through its builder and compares the data or the error with the case’s expected results, and the examples test runs each file and compares its output with the output shown here.

This builder writes the job shop reader spec, and the program reads an instance selected by name, then a file whose check fails:

build-jobshop.ts
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import {
atLeast,
distinct,
div,
gecode,
IrisError,
p,
range,
spec,
sum,
word,
} from "gecode-iris/build";
/**
* Reads OR-Library and JSPLIB files: a header line with the numbers of jobs
* and machines, then one line per job of machine and duration pairs.
*/
export function jobshop() {
return spec("jobshop", (b) => {
b.comment("#", "+++");
return b.instances((i) => {
const name = i.optionalCapture("name", word);
const nJobs = i.capture("n_jobs", atLeast(1));
const nMachines = i.capture("n_machines", atLeast(1));
const { job, machine, duration } = i.sections({ skipOther: true }, (s) => {
s.arm(p`instance ${name}`, { optional: true });
return s.arm(p`${nJobs} ${nMachines}`, { last: true }, (a) => {
a.check(
nJobs.le(div(div(gecode.intMax.minus(2), 2), nMachines)),
"there are too many jobs and machines",
);
return a.repeat(nJobs, (r) => {
r.asName("jobs"); // error messages name the records
r.eachName("job");
return {
job: r.record,
...r.repeat(nMachines, (step) => ({
machine: step.read("machine", range(0).below(nMachines)),
duration: step.read("duration", atLeast(0)),
})),
};
});
});
});
i.check(
distinct(job.get(machine)),
"a job uses a machine twice; each job must use every machine once",
);
i.check(
sum(job, (j) => sum(j.get(duration))).le(gecode.intMax),
"the total duration is too large",
);
return { name, nJobs, nMachines, machine, duration };
});
});
}
function main(): void {
const shop = jobshop();
const path = "formats/jobshop/cases/or-library/input.txt";
const data = shop.read(readFileSync(path), { instance: "handmade-or" }, path);
console.log(`${data.name ?? "unnamed"}: ${data.nJobs} jobs on ${data.nMachines} machines`);
for (const [job, machines] of data.machine.entries()) {
const durations = data.duration[job] ?? [];
console.log(
`job ${job}: machines [${machines.join(", ")}], durations [${durations.join(", ")}]`,
);
}
const invalid = "formats/jobshop/cases/duplicate-machine/input.txt";
try {
shop.read(readFileSync(invalid), { instance: "handmade-invalid" }, invalid);
} catch (error) {
if (!(error instanceof IrisError)) throw error;
const { inputLocation: at, callSite: site } = error;
console.log(`\n${error.code} at ${at?.origin}:${at?.line}:${at?.column}`);
console.log(error.message);
// The statement's call site: the line of the distinct check in jobshop().
console.log(`call site: ${basename(site?.file ?? "")}:${site?.line}`);
}
}
if (import.meta.main) {
main();
}
handmade-or: 3 jobs on 3 machines
job 0: machines [0, 1, 2], durations [2, 3, 1]
job 1: machines [1, 2, 0], durations [1, 4, 2]
job 2: machines [2, 0, 1], durations [3, 1, 2]

E-CHECK-FAILED at formats/jobshop/cases/duplicate-machine/input.txt:3:1
check failed for record 1 of 'jobs': a job uses a machine twice; each job must use every machine once
call site: build-jobshop.ts:47

spec("jobshop", (b) => ...) builds the spec of a format. Blocks take a callback, which receives the block’s scope: b.instances, i.sections, s.arm and r.repeat each open one, and the statements the callback adds go into it. Each block returns what its callback returns, so the handles leave the blocks as objects to destructure. p`${nJobs} ${nMachines}` is a pattern with entries in its placeholders. A range is range(0).below(nMachines) for Iris 0..<n_machines, range(0, n) for 0..n and atLeast(1) for 1...

r.record is the handle of each record of a repeat: job.get(machine) looks up a field, and sum(job, (j) => ...) writes a comprehension over the records. r.asName("jobs") names the list of records, as in repeat n_jobs as jobs, for error messages. Methods build expressions, such as nJobs.le(...), a.and(b) and a.not(), and gecode.intMax is Gecode’s largest integer.

read returns an object with the handles that the callback returned, each typed by its handle: a number for nJobs, string | undefined for name, which only some files give, and number[][] for machine and duration. A handle names its entry, value type, depth and presence, and reading checks them. For integers beyond Number.MAX_SAFE_INTEGER, read with jobshop().spec and use data.get(handle, { bigint: true }), which gives bigints.

Every statement records the line that calls it. When a check fails while reading, the IrisError has the input location, and error.callSite names the line of the check in build-jobshop.ts. A BuildError from a builder has the same call site. A statement in a helper function records the line in the helper, unless the helper is wrapped in trackCaller, which makes its statements record the helper’s caller, as clueLines below does.

A function that takes a scope and returns handles is a reusable piece of a reader spec. The nonogram builder reads the clues of the rows and the columns with one helper, and builds two pairs of checks with two expression helpers.

build-nonogram.ts
import { readFileSync } from "node:fs";
import {
atLeast,
count,
div,
eol,
type Field,
min,
p,
type Scope,
size,
spec,
sum,
trackCaller,
} from "gecode-iris/build";
/**
* Reads `n` lines of comma-separated clues into the entry `name`, and
* returns the record of each line with the clues. `trackCaller` makes its
* statements name the helper's caller.
*/
const clueLines = trackCaller((s: Scope, n: Field<"int", 0>, name: string) =>
s.repeat(n, (line) => ({ lines: line.record, clues: line.list(name, atLeast(0).until(eol)) })),
);
/** A 0 clue says the line is empty, so it must stand alone. */
function zeroStandsAlone(clues: Field<"int", 2>) {
return count(clues).le(1).or(min(clues).gt(0));
}
/** The clues and one gap between each pair of them fit into `length`. */
function cluesFit(clues: Field<"int", 2>, length: Field<"int", 0>) {
return sum(clues).plus(count(clues)).minus(1).le(length);
}
/** Reads a board size and the clues of each row and column. */
export function nonogram() {
return spec("nonogram", (b) => {
b.separators(",");
const width = b.capture("width", atLeast(1));
const height = b.capture("height", atLeast(1));
const { rows, cols } = b.sections({ skipOther: true }, (s) => {
s.arm(p`width ${width}`);
s.arm(p`height ${height}`);
return {
rows: s.arm("rows", (a) => clueLines(a, height, "row")),
cols: s.arm("columns", (a) => clueLines(a, width, "col")),
};
});
const [row, col] = [rows.clues, cols.clues];
b.check(height.le(div(1_000_000, width)), "the board must have at most a million fields");
b.check(
size(rows.lines).eq(height).and(size(cols.lines).eq(width)),
"the numbers of rows and columns differ from the board size",
);
b.check(zeroStandsAlone(row), "a 0 must be the only clue of its row");
b.check(zeroStandsAlone(col), "a 0 must be the only clue of its column");
b.check(cluesFit(row, width), "the clues of a row do not fit into the width of the board");
b.check(cluesFit(col, height), "the clues of a column do not fit into the height of the board");
return { width, height, row, col };
});
}
if (import.meta.main) {
const data = nonogram().read(readFileSync("formats/nonogram/cases/five-by-five/input.txt"));
console.log(`${data.width}x${data.height} board`);
console.log(`rows: ${JSON.stringify(data.row)}`);
console.log(`columns: ${JSON.stringify(data.col)}`);
}
5x5 board
rows: [[1,1],[1,1],[1],[1,1],[1,1]]
columns: [[1,1],[1,1],[1],[1,1],[1,1]]

A builder rejects a second definition of a name, so a helper called twice by mistake fails instead of quietly replacing an entry. That is why clueLines takes the entry name from its caller. The TSPLIB builder below uses the same idea for its two coordinate sections.

build-dimacs-cnf.ts
import { readFileSync } from "node:fs";
import { atLeast, gecode, p, range, spec } from "gecode-iris/build";
/** Reads the number of variables and one row of literals per clause. */
export function dimacsCnf() {
return spec("dimacs-cnf", (b) => {
b.comment("c");
b.stop("%"); // SATLIB files end with "%" and "0"
const vars = b.capture("vars", range(0, gecode.intMax));
const clauses = b.capture("clauses", atLeast(0));
b.line(p`p cnf ${vars} ${clauses}`);
// The terminating 0 is not stored.
const clause = b.repeat(clauses, (c) => c.list("clause", range(vars.neg(), vars).until(0)));
return { vars, clause };
});
}
if (import.meta.main) {
const data = dimacsCnf().read(readFileSync("formats/dimacs-cnf/cases/three-variables/input.txt"));
console.log(`${data.vars} variables`);
for (const clause of data.clause) {
console.log(clause.join(" "));
}
}
3 variables
1 -2
2 3 -1
-3
build-sudoku.ts
import { readFileSync } from "node:fs";
import { alphabet, atLeast, max, p, spec } from "gecode-iris/build";
/** Cell values from 1 to 36, so `1` reads as 1 and `0` as 36. */
const SYMBOLS = "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ0";
/** Reads the grid size, the box size, and the cells, with 0 for a blank. */
export function sudoku() {
return spec("sudoku", (b) =>
b.instances((i) => {
const n = i.capture("n", atLeast(1));
const boxWidth = i.capture("box_width", atLeast(1));
const boxHeight = i.capture("box_height", atLeast(1));
i.line(p`# size=${n} box=${boxWidth}x${boxHeight}`);
const cells = i.read("cells", alphabet(SYMBOLS, { blank: "." }).dims(n, n));
i.check(boxWidth.times(boxHeight).eq(n), "boxes must tile the grid");
i.check(max(cells).le(n), "a value is larger than the size of the grid");
return { n, boxWidth, boxHeight, cells };
}),
);
}
if (import.meta.main) {
const data = sudoku().read(readFileSync("formats/sudoku/cases/four-by-four/input.txt"));
console.log(`${data.n}x${data.n} grid with ${data.boxWidth}x${data.boxHeight} boxes`);
for (const row of data.cells) {
console.log(row.join(" "));
}
}
4x4 grid with 2x2 boxes
1 0 0 4
0 4 1 0
0 1 4 0
4 0 0 1

defineType gives a named type, used here for both ends of a time window:

build-tsptw.ts
import { readFileSync } from "node:fs";
import { div, gecode, range, spec } from "gecode-iris/build";
/**
* Reads a distance matrix and a time window for each node, with the depot as
* node 0.
*/
export function tsptw() {
return spec("tsptw", (b) => {
b.comment("#");
const n = b.read("n", range(2, 1000));
const dist = b.read("dist", range(0, div(div(gecode.intMax, 2), n)).dims(n, n));
const time = b.defineType("time", range(0, div(gecode.intMax, 2)));
const { node, ready, due } = b.repeat(n, (r) => {
r.asName("nodes");
r.eachName("node");
return { node: r.record, ready: r.read("ready", time), due: r.read("due", time) };
});
b.check(node.get(ready).le(node.get(due)), "the time window of a node is invalid");
b.check(node.list.at(0).get(ready).eq(0), "the depot (node 0) must open at time 0");
return { n, dist, ready, due };
});
}
if (import.meta.main) {
const data = tsptw().read(readFileSync("formats/tsptw/cases/three-nodes/input.txt"));
console.log(`${data.n} nodes`);
for (const [node, distances] of data.dist.entries()) {
const window = `${data.ready[node]}..${data.due[node]}`;
console.log(`node ${node}: open ${window}, distances ${distances.join(" ")}`);
}
}
3 nodes
node 0: open 0..20, distances 0 2 4
node 1: open 2..8, distances 3 0 2
node 2: open 5..12, distances 4 3 0

A TSPLIB file gives either a distance matrix or coordinates. t.set gives an optional entry a default while keeping its handle’s type, b.if and its else take the checks that apply to each kind of file, and oneOf is an ordinary function that builds an expression:

build-tsplib.ts
import { readFileSync } from "node:fs";
import {
type Arms,
defined,
div,
type Field,
float,
floatLit,
gecode,
int,
p,
range,
rest,
size,
spec,
trackCaller,
word,
} from "gecode-iris/build";
/** Whether `entry` is one of `values`: `entry == "A" || entry == "B"`. */
function oneOf(entry: Field<"string", 0>, first: string, ...others: string[]) {
return others.reduce((test, value) => test.or(entry.eq(value)), entry.eq(first));
}
/**
* Adds the optional section `title` of `n` lines `node x y`, with the entries
* named `${prefix}node`, `${prefix}x` and `${prefix}y`.
*/
const coordinateSection = trackCaller(
(arms: Arms, title: string, n: Field<"int", 0>, prefix: string) =>
arms.arm(title, { optional: true }, (a) =>
a.repeat(n, (line) => {
line.read(`${prefix}node`, int);
const x = line.read(`${prefix}x`, float);
const y = line.read(`${prefix}y`, float);
return { nodes: line.record, x, y };
}),
),
);
/**
* Reads a TSPLIB file, which gives either an explicit distance matrix or node
* coordinates, from which the distances follow.
*/
export function tsplib() {
return spec("tsplib", (b) => {
b.comment("COMMENT");
const name = b.optionalCapture("name", rest);
const problemType = b.optionalCapture("type", word);
const n = b.capture("n", range(2, 46340));
const edgeWeightType = b.optionalCapture("edge_weight_type", word);
const edgeWeightFormat = b.optionalCapture("edge_weight_format", word);
const nodeCoordType = b.optionalCapture("node_coord_type", word);
const displayDataType = b.optionalCapture("display_data_type", word);
const { coordinates, weight } = b.sections({ until: "EOF" }, (s) => {
s.arm(p`NAME : ${name}`, { optional: true });
s.arm(p`TYPE : ${problemType}`, { optional: true });
s.arm(p`DIMENSION : ${n}`);
s.arm(p`EDGE_WEIGHT_TYPE : ${edgeWeightType}`, { optional: true });
s.arm(p`EDGE_WEIGHT_FORMAT : ${edgeWeightFormat}`, { optional: true });
s.arm(p`NODE_COORD_TYPE : ${nodeCoordType}`, { optional: true });
s.arm(p`DISPLAY_DATA_TYPE : ${displayDataType}`, { optional: true });
const handles = {
coordinates: coordinateSection(s, "NODE_COORD_SECTION", n, ""),
weight: s.arm("EDGE_WEIGHT_SECTION", { optional: true }, (a) => {
a.if(defined(edgeWeightFormat).not(), (t) => t.set(edgeWeightFormat, "FULL_MATRIX"));
const weights = range(0, div(gecode.intMax, n)).dims(n, n).layout(edgeWeightFormat);
return a.read("weight", weights);
}),
};
coordinateSection(s, "DISPLAY_DATA_SECTION", n, "display_");
return handles;
});
b.if(defined(edgeWeightType).not(), (t) => t.set(edgeWeightType, "EXPLICIT"));
const { x, y } = coordinates;
const limit = floatLit(1e9);
b.if(edgeWeightType.eq("EXPLICIT"), (t) => {
t.check(defined(weight), "no EDGE_WEIGHT_SECTION");
t.check(size(weight).eq(n), "edge weights do not match DIMENSION");
}).else((e) => {
e.check(
oneOf(edgeWeightType, "EUC_2D", "CEIL_2D", "ATT", "GEO"),
"EDGE_WEIGHT_TYPE must be EXPLICIT, EUC_2D, CEIL_2D, ATT, or GEO",
);
e.check(defined(x), "no NODE_COORD_SECTION");
e.check(size(coordinates.nodes).eq(n), "coordinates do not match DIMENSION");
e.check(
x.ge(limit.neg()).and(x.le(limit)).and(y.ge(limit.neg())).and(y.le(limit)),
"coordinates must be between -1e9 and 1e9",
);
});
return { name, n, edgeWeightType, weight, x, y };
});
}
if (import.meta.main) {
const problem = tsplib();
for (const example of ["explicit-five", "euc-2d-five"]) {
const data = problem.read(readFileSync(`formats/tsplib/cases/${example}/input.txt`));
console.log(`${data.name ?? "unnamed"}: ${data.n} cities, ${data.edgeWeightType ?? "?"}`);
for (const row of data.weight ?? []) {
console.log(` ${row.join(" ")}`);
}
if (data.x !== undefined && data.y !== undefined) {
console.log(` x ${data.x.join(" ")}\n y ${data.y.join(" ")}`);
}
}
}
handmade-explicit-five: 5 cities, EXPLICIT
  0 2 4 6 3
  2 0 5 4 7
  4 5 0 3 6
  6 4 3 0 2
  3 7 6 2 0
handmade-euc-2d-five: 5 cities, EUC_2D
  x 0 3 3 0 1.5
  y 0 0 4 4 2

A reader spec written as an .iris file has no builder, but iris gen --lang typescript SPEC prints a module with a typed handle for each of its entries:

Terminal window
iris gen --lang typescript formats/jobshop/jobshop.iris
// Typed handles of a reader spec, generated by `iris gen --lang typescript`.

import type { Handle } from "gecode-iris";

export const name: Handle<"string", 0, "maybe"> = {
  name: "name",
  valueType: "string",
  depth: 0,
  presence: "maybe",
};

export const n_jobs: Handle<"int", 0, "required"> = {
  name: "n_jobs",
  valueType: "int",
  depth: 0,
  presence: "required",
};

export const n_machines: Handle<"int", 0, "required"> = {
  name: "n_machines",
  valueType: "int",
  depth: 0,
  presence: "required",
};

export const machine: Handle<"int", 2, "required"> = {
  name: "machine",
  valueType: "int",
  depth: 2,
  presence: "required",
};

export const duration: Handle<"int", 2, "required"> = {
  name: "duration",
  valueType: "int",
  depth: 2,
  presence: "required",
};

Save the output as a module, such as jobshop-view.ts, import it with import * as jobshop from "./jobshop-view.ts", and read data from Spec.parse with data.get(jobshop.machine), which returns a number[][]. The module imports only the Handle type, so it adds nothing to a bundle but the handles. An entry whose value type or depth differs between the places that define it gets a handle with the value type "dyn", which returns a Value | undefined.

Only a Spec holds WebAssembly memory, and garbage collection releases it. To release it earlier, declare the spec with using, or call spec[Symbol.dispose](). Calling free() is never required.