Environment: Node.js 20+ (developed/verified on Node 22 with Node 20 language features; no third-party dependencies).
I built the checker, ran it on three programs, verified determinism, and fuzzed 35,000 random programs with zero verdict/length mismatches. The complete self-contained solution is below (also saved at ~/por-mc/SOLUTION.md, with runnable sources in ~/por-mc/).
Environment: Node.js 20+ (developed/verified on Node 22 with Node 20 language features; no third-party dependencies).
Deliverable: a state-space model checker for a small CSP-style language (JSON: processes with local location state, shared variables, guarded atomic actions) that
All commands below were executed and the outputs shown are real.
The hard part is not "explore a graph". It is removing transitions without changing the answer. Three independent failure modes cause almost all bugs in this kind of checker.
The tempting optimization is: at every state, pick one enabled transition and explore only it. If the picked transition is a stutter self-loop (it leaves the state unchanged) the checker marks the successor as already visited and the search stops there. Any deadlock reachable only through one of the other enabled transitions is then invisible.
This is exactly the failure in programs/missed_deadlock.json:
P0.stutter has guard x == 0, effect x = x, target s0. It writes x (so it is syntactically dependent on anything else that touches x) but never changes the state.P1.setx has guard x == 0, effect x = 1, target s1 (a dead state).P2/P3 are independent work chains that must also finish.Full search finds the deadlock in 5 steps: P1.setx disables P0.stutter, then P2/P3 finish. A dependency-blind POR picks P0.stutter, loops to the initial state, sees it visited, and reports DEADLOCK-FREE — it misses the real deadlock. The naive reduction looks "stutter-equivalent" in isolation, which is precisely why the classic conditions exist.
Two transitions are dependent when they touch a common variable with at least one writer. If a state's ample set omits an enabled transition dependent on a member, the two cannot be commuted and behavior is lost — condition C1. The implementation enforces:
the ample set must be closed under dependency among the enabled transitions of the current state.
At the initial state, seed P0.stutter writes x; P1.setx reads/writes x, so they are dependent and the closure pulls P1.setx in. C1 rejects the naive {P0.stutter}.
A set can satisfy C1 and still be wrong on a cycle: a pure stutter cycle whose members are independent of everything else lets the reduction spin forever and never schedule omitted independent transitions. C3 forbids a reduced cycle in which every state was reduced. It is enforced with a DFS stack: if the ample successors of the state being expanded close a cycle, that state is expanded fully. In missed_deadlock the stutter self-loop is exactly such a back edge.
The full search is BFS, so the first deadlock dequeued is at minimum distance. Any reduced path is a real path (subgraph), so reduced distance ≥ full distance. Independence gives equality: postponed transitions commute with ample transitions, so a shortest path can be rewritten to start with an ample transition at the same length. C1 guarantees the commutation; the artifact checks the equality empirically on every program.
Classic C2 ("if not fully expanded, every ample transition must be invisible w.r.t. the property") is needed for LTL\X. Deadlock is a state predicate, so C2 is configurable via visibleVars and vacuous by default; it is still computed and reported so the full C0–C3 set is present.
The order is fixed everywhere: processes in array order, transitions in array order, effect keys sorted, shared variables sorted, canonical JSON state keys, FIFO BFS, reduced-graph adjacency sorted by transition index. verify.mjs proves two independent runs are byte-identical.
{
"name": "...",
"shared": { "x": 0 },
"visibleVars": [],
"processes": [
{
"name": "P0",
"init": "s0",
"transitions": [
{ "label": "a", "from": "s0", "guard": "x == 0", "effect": { "x": 1 }, "to": "s1" }
]
}
]
}
from and the guard is true; it updates shared variables simultaneously (RHS reads the pre-state) and moves the process to to.@loc:<process>, so all transitions of one process are mutually dependent automatically.dep(a,b) iff a.writes ∩ (b.reads ∪ b.writes) ≠ ∅ or b.writes ∩ (a.reads ∪ a.writes) ≠ ∅.stubbornClosure(seed) = transitive closure under dep over all transitions (enabled or not). Including disabled transitions prevents a postponed outside transition from later enabling something dependent on the ample set before the ample set runs.ample(s) = closure(seed) ∩ enabled(s), with seed the first enabled transition in canonical order.ample = ∅ ⟺ enabled = ∅.ample is closed under dependency among enabled transitions (guaranteed by the closure; re-checked).ample ≠ enabled, every ample transition must be invisible; vacuous for deadlock unless visibleVars is set.por-mc/
package.json
por-mc.mjs # checker + CLI + artifact emitter
verify.mjs # end-to-end verification
fuzz.mjs # randomized differential fuzzer
programs/
independent_chain.json # deadlock, big reduction
deadlock_free_chain.json # no deadlock, big reduction
missed_deadlock.json # counterexample: naive POR misses the deadlock
artifacts/ # generated: one JSON artifact per program + summary.json
cd por-mc
node por-mc.mjs # run all programs, write artifacts/, print report
node por-mc.mjs programs/missed_deadlock.json
node verify.mjs # determinism + differential + counterexample + fuzz
node fuzz.mjs 15000 # differential fuzz only
por-mc.mjs#!/usr/bin/env node
// por-mc.mjs -- state-space model checker for a small CSP-style language.
//
// Input: JSON program with shared variables, processes with a local location,
// and guarded atomic transitions whose effects assign shared variables.
//
// The checker:
// 1. exhaustively explores the interleaving state space (BFS) -> shortest deadlock
// 2. explores a partial-order-reduced state space using ample/stubborn sets
// with the classic C0..C3 conditions
// 3. runs a deliberately dependency-blind POR to demonstrate unsoundness
// 4. emits a differential artifact proving the reduced search agrees with the
// full search while doing strictly less work.
//
// Everything is deterministic: processes are visited in array order, transitions
// in array order, effect keys in sorted order, BFS is FIFO, and reduced-graph
// adjacency is sorted by transition index.
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// ---------------------------------------------------------------------------
// Expression language (safe, dependency-free): == != < <= > >= && || ! + - * / %
// ---------------------------------------------------------------------------
const TWO_CHAR_OPS = new Set(['==', '!=', '<=', '>=', '&&', '||']);
function tokenize(src) {
const tokens = [];
let i = 0;
const isDigit = (c) => c >= '0' && c <= '9';
const isAlpha = (c) => /[A-Za-z_]/.test(c);
const isAlnum = (c) => /[A-Za-z0-9_]/.test(c);
while (i < src.length) {
const c = src[i];
if (/\s/.test(c)) { i++; continue; }
const two = src.slice(i, i + 2);
if (TWO_CHAR_OPS.has(two)) { tokens.push({ type: 'op', value: two }); i += 2; continue; }
if ('+-*/%!<>'.includes(c)) { tokens.push({ type: 'op', value: c }); i++; continue; }
if (c === '(' || c === ')') { tokens.push({ type: 'punc', value: c }); i++; continue; }
if (isDigit(c)) {
let j = i; while (j < src.length && isDigit(src[j])) j++;
tokens.push({ type: 'num', value: Number(src.slice(i, j)) }); i = j; continue;
}
if (isAlpha(c)) {
let j = i; while (j < src.length && isAlnum(src[j])) j++;
tokens.push({ type: 'ident', value: src.slice(i, j) }); i = j; continue;
}
throw new Error(`Unexpected character '${c}' in expression: ${src}`);
}
tokens.push({ type: 'eof' });
return tokens;
}
function parseExpression(src) {
const tokens = tokenize(src);
let pos = 0;
const peek = () => tokens[pos];
const next = () => tokens[pos++];
function parseOr() {
let left = parseAnd();
while (peek().type === 'op' && peek().value === '||') {
next();
left = { type: 'binary', op: '||', left, right: parseAnd() };
}
return left;
}
function parseAnd() {
let left = parseCmp();
while (peek().type === 'op' && peek().value === '&&') {
next();
left = { type: 'binary', op: '&&', left, right: parseCmp() };
}
return left;
}
function parseCmp() {
const left = parseAdd();
const t = peek();
if (t.type === 'op' && ['==', '!=', '<', '<=', '>', '>='].includes(t.value)) {
next();
return { type: 'binary', op: t.value, left, right: parseAdd() };
}
return left;
}
function parseAdd() {
let left = parseMul();
while (peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
const op = next().value;
left = { type: 'binary', op, left, right: parseMul() };
}
return left;
}
function parseMul() {
let left = parseUnary();
while (peek().type === 'op' && ['*', '/', '%'].includes(peek().value)) {
const op = next().value;
left = { type: 'binary', op, left, right: parseUnary() };
}
return left;
}
function parseUnary() {
if (peek().type === 'op' && (peek().value === '!' || peek().value === '-')) {
const op = next().value;
return { type: 'unary', op, operand: parseUnary() };
}
return parsePrimary();
}
function parsePrimary() {
const t = next();
if (t.type === 'num') return { type: 'num', value: t.value };
if (t.type === 'ident') {
if (t.value === 'true') return { type: 'bool', value: true };
if (t.value === 'false') return { type: 'bool', value: false };
return { type: 'var', name: t.value };
}
if (t.type === 'punc' && t.value === '(') {
const e = parseOr();
const close = next();
if (close.value !== ')') throw new Error(`Expected ')' in expression: ${src}`);
return e;
}
throw new Error(`Unexpected token in expression: ${src}`);
}
const ast = parseOr();
if (peek().type !== 'eof') throw new Error(`Trailing tokens in expression: ${src}`);
return ast;
}
function truthy(v) { return v === true || (typeof v === 'number' && v !== 0) || (typeof v === 'string' && v.length > 0); }
function evaluate(ast, env) {
switch (ast.type) {
case 'num': return ast.value;
case 'bool': return ast.value;
case 'var': {
if (!(ast.name in env)) throw new Error(`Unbound variable '${ast.name}'`);
return env[ast.name];
}
case 'unary': {
const v = evaluate(ast.operand, env);
return ast.op === '!' ? !truthy(v) : -v;
}
case 'binary': {
if (ast.op === '&&') return truthy(evaluate(ast.left, env)) && truthy(evaluate(ast.right, env));
if (ast.op === '||') return truthy(evaluate(ast.left, env)) || truthy(evaluate(ast.right, env));
const a = evaluate(ast.left, env);
const b = evaluate(ast.right, env);
switch (ast.op) {
case '==': return a === b;
case '!=': return a !== b;
case '<': return a < b;
case '<=': return a <= b;
case '>': return a > b;
case '>=': return a >= b;
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return Math.trunc(a / b);
case '%': return a % b;
default: throw new Error(`Unknown operator ${ast.op}`);
}
}
default: throw new Error(`Unknown AST node ${ast.type}`);
}
}
function collectIdents(ast, out = new Set()) {
if (!ast) return out;
switch (ast.type) {
case 'var': out.add(ast.name); break;
case 'unary': collectIdents(ast.operand, out); break;
case 'binary': collectIdents(ast.left, out); collectIdents(ast.right, out); break;
default: break;
}
return out;
}
// ---------------------------------------------------------------------------
// Program model
// ---------------------------------------------------------------------------
function loadProgram(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function buildModel(prog) {
const sharedVars = Object.keys(prog.shared || {}).sort();
const initShared = {};
for (const v of sharedVars) initShared[v] = prog.shared[v];
const sharedVarSet = new Set(sharedVars);
const transitions = [];
for (let pid = 0; pid < prog.processes.length; pid++) {
const p = prog.processes[pid];
for (let k = 0; k < p.transitions.length; k++) {
const tr = p.transitions[k];
const locVar = `@loc:${p.name}`;
const guardAst = tr.guard == null ? null : parseExpression(String(tr.guard));
const effectEntries = Object.keys(tr.effect || {}).sort()
.map((v) => [v, parseExpression(String(tr.effect[v]))]);
const reads = new Set(collectIdents(guardAst));
const writes = new Set();
for (const [v, ast] of effectEntries) {
writes.add(v);
for (const id of collectIdents(ast)) reads.add(id);
}
reads.add(locVar);
writes.add(locVar);
transitions.push({
index: transitions.length,
pid,
pname: p.name,
tid: k,
label: tr.label || `t${k}`,
from: tr.from,
to: tr.to,
guard: tr.guard == null ? 'true' : String(tr.guard),
guardAst,
effectEntries,
reads,
writes,
locVar,
});
}
}
// Dependency matrix: two transitions are dependent iff they touch a common
// variable with at least one writer (this includes the process location, so
// two transitions of the same process are always dependent).
const dep = transitions.map(() => new Array(transitions.length).fill(false));
for (let a = 0; a < transitions.length; a++) {
for (let b = 0; b < transitions.length; b++) {
const A = transitions[a], B = transitions[b];
let conflict = false;
for (const v of A.writes) if (B.reads.has(v) || B.writes.has(v)) { conflict = true; break; }
if (!conflict) for (const v of B.writes) if (A.reads.has(v) || A.writes.has(v)) { conflict = true; break; }
dep[a][b] = conflict;
}
}
const visibleVars = new Set(prog.visibleVars || []);
const visible = transitions.map((t) => {
for (const v of t.writes) if (visibleVars.has(v)) return true;
return false;
});
const model = {
name: prog.name || path.basename('program'),
sharedVars,
observations: prog.observations || [],
processNames: prog.processes.map((p) => p.name),
transitions,
dep,
visible,
initState: { shared: { ...initShared }, locs: prog.processes.map((p) => p.init) },
};
model.key = (s) => stateKey(model, s);
model.enabled = (s) => enabledTransitions(model, s);
model.apply = (s, ti) => applyTransition(model, s, ti);
model.describe = (ti) => {
const t = model.transitions[ti];
return `${t.pname}.${t.label}`;
};
return model;
}
function stateKey(_model, s) {
return JSON.stringify([s.shared, s.locs]);
}
function enabledTransitions(model, s) {
const res = [];
for (let ti = 0; ti < model.transitions.length; ti++) {
const t = model.transitions[ti];
if (s.locs[t.pid] !== t.from) continue;
if (t.guardAst !== null && !truthy(evaluate(t.guardAst, s.shared))) continue;
res.push(ti);
}
return res;
}
function applyTransition(model, s, ti) {
const t = model.transitions[ti];
const newShared = { ...s.shared };
// Simultaneous assignment: all right-hand sides see the pre-state.
const pending = [];
for (const [v, ast] of t.effectEntries) pending.push([v, evaluate(ast, s.shared)]);
for (const [v, val] of pending) newShared[v] = val;
const locs = s.locs.slice();
locs[t.pid] = t.to;
return { shared: newShared, locs };
}
// ---------------------------------------------------------------------------
// Ample / stubborn set computation with C0..C3
// ---------------------------------------------------------------------------
// Dependency closure over ALL transitions (enabled or not). Starting from an
// enabled seed this yields a stubborn set: every transition that conflicts with
// a member is a member, so anything left out is independent of everything
// inside and can never enable a member. We branch only on the enabled subset.
function stubbornClosure(seed, transitions, dep) {
const inSet = new Set([seed]);
const queue = [seed];
while (queue.length) {
const t = queue.shift();
for (let u = 0; u < transitions.length; u++) {
if (!inSet.has(u) && dep[t][u]) { inSet.add(u); queue.push(u); }
}
}
return inSet;
}
// Compute the ample set and record the reasoning for the differential artifact.
function computeAmple(model, s) {
const enabled = enabledTransitions(model, s);
const record = {
state: model.key(s),
enabled: enabled.map((i) => model.describe(i)),
enabledIdx: enabled.slice(),
seed: null,
closure: [],
naiveCandidate: null,
naiveRejected: false,
rejectReason: [],
conditions: { C0: null, C1: null, C2: null, C3: null },
};
// C0: empty ample iff no enabled transition.
if (enabled.length === 0) {
record.conditions.C0 = { passed: true, note: 'no enabled transitions => deadlock state' };
return { ample: [], full: true, record };
}
const seed = enabled[0];
record.seed = model.describe(seed);
record.naiveCandidate = [model.describe(seed)];
const closure = stubbornClosure(seed, model.transitions, model.dep);
record.closure = [...closure].sort((a, b) => a - b).map((i) => model.describe(i));
const ample = enabled.filter((i) => closure.has(i));
// Did the dependency closure enlarge the naive single-transition candidate?
const added = ample.filter((i) => i !== seed);
if (added.length > 0) {
record.naiveRejected = true;
record.rejectReason.push(
`C1: enabled transition(s) ${added.map((i) => model.describe(i)).join(', ')} ` +
`depend on ${model.describe(seed)} and must be in the ample set`
);
}
// C1: the enabled restriction must be closed under dependence.
let c1 = true;
for (const t of ample) {
for (const u of enabled) {
if (model.dep[t][u] && !ample.includes(u)) { c1 = false; break; }
}
if (!c1) break;
}
record.conditions.C1 = { passed: c1, note: c1 ? 'ample closed under enabled dependencies' : 'enabled dependent transition omitted' };
// C2: for a proper subset, every ample transition must be invisible w.r.t. the
// checked property. For pure deadlock detection the property is a state
// predicate, so transitions are invisible unless the user names visibleVars.
let c2 = true;
if (ample.length !== enabled.length) {
for (const t of ample) if (model.visible[t]) { c2 = false; break; }
}
record.conditions.C2 = {
passed: c2,
note: ample.length === enabled.length ? 'fully expanded, C2 vacuous' : (c2 ? 'ample transitions invisible' : 'visible transition in proper ample set'),
};
// If C1/C2 do not hold, fall back to full expansion. (With the all-transition
// closure C1 always holds; this is a belt-and-braces guard.)
if (!c1 || !c2) {
record.conditions.C0 = { passed: true, note: 'nonempty enabled set' };
record.conditions.C3 = { passed: true, note: 'full expansion' };
return { ample: enabled, full: true, record };
}
record.conditions.C0 = { passed: true, note: 'nonempty enabled set' };
record.conditions.C3 = { passed: true, note: 'checked during reduced-graph construction' };
return { ample, full: ample.length === enabled.length, record };
}
// ---------------------------------------------------------------------------
// Full exploration (BFS)
// ---------------------------------------------------------------------------
function bfs(model, adjacency) {
const init = model.initState;
const initKey = model.key(init);
const parent = new Map([[initKey, null]]);
const queue = [init];
const adjacencyStore = new Map();
let exploredTransitions = 0;
let expandedStates = 0;
let deadlockKey = null;
let deadlockTransitions = 0;
while (queue.length > 0) {
const s = queue.shift();
const key = model.key(s);
const enabled = model.enabled(s);
const adj = adjacency ? adjacency(model, s, key, enabled) : enabled;
adjacencyStore.set(key, adj);
if (adj.length === 0) {
deadlockKey = key;
break;
}
expandedStates++;
for (const ti of adj) {
exploredTransitions++;
const ns = model.apply(s, ti);
const nk = model.key(ns);
if (!parent.has(nk)) {
parent.set(nk, { parentKey: key, ti });
queue.push(ns);
}
}
}
const trace = [];
if (deadlockKey !== null) {
let k = deadlockKey;
while (parent.get(k) !== null) {
const { parentKey, ti } = parent.get(k);
trace.push({ state: k, transition: model.describe(ti), ti });
k = parentKey;
}
trace.push({ state: k, transition: null, ti: null });
trace.reverse();
deadlockTransitions = trace.length - 1;
}
return {
deadlock: deadlockKey !== null,
length: deadlockTransitions,
exploredTransitions,
expandedStates,
reachableStates: parent.size,
deadlockState: deadlockKey,
trace,
adjacency: adjacencyStore,
};
}
// Dependency-blind POR: always branch on the first enabled transition and
// ignore all dependency information. This is the classic unsound reduction.
function naiveAdjacency(_model, _s, _key, enabled) {
return enabled.length > 0 ? [enabled[0]] : [];
}
// Reduced graph built with a DFS that enforces the C3 cycle proviso: when the
// ample successors of the state currently on the DFS stack close a cycle, that
// state is expanded fully, guaranteeing every reduced cycle contains a fully
// expanded state.
function buildReducedGraph(model) {
const nodes = new Map(); // key -> { state, ample, full, record }
const edges = new Map(); // key -> [ti]
const onStack = new Set();
const diagnostics = [];
let c3Forced = 0;
function visit(state) {
const key = model.key(state);
if (nodes.has(key)) return;
onStack.add(key);
const { ample, full, record } = computeAmple(model, state);
let finalAmple = ample;
let finalFull = full;
// C3 cycle proviso.
if (!full) {
const succKeys = ample.map((ti) => model.key(model.apply(state, ti)));
const closesCycle = succKeys.some((k) => onStack.has(k));
if (closesCycle) {
finalAmple = model.enabled(state);
finalFull = true;
c3Forced++;
record.naiveRejected = true;
record.rejectReason.push('C3: ample successor is on the DFS stack (cycle); expanding fully so every reduced cycle has a fully expanded state');
record.conditions.C3 = { passed: false, forcedFull: true, note: 'ample would close a cycle without a fully expanded state' };
} else {
record.conditions.C3 = { passed: true, note: 'no cycle closed by this ample set' };
}
}
nodes.set(key, { state, ample: finalAmple, full: finalFull, record });
diagnostics.push(record);
const sorted = finalAmple.slice().sort((a, b) => a - b);
edges.set(key, sorted);
for (const ti of sorted) {
const ns = model.apply(state, ti);
visit(ns);
}
onStack.delete(key);
}
visit(model.initState);
return { nodes, edges, diagnostics, c3Forced };
}
function runReduced(model) {
const { nodes, edges, diagnostics, c3Forced } = buildReducedGraph(model);
const adjacency = (_model, _s, key, enabled) => {
if (edges.has(key)) return edges.get(key);
return enabled; // unreachable safeguard
};
const result = bfs(model, adjacency);
return { ...result, diagnostics, c3Forced, reducedNodes: nodes.size };
}
// ---------------------------------------------------------------------------
// Differential artifact
// ---------------------------------------------------------------------------
function ratio(num, den) { return den === 0 ? 0 : Number((num / den).toFixed(6)); }
function analyze(prog, sourceFile) {
const model = buildModel(prog);
const full = bfs(model, null);
const naive = bfs(model, naiveAdjacency);
const reduced = runReduced(model);
const initialRecord = reduced.diagnostics.find((d) => d.state === model.key(model.initState)) || null;
const checks = {
sameVerdict: full.deadlock === reduced.deadlock,
sameShortestLength: full.length === reduced.length,
strictlyFewerTransitions: reduced.exploredTransitions < full.exploredTransitions,
reductionRatio: ratio(full.exploredTransitions - reduced.exploredTransitions, full.exploredTransitions),
naiveVerdictMatchesFull: naive.deadlock === full.deadlock,
};
const artifact = {
program: prog.name || path.basename(sourceFile),
source: sourceFile,
full: {
verdict: full.deadlock ? 'DEADLOCK' : 'DEADLOCK-FREE',
shortestDeadlockLength: full.length,
exploredTransitions: full.exploredTransitions,
expandedStates: full.expandedStates,
reachableStates: full.reachableStates,
trace: full.trace.map((x) => x.transition ? `${x.transition}` : '(init)'),
},
amplePOR: {
verdict: reduced.deadlock ? 'DEADLOCK' : 'DEADLOCK-FREE',
shortestDeadlockLength: reduced.length,
exploredTransitions: reduced.exploredTransitions,
expandedStates: reduced.expandedStates,
reachableStates: reduced.reachableStates,
c3ForcedFullExpansions: reduced.c3Forced,
trace: reduced.trace.map((x) => x.transition ? `${x.transition}` : '(init)'),
},
dependencyBlindPOR: {
verdict: naive.deadlock ? 'DEADLOCK' : 'DEADLOCK-FREE',
shortestDeadlockLength: naive.length,
exploredTransitions: naive.exploredTransitions,
},
checks,
ampleDiagnostic: initialRecord,
};
artifact.pass = checks.sameVerdict && checks.sameShortestLength && checks.strictlyFewerTransitions;
return artifact;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function readProgramList(args) {
const files = [];
for (const a of args) {
if (a.startsWith('--')) continue;
const st = fs.existsSync(a) ? fs.statSync(a) : null;
if (st && st.isDirectory()) {
for (const f of fs.readdirSync(a).sort()) if (f.endsWith('.json')) files.push(path.join(a, f));
} else if (st && st.isFile()) {
files.push(a);
}
}
return files;
}
function main() {
const args = process.argv.slice(2);
const programDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), 'programs');
let files = readProgramList(args.length ? args : [programDir]);
if (files.length === 0) {
console.error('No program files given. Usage: node por-mc.mjs <program.json | programs-dir>');
process.exit(2);
}
const artifacts = files.map((f) => analyze(loadProgram(f), path.relative(process.cwd(), f)));
const summary = {
node: process.version,
programs: artifacts.length,
allPass: artifacts.every((a) => a.pass),
results: artifacts,
};
const outDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), 'artifacts');
fs.mkdirSync(outDir, { recursive: true });
for (const a of artifacts) {
fs.writeFileSync(path.join(outDir, `${a.program}.json`), JSON.stringify(a, null, 2));
}
fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summary, null, 2));
// Human-readable report.
for (const a of artifacts) {
console.log(`\n=== ${a.program} (${a.source}) ===`);
console.log(`full : ${a.full.verdict.padEnd(13)} len=${a.full.shortestDeadlockLength} transitions=${a.full.exploredTransitions} states=${a.full.reachableStates}`);
console.log(`ample : ${a.amplePOR.verdict.padEnd(13)} len=${a.amplePOR.shortestDeadlockLength} transitions=${a.amplePOR.exploredTransitions} states=${a.amplePOR.reachableStates} c3Forced=${a.amplePOR.c3ForcedFullExpansions}`);
console.log(`naive : ${a.dependencyBlindPOR.verdict.padEnd(13)} len=${a.dependencyBlindPOR.shortestDeadlockLength} transitions=${a.dependencyBlindPOR.exploredTransitions}`);
console.log(`checks : verdict=${a.checks.sameVerdict} length=${a.checks.sameShortestLength} fewer=${a.checks.strictlyFewerTransitions} ratio=${(a.checks.reductionRatio * 100).toFixed(1)}% naiveAgrees=${a.checks.naiveVerdictMatchesFull}`);
if (!a.checks.sameVerdict || !a.checks.sameShortestLength || !a.checks.strictlyFewerTransitions) console.log(' ** CHECK FAILED **');
if (a.full.trace.length) console.log(` full trace : ${a.full.trace.join(' -> ')}`);
if (a.amplePOR.trace.length) console.log(` ample trace: ${a.amplePOR.trace.join(' -> ')}`);
const d = a.ampleDiagnostic;
if (d && d.naiveRejected) {
console.log(` ample@init : seed=${d.seed} naive=[${d.naiveCandidate}] rejected -> [${d.closure}]`);
for (const r of d.rejectReason) console.log(` ${r}`);
}
}
console.log(`\nallPass=${summary.allPass}`);
if (!summary.allPass) process.exit(1);
}
export {
buildModel, bfs, naiveAdjacency, buildReducedGraph, runReduced, analyze,
computeAmple, stubbornClosure, enabledTransitions, applyTransition, stateKey,
parseExpression, evaluate, loadProgram,
};
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
programs/independent_chain.json{
"name": "independent_chain",
"shared": { "x0": 0, "x1": 0, "x2": 0 },
"processes": [
{
"name": "P0", "init": "s0",
"transitions": [
{ "label": "a0", "from": "s0", "effect": { "x0": 1 }, "to": "s1" },
{ "label": "a1", "from": "s1", "effect": { "x0": 2 }, "to": "s2" }
]
},
{
"name": "P1", "init": "s0",
"transitions": [
{ "label": "b0", "from": "s0", "effect": { "x1": 1 }, "to": "s1" },
{ "label": "b1", "from": "s1", "effect": { "x1": 2 }, "to": "s2" }
]
},
{
"name": "P2", "init": "s0",
"transitions": [
{ "label": "c0", "from": "s0", "effect": { "x2": 1 }, "to": "s1" },
{ "label": "c1", "from": "s1", "effect": { "x2": 2 }, "to": "s2" }
]
}
]
}
programs/deadlock_free_chain.json{
"name": "deadlock_free_chain",
"shared": { "x0": 0, "x1": 0, "keepalive": 0 },
"processes": [
{
"name": "P0", "init": "s0",
"transitions": [
{ "label": "a0", "from": "s0", "effect": { "x0": 1 }, "to": "s1" },
{ "label": "a1", "from": "s1", "effect": { "x0": 2 }, "to": "s2" }
]
},
{
"name": "P1", "init": "s0",
"transitions": [
{ "label": "b0", "from": "s0", "effect": { "x1": 1 }, "to": "s1" },
{ "label": "b1", "from": "s1", "effect": { "x1": 2 }, "to": "s2" }
]
},
{
"name": "Keepalive", "init": "s0",
"transitions": [
{ "label": "tick", "from": "s0", "effect": { "keepalive": "keepalive" }, "to": "s0" }
]
}
]
}
programs/missed_deadlock.json{
"name": "missed_deadlock",
"shared": { "x": 0, "y": 0, "z": 0 },
"processes": [
{
"name": "P0", "init": "s0",
"transitions": [
{ "label": "stutter", "from": "s0", "guard": "x == 0", "effect": { "x": "x" }, "to": "s0" }
]
},
{
"name": "P1", "init": "s0",
"transitions": [
{ "label": "setx", "from": "s0", "guard": "x == 0", "effect": { "x": 1 }, "to": "s1" }
]
},
{
"name": "P2", "init": "s0",
"transitions": [
{ "label": "c0", "from": "s0", "effect": { "y": 1 }, "to": "s1" },
{ "label": "c1", "from": "s1", "effect": { "y": 2 }, "to": "s2" }
]
},
{
"name": "P3", "init": "s0",
"transitions": [
{ "label": "d0", "from": "s0", "effect": { "z": 1 }, "to": "s1" },
{ "label": "d1", "from": "s1", "effect": { "z": 2 }, "to": "s2" }
]
}
]
}
fuzz.mjs#!/usr/bin/env node
// Randomized differential fuzzing: full BFS vs ample-POR.
// Checks verdict AND shortest-deadlock length agree on many random programs.
import { buildModel, bfs, runReduced, analyze } from './por-mc.mjs';
// Deterministic PRNG so runs are reproducible.
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick(rnd, arr) { return arr[Math.floor(rnd() * arr.length)]; }
function int(rnd, n) { return Math.floor(rnd() * n); }
function genProgram(seed) {
const rnd = mulberry32(seed);
const nVars = 2 + int(rnd, 2);
const nProc = 2 + int(rnd, 3);
const vars = Array.from({ length: nVars }, (_, i) => `v${i}`);
const shared = {};
for (const v of vars) shared[v] = int(rnd, 2);
const guardPool = ['true'];
for (const v of vars) guardPool.push(`${v} == 0`, `${v} == 1`, `${v} < 1`, `${v} >= 0`, `${v} != 0`);
const exprPool = ['0', '1'];
for (const v of vars) exprPool.push(v, `1 - ${v}`, `${v} < 1`, `${v} == 0`, `${v} != 0`);
const processes = [];
for (let p = 0; p < nProc; p++) {
const nLoc = 1 + int(rnd, 3);
const transitions = [];
for (let l = 0; l < nLoc; l++) {
const nT = int(rnd, 4);
for (let k = 0; k < nT; k++) {
const effect = {};
const nW = int(rnd, 2);
for (let w = 0; w < nW; w++) effect[pick(rnd, vars)] = pick(rnd, exprPool);
transitions.push({
label: `t${l}_${k}`,
from: l,
guard: pick(rnd, guardPool),
effect,
to: int(rnd, nLoc),
});
}
}
processes.push({ name: `P${p}`, init: 0, transitions });
}
return { name: `fuzz_${seed}`, shared, processes };
}
const N = Number(process.argv[2] || 20000);
let mismatches = 0;
let lengthMismatch = 0;
let reducedNotLess = 0;
let tested = 0;
for (let seed = 1; seed <= N; seed++) {
const prog = genProgram(seed);
let model;
try { model = buildModel(prog); } catch { continue; }
let full, reduced;
try {
full = bfs(model, null);
reduced = runReduced(model);
} catch { continue; }
tested++;
if (full.deadlock !== reduced.deadlock) {
mismatches++;
console.log(`VERDICT MISMATCH seed=${seed} full=${full.deadlock} reduced=${reduced.deadlock}`);
console.log(JSON.stringify(prog));
if (mismatches >= 5) break;
} else if (full.deadlock && full.length !== reduced.length) {
lengthMismatch++;
console.log(`LENGTH MISMATCH seed=${seed} full=${full.length} reduced=${reduced.length}`);
console.log(JSON.stringify(prog));
if (lengthMismatch >= 5) break;
}
if (reduced.exploredTransitions >= full.exploredTransitions) reducedNotLess++;
}
console.log(`\ntested=${tested} verdictMismatches=${mismatches} lengthMismatches=${lengthMismatch} nonStrictReduction=${reducedNotLess}`);
process.exit(mismatches === 0 && lengthMismatch === 0 ? 0 : 1);
verify.mjs#!/usr/bin/env node
// verify.mjs -- end-to-end verification of the model checker.
//
// 1. every shipped program: full == ample-POR verdict and shortest length,
// ample-POR strictly fewer transitions, artifact emitted
// 2. determinism: two independent runs produce byte-identical artifacts
// 3. the counterexample program: dependency-blind POR disagrees with full
// search while ample-POR does not
// 4. randomized differential fuzzing: no verdict/length mismatch
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { analyze, loadProgram } from './por-mc.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const programDir = path.join(here, 'programs');
const files = fs.readdirSync(programDir).filter((f) => f.endsWith('.json')).sort()
.map((f) => path.join(programDir, f));
let failures = 0;
const fail = (msg) => { failures++; console.log(`FAIL ${msg}`); };
const ok = (msg) => console.log(`ok ${msg}`);
// 1 + 2: analyze twice and compare
const first = [];
const second = [];
for (const f of files) {
const prog = loadProgram(f);
first.push(analyze(prog, f));
second.push(analyze(prog, f));
}
const a1 = JSON.stringify(first);
const a2 = JSON.stringify(second);
if (a1 === a2) ok('determinism: two runs produce byte-identical artifacts');
else fail('determinism: artifacts differ between runs');
for (const a of first) {
if (!a.checks.sameVerdict) fail(`${a.program}: verdict differs (full=${a.full.verdict}, por=${a.amplePOR.verdict})`);
else if (!a.checks.sameShortestLength) fail(`${a.program}: shortest length differs (full=${a.full.shortestDeadlockLength}, por=${a.amplePOR.shortestDeadlockLength})`);
else if (!a.checks.strictlyFewerTransitions) fail(`${a.program}: POR did not strictly reduce transitions (full=${a.full.exploredTransitions}, por=${a.amplePOR.exploredTransitions})`);
else ok(`${a.program}: verdict+length preserved, transitions ${a.full.exploredTransitions} -> ${a.amplePOR.exploredTransitions} (ratio ${(a.checks.reductionRatio * 100).toFixed(1)}%)`);
}
// 3: dependency-blind counterexample
const miss = first.find((a) => a.program === 'missed_deadlock');
if (!miss) fail('counterexample program missed_deadlock not found');
else {
if (miss.full.verdict === 'DEADLOCK' && miss.dependencyBlindPOR.verdict === 'DEADLOCK-FREE' && miss.amplePOR.verdict === 'DEADLOCK') {
ok('counterexample: dependency-blind POR misses the real deadlock, ample-POR finds it');
} else {
fail('counterexample: expected naive to miss and ample to find the deadlock');
}
const d = miss.ampleDiagnostic;
if (d && d.naiveRejected && d.rejectReason.length > 0) ok(`counterexample: ample computation rejects the naive reduction (${d.rejectReason.length} reason(s))`);
else fail('counterexample: ample computation did not record a rejection');
}
// 4: fuzz (fixed seed range, deterministic)
try {
const out = execFileSync(process.execPath, [path.join(here, 'fuzz.mjs'), '8000'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const line = out.trim().split('\n').filter((l) => l.startsWith('tested=')).pop();
if (line && /verdictMismatches=0 lengthMismatches=0/.test(line)) ok(`fuzz: ${line}`);
else fail(`fuzz: ${line || 'no output'}`);
} catch (e) {
fail(`fuzz: ${e.message}`);
}
console.log(`\n${failures === 0 ? 'ALL VERIFICATION CHECKS PASSED' : `${failures} CHECK(S) FAILED`}`);
process.exit(failures === 0 ? 0 : 1);
$ node por-mc.mjs
=== deadlock_free_chain (programs/deadlock_free_chain.json) ===
full : DEADLOCK-FREE len=0 transitions=21 states=9
ample : DEADLOCK-FREE len=0 transitions=5 states=5 c3Forced=0
naive : DEADLOCK-FREE len=0 transitions=5
checks : verdict=true length=true fewer=true ratio=76.2% naiveAgrees=true
=== independent_chain (programs/independent_chain.json) ===
full : DEADLOCK len=6 transitions=54 states=27
ample : DEADLOCK len=6 transitions=6 states=7 c3Forced=0
naive : DEADLOCK len=6 transitions=6
checks : verdict=true length=true fewer=true ratio=88.9% naiveAgrees=true
full trace : (init) -> P0.a0 -> P0.a1 -> P1.b0 -> P1.b1 -> P2.c0 -> P2.c1
ample trace: (init) -> P0.a0 -> P0.a1 -> P1.b0 -> P1.b1 -> P2.c0 -> P2.c1
=== missed_deadlock (programs/missed_deadlock.json) ===
full : DEADLOCK len=5 transitions=42 states=18
ample : DEADLOCK len=5 transitions=38 states=18 c3Forced=8
naive : DEADLOCK-FREE len=0 transitions=1
checks : verdict=true length=true fewer=true ratio=9.5% naiveAgrees=false
full trace : (init) -> P1.setx -> P2.c0 -> P2.c1 -> P3.d0 -> P3.d1
ample trace: (init) -> P1.setx -> P2.c0 -> P2.c1 -> P3.d0 -> P3.d1
ample@init : seed=P0.stutter naive=[P0.stutter] rejected -> [P0.stutter,P1.setx]
C1: enabled transition(s) P1.setx depend on P0.stutter and must be in the ample set
C3: ample successor is on the DFS stack (cycle); expanding fully so every reduced cycle has a fully expanded state
allPass=true
The counterexample row is the required demonstration: dependency-blind POR reports DEADLOCK-FREE while the real program deadlocks, and the ample computation records the C1 and C3 reasons that reject the naive reduction. The ample-POR verdict and shortest length match the full search.
artifacts/missed_deadlock.json){
"program": "missed_deadlock",
"source": "programs/missed_deadlock.json",
"full": {
"verdict": "DEADLOCK",
"shortestDeadlockLength": 5,
"exploredTransitions": 42,
"expandedStates": 17,
"reachableStates": 18,
"trace": [
"(init)",
"P1.setx",
"P2.c0",
"P2.c1",
"P3.d0",
"P3.d1"
]
},
"amplePOR": {
"verdict": "DEADLOCK",
"shortestDeadlockLength": 5,
"exploredTransitions": 38,
"expandedStates": 17,
"reachableStates": 18,
"c3ForcedFullExpansions": 8,
"trace": [
"(init)",
"P1.setx",
"P2.c0",
"P2.c1",
"P3.d0",
"P3.d1"
]
},
"dependencyBlindPOR": {
"verdict": "DEADLOCK-FREE",
"shortestDeadlockLength": 0,
"exploredTransitions": 1
},
"checks": {
"sameVerdict": true,
"sameShortestLength": true,
"strictlyFewerTransitions": true,
"reductionRatio": 0.095238,
"naiveVerdictMatchesFull": false
},
"ampleDiagnostic": {
"state": "[{\"x\":0,\"y\":0,\"z\":0},[\"s0\",\"s0\",\"s0\",\"s0\"]]",
"enabled": [
"P0.stutter",
"P1.setx",
"P2.c0",
"P3.d0"
],
"enabledIdx": [0, 1, 2, 4],
"seed": "P0.stutter",
"closure": [
"P0.stutter",
"P1.setx"
],
"naiveCandidate": [
"P0.stutter"
],
"naiveRejected": true,
"rejectReason": [
"C1: enabled transition(s) P1.setx depend on P0.stutter and must be in the ample set",
"C3: ample successor is on the DFS stack (cycle); expanding fully so every reduced cycle has a fully expanded state"
],
"conditions": {
"C0": { "passed": true, "note": "nonempty enabled set" },
"C1": { "passed": true, "note": "ample closed under enabled dependencies" },
"C2": { "passed": true, "note": "ample transitions invisible" },
"C3": { "passed": false, "forcedFull": true, "note": "ample would close a cycle without a fully expanded state" }
}
},
"pass": true
}
The artifact is emitted for every program. Its checks block is the machine-checkable soundness claim:
| field | meaning | required |
|---|---|---|
sameVerdict |
full vs ample-POR deadlock verdict | true |
sameShortestLength |
full vs ample-POR minimum trace length | true |
strictlyFewerTransitions |
ample-POR expanded strictly fewer state/transition pairs | true |
reductionRatio |
(full - por) / full explored transitions |
reported |
naiveVerdictMatchesFull |
whether the dependency-blind POR was sound | false on the counterexample |
$ node verify.mjs
ok determinism: two runs produce byte-identical artifacts
ok deadlock_free_chain: verdict+length preserved, transitions 21 -> 5 (ratio 76.2%)
ok independent_chain: verdict+length preserved, transitions 54 -> 6 (ratio 88.9%)
ok missed_deadlock: verdict+length preserved, transitions 42 -> 38 (ratio 9.5%)
ok counterexample: dependency-blind POR misses the real deadlock, ample-POR finds it
ok counterexample: ample computation rejects the naive reduction (2 reason(s))
ok fuzz: tested=8000 verdictMismatches=0 lengthMismatches=0 nonStrictReduction=7905
ALL VERIFICATION CHECKS PASSED
The fuzzer generates small random programs (2–4 processes, 2–3 shared variables, 1–3 locations, up to 3 transitions per location, guards/effects over the variables) and compares the full BFS against the ample-POR on verdict and shortest deadlock length. Effects are kept in a bounded value set so every program has a finite state space.
$ node fuzz.mjs 20000 # first generator profile
tested=20000 verdictMismatches=0 lengthMismatches=0 nonStrictReduction=19715
$ node fuzz.mjs 15000 # larger profile: 2-4 procs, 1-3 locs, up to 3 transitions/loc
tested=15000 verdictMismatches=0 lengthMismatches=0 nonStrictReduction=14807
Across 35,000 random programs there was no verdict mismatch and no shortest-length mismatch, which is the empirical backing for the soundness differential.
cd por-mc
node verify.mjs # expect: ALL VERIFICATION CHECKS PASSED
node por-mc.mjs # expect: allPass=true
visibleVars turns C2 into a real filter if the checker is later extended to labeled/LTL properties; for pure deadlock detection it is vacuous.eval), so untrusted program JSON cannot execute code.# Evidence - Problem class: js-por-ample-set-deadlock-preservation-differential - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T10:43:22.899Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Build a state-space model checker in Node 20 for a small CSP-style language (JSON input: processes with local state, shared variables, guarded atomic actions) that decides whether the system can deadlock and returns the shortest deadlock trace. Add partial-order reduction using ample/stubborn sets with the classic C0-C3 conditions, and emit a differential artifact per program proving the reduced search is sound: identical verdict, identical shortest deadlock trace length, strictly fewer explored transitions, and the reduction ratio. Include at least one program where a dependency-blind POR either misses the real deadlock or invents a spurious one, show the ample-set computation rejecting that stutter-equivalent reduction, and make the checker deterministic under a fixed traversal order.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "js-por-ample-set-deadlock-preservation-differential", "provider": "openrouter", "solved_at": "2026-09-21T10:43:22.899Z", "version": "20.x"}I built the checker, ran it on three programs, verified determinism, and fuzzed 35,000 random programs with zero verdict/length mismatches. The complete self-contained solution is below (also saved at ~/por-mc/SOLUTION.md, with runnable sources in ~/por-mc/).
Environment: Node.js 20+ (developed/verified on Node 22 with Node 20 language features; no third-party dependencies).
Deliverable: a state-space model checker for a small CSP-style language (JSON: processes with local location state, shared variables, guarded atomic actions) that
All commands below were executed and the outputs shown are real.
The hard part is not "explore a graph". It is removing transitions without changing the answer. Three independent failure modes cause almost all bugs in this kind of checker.
The tempting optimization is: at every state, pick one enabled transition and explore only it. If the picked transition is a stutter self-loop (it leaves the state unchanged) the checker marks the successor as already visited and the search stops there. Any deadlock reachable only through one of the other enabled transitions is then invisible.
This is exactly the failure in programs/missed_deadlock.json:
P0.stutter has guard x == 0, effect x = x, target s0. It writes x (so it is syntactically dependent on anything else that touches x) but never changes the state.P1.setx has guard x == 0, effect x = 1, target s1 (a dead state).P2/P3 are independent work chains that must also finish.Full search finds the deadlock in 5 steps: P1.setx disables P0.stutter, then P2/P3 finish. A dependency-blind POR picks P0.stutter, loops to the initial state, sees it visited, and reports DEADLOCK-FREE — it misses the real deadlock. The naive reduction looks "stutter-equivalent" in isolation, which is precisely why the classic conditions exist.
Two transitions are dependent when they touch a common variable with at least one writer. If a state's ample set omits an enabled transition dependent on a member, the two cannot be commuted and behavior is lost — condition C1. The implementation enforces:
the ample set must be closed under dependency among the enabled transitions of the current state.
At the initial state, seed P0.stutter writes x; P1.setx reads/writes x, so they are dependent and the closure pulls P1.setx in. C1 rejects the naive {P0.stutter}.
A set can satisfy C1 and still be wrong on a cycle: a pure stutter cycle whose members are independent of everything else lets the reduction spin forever and never schedule omitted independent transitions. C3 forbids a reduced cycle in which every state was reduced. It is enforced with a DFS stack: if the ample successors of the state being expanded close a cycle, that state is expanded fully. In missed_deadlock the stutter self-loop is exactly such a back edge.
The full search is BFS, so the first deadlock dequeued is at minimum distance. Any reduced path is a real path (subgraph), so reduced distance ≥ full distance. Independence gives equality: postponed transitions commute with ample transitions, so a shortest path can be rewritten to start with an ample transition at the same length. C1 guarantees the commutation; the artifact checks the equality empirically on every program.
Classic C2 ("if not fully expanded, every ample transition must be invisible w.r.t. the property") is needed for LTL\X. Deadlock is a state predicate, so C2 is configurable via visibleVars and vacuous by default; it is still computed and reported so the full C0–C3 set is present.
The order is fixed everywhere: processes in array order, transitions in array order, effect keys sorted, shared variables sorted, canonical JSON state keys, FIFO BFS, reduced-graph adjacency sorted by transition index. verify.mjs proves two independent runs are byte-identical.
{
"name": "...",
"shared": { "x": 0 },
"visibleVars": [],
"processes": [
{
"name": "P0",
"init": "s0",
"transitions": [
{ "label": "a", "from": "s0", "guard": "x == 0", "effect": { "x": 1 }, "to": "s1" }
]
}
]
}
from and the guard is true; it updates shared variables simultaneously (RHS reads the pre-state) and moves the process to to.@loc:<process>, so all transitions of one process are mutually dependent automatically.dep(a,b) iff a.writes ∩ (b.reads ∪ b.writes) ≠ ∅ or b.writes ∩ (a.reads ∪ a.writes) ≠ ∅.stubbornClosure(seed) = transitive closure under dep over all transitions (enabled or not). Including disabled transitions prevents a postponed outside transition from later enabling something dependent on the ample set before the ample set runs.ample(s) = closure(seed) ∩ enabled(s), with seed the first enabled transition in canonical order.ample = ∅ ⟺ enabled = ∅.ample is closed under dependency among enabled transitions (guaranteed by the closure; re-checked).ample ≠ enabled, every ample transition must be invisible; vacuous for deadlock unless visibleVars is set.por-mc/
package.json
por-mc.mjs # checker + CLI + artifact emitter
verify.mjs # end-to-end verification
fuzz.mjs # randomized differential fuzzer
programs/
independent_chain.json # deadlock, big reduction
deadlock_free_chain.json # no deadlock, big reduction
missed_deadlock.json # counterexample: naive POR misses the deadlock
artifacts/ # generated: one JSON artifact per program + summary.json
cd por-mc
node por-mc.mjs # run all programs, write artifacts/, print report
node por-mc.mjs programs/missed_deadlock.json
node verify.mjs # determinism + differential + counterexample + fuzz
node fuzz.mjs 15000 # differential fuzz only
por-mc.mjs#!/usr/bin/env node
// por-mc.mjs -- state-space model checker for a small CSP-style language.
//
// Input: JSON program with shared variables, processes with a local location,
// and guarded atomic transitions whose effects assign shared variables.
//
// The checker:
// 1. exhaustively explores the interleaving state space (BFS) -> shortest deadlock
// 2. explores a partial-order-reduced state space using ample/stubborn sets
// with the classic C0..C3 conditions
// 3. runs a deliberately dependency-blind POR to demonstrate unsoundness
// 4. emits a differential artifact proving the reduced search agrees with the
// full search while doing strictly less work.
//
// Everything is deterministic: processes are visited in array order, transitions
// in array order, effect keys in sorted order, BFS is FIFO, and reduced-graph
// adjacency is sorted by transition index.
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// ---------------------------------------------------------------------------
// Expression language (safe, dependency-free): == != < <= > >= && || ! + - * / %
// ---------------------------------------------------------------------------
const TWO_CHAR_OPS = new Set(['==', '!=', '<=', '>=', '&&', '||']);
function tokenize(src) {
const tokens = [];
let i = 0;
const isDigit = (c) => c >= '0' && c <= '9';
const isAlpha = (c) => /[A-Za-z_]/.test(c);
const isAlnum = (c) => /[A-Za-z0-9_]/.test(c);
while (i < src.length) {
const c = src[i];
if (/\s/.test(c)) { i++; continue; }
const two = src.slice(i, i + 2);
if (TWO_CHAR_OPS.has(two)) { tokens.push({ type: 'op', value: two }); i += 2; continue; }
if ('+-*/%!<>'.includes(c)) { tokens.push({ type: 'op', value: c }); i++; continue; }
if (c === '(' || c === ')') { tokens.push({ type: 'punc', value: c }); i++; continue; }
if (isDigit(c)) {
let j = i; while (j < src.length && isDigit(src[j])) j++;
tokens.push({ type: 'num', value: Number(src.slice(i, j)) }); i = j; continue;
}
if (isAlpha(c)) {
let j = i; while (j < src.length && isAlnum(src[j])) j++;
tokens.push({ type: 'ident', value: src.slice(i, j) }); i = j; continue;
}
throw new Error(`Unexpected character '${c}' in expression: ${src}`);
}
tokens.push({ type: 'eof' });
return tokens;
}
function parseExpression(src) {
const tokens = tokenize(src);
let pos = 0;
const peek = () => tokens[pos];
const next = () => tokens[pos++];
function parseOr() {
let left = parseAnd();
while (peek().type === 'op' && peek().value === '||') {
next();
left = { type: 'binary', op: '||', left, right: parseAnd() };
}
return left;
}
function parseAnd() {
let left = parseCmp();
while (peek().type === 'op' && peek().value === '&&') {
next();
left = { type: 'binary', op: '&&', left, right: parseCmp() };
}
return left;
}
function parseCmp() {
const left = parseAdd();
const t = peek();
if (t.type === 'op' && ['==', '!=', '<', '<=', '>', '>='].includes(t.value)) {
next();
return { type: 'binary', op: t.value, left, right: parseAdd() };
}
return left;
}
function parseAdd() {
let left = parseMul();
while (peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
const op = next().value;
left = { type: 'binary', op, left, right: parseMul() };
}
return left;
}
function parseMul() {
let left = parseUnary();
while (peek().type === 'op' && ['*', '/', '%'].includes(peek().value)) {
const op = next().value;
left = { type: 'binary', op, left, right: parseUnary() };
}
return left;
}
function parseUnary() {
if (peek().type === 'op' && (peek().value === '!' || peek().value === '-')) {
const op = next().value;
return { type: 'unary', op, operand: parseUnary() };
}
return parsePrimary();
}
function parsePrimary() {
const t = next();
if (t.type === 'num') return { type: 'num', value: t.value };
if (t.type === 'ident') {
if (t.value === 'true') return { type: 'bool', value: true };
if (t.value === 'false') return { type: 'bool', value: false };
return { type: 'var', name: t.value };
}
if (t.type === 'punc' && t.value === '(') {
const e = parseOr();
const close = next();
if (close.value !== ')') throw new Error(`Expected ')' in expression: ${src}`);
return e;
}
throw new Error(`Unexpected token in expression: ${src}`);
}
const ast = parseOr();
if (peek().type !== 'eof') throw new Error(`Trailing tokens in expression: ${src}`);
return ast;
}
function truthy(v) { return v === true || (typeof v === 'number' && v !== 0) || (typeof v === 'string' && v.length > 0); }
function evaluate(ast, env) {
switch (ast.type) {
case 'num': return ast.value;
case 'bool': return ast.value;
case 'var': {
if (!(ast.name in env)) throw new Error(`Unbound variable '${ast.name}'`);
return env[ast.name];
}
case 'unary': {
const v = evaluate(ast.operand, env);
return ast.op === '!' ? !truthy(v) : -v;
}
case 'binary': {
if (ast.op === '&&') return truthy(evaluate(ast.left, env)) && truthy(evaluate(ast.right, env));
if (ast.op === '||') return truthy(evaluate(ast.left, env)) || truthy(evaluate(ast.right, env));
const a = evaluate(ast.left, env);
const b = evaluate(ast.right, env);
switch (ast.op) {
case '==': return a === b;
case '!=': return a !== b;
case '<': return a < b;
case '<=': return a <= b;
case '>': return a > b;
case '>=': return a >= b;
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return Math.trunc(a / b);
case '%': return a % b;
default: throw new Error(`Unknown operator ${ast.op}`);
}
}
default: throw new Error(`Unknown AST node ${ast.type}`);
}
}
function collectIdents(ast, out = new Set()) {
if (!ast) return out;
switch (ast.type) {
case 'var': out.add(ast.name); break;
case 'unary': collectIdents(ast.operand, out); break;
case 'binary': collectIdents(ast.left, out); collectIdents(ast.right, out); break;
default: break;
}
return out;
}
// ---------------------------------------------------------------------------
// Program model
// ---------------------------------------------------------------------------
function loadProgram(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function buildModel(prog) {
const sharedVars = Object.keys(prog.shared || {}).sort();
const initShared = {};
for (const v of sharedVars) initShared[v] = prog.shared[v];
const sharedVarSet = new Set(sharedVars);
const transitions = [];
for (let pid = 0; pid < prog.processes.length; pid++) {
const p = prog.processes[pid];
for (let k = 0; k < p.transitions.length; k++) {
const tr = p.transitions[k];
const locVar = `@loc:${p.name}`;
const guardAst = tr.guard == null ? null : parseExpression(String(tr.guard));
const effectEntries = Object.keys(tr.effect || {}).sort()
.map((v) => [v, parseExpression(String(tr.effect[v]))]);
const reads = new Set(collectIdents(guardAst));
const writes = new Set();
for (const [v, ast] of effectEntries) {
writes.add(v);
for (const id of collectIdents(ast)) reads.add(id);
}
reads.add(locVar);
writes.add(locVar);
transitions.push({
index: transitions.length,
pid,
pname: p.name,
tid: k,
label: tr.label || `t${k}`,
from: tr.from,
to: tr.to,
guard: tr.guard == null ? 'true' : String(tr.guard),
guardAst,
effectEntries,
reads,
writes,
locVar,
});
}
}
// Dependency matrix: two transitions are dependent iff they touch a common
// variable with at least one writer (this includes the process location, so
// two transitions of the same process are always dependent).
const dep = transitions.map(() => new Array(transitions.length).fill(false));
for (let a = 0; a < transitions.length; a++) {
for (let b = 0; b < transitions.length; b++) {
const A = transitions[a], B = transitions[b];
let conflict = false;
for (const v of A.writes) if (B.reads.has(v) || B.writes.has(v)) { conflict = true; break; }
if (!conflict) for (const v of B.writes) if (A.reads.has(v) || A.writes.has(v)) { conflict = true; break; }
dep[a][b] = conflict;
}
}
const visibleVars = new Set(prog.visibleVars || []);
const visible = transitions.map((t) => {
for (const v of t.writes) if (visibleVars.has(v)) return true;
return false;
});
const model = {
name: prog.name || path.basename('program'),
sharedVars,
observations: prog.observations || [],
processNames: prog.processes.map((p) => p.name),
transitions,
dep,
visible,
initState: { shared: { ...initShared }, locs: prog.processes.map((p) => p.init) },
};
model.key = (s) => stateKey(model, s);
model.enabled = (s) => enabledTransitions(model, s);
model.apply = (s, ti) => applyTransition(model, s, ti);
model.describe = (ti) => {
const t = model.transitions[ti];
return `${t.pname}.${t.label}`;
};
return model;
}
function stateKey(_model, s) {
return JSON.stringify([s.shared, s.locs]);
}
function enabledTransitions(model, s) {
const res = [];
for (let ti = 0; ti < model.transitions.length; ti++) {
const t = model.transitions[ti];
if (s.locs[t.pid] !== t.from) continue;
if (t.guardAst !== null && !truthy(evaluate(t.guardAst, s.shared))) continue;
res.push(ti);
}
return res;
}
function applyTransition(model, s, ti) {
const t = model.transitions[ti];
const newShared = { ...s.shared };
// Simultaneous assignment: all right-hand sides see the pre-state.
const pending = [];
for (const [v, ast] of t.effectEntries) pending.push([v, evaluate(ast, s.shared)]);
for (const [v, val] of pending) newShared[v] = val;
const locs = s.locs.slice();
locs[t.pid] = t.to;
return { shared: newShared, locs };
}
// ---------------------------------------------------------------------------
// Ample / stubborn set computation with C0..C3
// ---------------------------------------------------------------------------
// Dependency closure over ALL transitions (enabled or not). Starting from an
// enabled seed this yields a stubborn set: every transition that conflicts with
// a member is a member, so anything left out is independent of everything
// inside and can never enable a member. We branch only on the enabled subset.
function stubbornClosure(seed, transitions, dep) {
const inSet = new Set([seed]);
const queue = [seed];
while (queue.length) {
const t = queue.shift();
for (let u = 0; u < transitions.length; u++) {
if (!inSet.has(u) && dep[t][u]) { inSet.add(u); queue.push(u); }
}
}
return inSet;
}
// Compute the ample set and record the reasoning for the differential artifact.
function computeAmple(model, s) {
const enabled = enabledTransitions(model, s);
const record = {
state: model.key(s),
enabled: enabled.map((i) => model.describe(i)),
enabledIdx: enabled.slice(),
seed: null,
closure: [],
naiveCandidate: null,
naiveRejected: false,
rejectReason: [],
conditions: { C0: null, C1: null, C2: null, C3: null },
};
// C0: empty ample iff no enabled transition.
if (enabled.length === 0) {
record.conditions.C0 = { passed: true, note: 'no enabled transitions => deadlock state' };
return { ample: [], full: true, record };
}
const seed = enabled[0];
record.seed = model.describe(seed);
record.naiveCandidate = [model.describe(seed)];
const closure = stubbornClosure(seed, model.transitions, model.dep);
record.closure = [...closure].sort((a, b) => a - b).map((i) => model.describe(i));
const ample = enabled.filter((i) => closure.has(i));
// Did the dependency closure enlarge the naive single-transition candidate?
const added = ample.filter((i) => i !== seed);
if (added.length > 0) {
record.naiveRejected = true;
record.rejectReason.push(
`C1: enabled transition(s) ${added.map((i) => model.describe(i)).join(', ')} ` +
`depend on ${model.describe(seed)} and must be in the ample set`
);
}
// C1: the enabled restriction must be closed under dependence.
let c1 = true;
for (const t of ample) {
for (const u of enabled) {
if (model.dep[t][u] && !ample.includes(u)) { c1 = false; break; }
}
if (!c1) break;
}
record.conditions.C1 = { passed: c1, note: c1 ? 'ample closed under enabled dependencies' : 'enabled dependent transition omitted' };
// C2: for a proper subset, every ample transition must be invisible w.r.t. the
// checked property. For pure deadlock detection the property is a state
// predicate, so transitions are invisible unless the user names visibleVars.
let c2 = true;
if (ample.length !== enabled.length) {
for (const t of ample) if (model.visible[t]) { c2 = false; break; }
}
record.conditions.C2 = {
passed: c2,
note: ample.length === enabled.length ? 'fully expanded, C2 vacuous' : (c2 ? 'ample transitions invisible' : 'visible transition in proper ample set'),
};
// If C1/C2 do not hold, fall back to full expansion. (With the all-transition
// closure C1 always holds; this is a belt-and-braces guard.)
if (!c1 || !c2) {
record.conditions.C0 = { passed: true, note: 'nonempty enabled set' };
record.conditions.C3 = { passed: true, note: 'full expansion' };
return { ample: enabled, full: true, record };
}
record.conditions.C0 = { passed: true, note: 'nonempty enabled set' };
record.conditions.C3 = { passed: true, note: 'checked during reduced-graph construction' };
return { ample, full: ample.length === enabled.length, record };
}
// ---------------------------------------------------------------------------
// Full exploration (BFS)
// ---------------------------------------------------------------------------
function bfs(model, adjacency) {
const init = model.initState;
const initKey = model.key(init);
const parent = new Map([[initKey, null]]);
const queue = [init];
const adjacencyStore = new Map();
let exploredTransitions = 0;
let expandedStates = 0;
let deadlockKey = null;
let deadlockTransitions = 0;
while (queue.length > 0) {
const s = queue.shift();
const key = model.key(s);
const enabled = model.enabled(s);
const adj = adjacency ? adjacency(model, s, key, enabled) : enabled;
adjacencyStore.set(key, adj);
if (adj.length === 0) {
deadlockKey = key;
break;
}
expandedStates++;
for (const ti of adj) {
exploredTransitions++;
const ns = model.apply(s, ti);
const nk = model.key(ns);
if (!parent.has(nk)) {
parent.set(nk, { parentKey: key, ti });
queue.push(ns);
}
}
}
const trace = [];
if (deadlockKey !== null) {
let k = deadlockKey;
while (parent.get(k) !== null) {
const { parentKey, ti } = parent.get(k);
trace.push({ state: k, transition: model.describe(ti), ti });
k = parentKey;
}
trace.push({ state: k, transition: null, ti: null });
trace.reverse();
deadlockTransitions = trace.length - 1;
}
return {
deadlock: deadlockKey !== null,
length: deadlockTransitions,
exploredTransitions,
expandedStates,
reachableStates: parent.size,
deadlockState: deadlockKey,
trace,
adjacency: adjacencyStore,
};
}
// Dependency-blind POR: always branch on the first enabled transition and
// ignore all dependency information. This is the classic unsound reduction.
function naiveAdjacency(_model, _s, _key, enabled) {
return enabled.length > 0 ? [enabled[0]] : [];
}
// Reduced graph built with a DFS that enforces the C3 cycle proviso: when the
// ample successors of the state currently on the DFS stack close a cycle, that
// state is expanded fully, guaranteeing every reduced cycle contains a fully
// expanded state.
function buildReducedGraph(model) {
const nodes = new Map(); // key -> { state, ample, full, record }
const edges = new Map(); // key -> [ti]
const onStack = new Set();
const diagnostics = [];
let c3Forced = 0;
function visit(state) {
const key = model.key(state);
if (nodes.has(key)) return;
onStack.add(key);
const { ample, full, record } = computeAmple(model, state);
let finalAmple = ample;
let finalFull = full;
// C3 cycle proviso.
if (!full) {
const succKeys = ample.map((ti) => model.key(model.apply(state, ti)));
const closesCycle = succKeys.some((k) => onStack.has(k));
if (closesCycle) {
finalAmple = model.enabled(state);
finalFull = true;
c3Forced++;
record.naiveRejected = true;
record.rejectReason.push('C3: ample successor is on the DFS stack (cycle); expanding fully so every reduced cycle has a fully expanded state');
record.conditions.C3 = { passed: false, forcedFull: true, note: 'ample would close a cycle without a fully expanded state' };
} else {
record.conditions.C3 = { passed: true, note: 'no cycle closed by this ample set' };
}
}
nodes.set(key, { state, ample: finalAmple, full: finalFull, record });
diagnostics.push(record);
const sorted = finalAmple.slice().sort((a, b) => a - b);
edges.set(key, sorted);
for (const ti of sorted) {
const ns = model.apply(state, ti);
visit(ns);
}
onStack.delete(key);
}
visit(model.initState);
return { nodes, edges, diagnostics, c3Forced };
}
function runReduced(model) {
const { nodes, edges, diagnostics, c3Forced } = buildReducedGraph(model);
const adjacency = (_model, _s, key, enabled) => {
if (edges.has(key)) return edges.get(key);
return enabled; // unreachable safeguard
};
const result = bfs(model, adjacency);
return { ...result, diagnostics, c3Forced, reducedNodes: nodes.size };
}
// ---------------------------------------------------------------------------
// Differential artifact
// ---------------------------------------------------------------------------
function ratio(num, den) { return den === 0 ? 0 : Number((num / den).toFixed(6)); }
function analyze(prog, sourceFile) {
const model = buildModel(prog);
const full = bfs(model, null);
const naive = bfs(model, naiveAdjacency);
const reduced = runReduced(model);
const initialRecord = reduced.diagnostics.find((d) => d.state === model.key(model.initState)) || null;
const checks = {
sameVerdict: full.deadlock === reduced.deadlock,
sameShortestLength: full.length === reduced.length,
strictlyFewerTransitions: reduced.exploredTransitions < full.exploredTransitions,
reductionRatio: ratio(full.exploredTransitions - reduced.exploredTransitions, full.exploredTransitions),
naiveVerdictMatchesFull: naive.deadlock === full.deadlock,
};
const artifact = {
program: prog.name || path.basename(sourceFile),
source: sourceFile,
full: {
verdict: full.deadlock ? 'DEADLOCK' : 'DEADLOCK-FREE',
shortestDeadlockLength: full.length,
exploredTransitions: full.exploredTransitions,
expandedStates: full.expandedStates,
reachableStates: full.reachableStates,
trace: full.trace.map((x) => x.transition ? `${x.transition}` : '(init)'),
},
amplePOR: {
verdict: reduced.deadlock ? 'DEADLOCK' : 'DEADLOCK-FREE',
shortestDeadlockLength: reduced.length,
exploredTransitions: reduced.exploredTransitions,
expandedStates: reduced.expandedStates,
reachableStates: reduced.reachableStates,
c3ForcedFullExpansions: reduced.c3Forced,
trace: reduced.trace.map((x) => x.transition ? `${x.transition}` : '(init)'),
},
dependencyBlindPOR: {
verdict: naive.deadlock ? 'DEADLOCK' : 'DEADLOCK-FREE',
shortestDeadlockLength: naive.length,
exploredTransitions: naive.exploredTransitions,
},
checks,
ampleDiagnostic: initialRecord,
};
artifact.pass = checks.sameVerdict && checks.sameShortestLength && checks.strictlyFewerTransitions;
return artifact;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function readProgramList(args) {
const files = [];
for (const a of args) {
if (a.startsWith('--')) continue;
const st = fs.existsSync(a) ? fs.statSync(a) : null;
if (st && st.isDirectory()) {
for (const f of fs.readdirSync(a).sort()) if (f.endsWith('.json')) files.push(path.join(a, f));
} else if (st && st.isFile()) {
files.push(a);
}
}
return files;
}
function main() {
const args = process.argv.slice(2);
const programDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), 'programs');
let files = readProgramList(args.length ? args : [programDir]);
if (files.length === 0) {
console.error('No program files given. Usage: node por-mc.mjs <program.json | programs-dir>');
process.exit(2);
}
const artifacts = files.map((f) => analyze(loadProgram(f), path.relative(process.cwd(), f)));
const summary = {
node: process.version,
programs: artifacts.length,
allPass: artifacts.every((a) => a.pass),
results: artifacts,
};
const outDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), 'artifacts');
fs.mkdirSync(outDir, { recursive: true });
for (const a of artifacts) {
fs.writeFileSync(path.join(outDir, `${a.program}.json`), JSON.stringify(a, null, 2));
}
fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summary, null, 2));
// Human-readable report.
for (const a of artifacts) {
console.log(`\n=== ${a.program} (${a.source}) ===`);
console.log(`full : ${a.full.verdict.padEnd(13)} len=${a.full.shortestDeadlockLength} transitions=${a.full.exploredTransitions} states=${a.full.reachableStates}`);
console.log(`ample : ${a.amplePOR.verdict.padEnd(13)} len=${a.amplePOR.shortestDeadlockLength} transitions=${a.amplePOR.exploredTransitions} states=${a.amplePOR.reachableStates} c3Forced=${a.amplePOR.c3ForcedFullExpansions}`);
console.log(`naive : ${a.dependencyBlindPOR.verdict.padEnd(13)} len=${a.dependencyBlindPOR.shortestDeadlockLength} transitions=${a.dependencyBlindPOR.exploredTransitions}`);
console.log(`checks : verdict=${a.checks.sameVerdict} length=${a.checks.sameShortestLength} fewer=${a.checks.strictlyFewerTransitions} ratio=${(a.checks.reductionRatio * 100).toFixed(1)}% naiveAgrees=${a.checks.naiveVerdictMatchesFull}`);
if (!a.checks.sameVerdict || !a.checks.sameShortestLength || !a.checks.strictlyFewerTransitions) console.log(' ** CHECK FAILED **');
if (a.full.trace.length) console.log(` full trace : ${a.full.trace.join(' -> ')}`);
if (a.amplePOR.trace.length) console.log(` ample trace: ${a.amplePOR.trace.join(' -> ')}`);
const d = a.ampleDiagnostic;
if (d && d.naiveRejected) {
console.log(` ample@init : seed=${d.seed} naive=[${d.naiveCandidate}] rejected -> [${d.closure}]`);
for (const r of d.rejectReason) console.log(` ${r}`);
}
}
console.log(`\nallPass=${summary.allPass}`);
if (!summary.allPass) process.exit(1);
}
export {
buildModel, bfs, naiveAdjacency, buildReducedGraph, runReduced, analyze,
computeAmple, stubbornClosure, enabledTransitions, applyTransition, stateKey,
parseExpression, evaluate, loadProgram,
};
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
programs/independent_chain.json{
"name": "independent_chain",
"shared": { "x0": 0, "x1": 0, "x2": 0 },
"processes": [
{
"name": "P0", "init": "s0",
"transitions": [
{ "label": "a0", "from": "s0", "effect": { "x0": 1 }, "to": "s1" },
{ "label": "a1", "from": "s1", "effect": { "x0": 2 }, "to": "s2" }
]
},
{
"name": "P1", "init": "s0",
"transitions": [
{ "label": "b0", "from": "s0", "effect": { "x1": 1 }, "to": "s1" },
{ "label": "b1", "from": "s1", "effect": { "x1": 2 }, "to": "s2" }
]
},
{
"name": "P2", "init": "s0",
"transitions": [
{ "label": "c0", "from": "s0", "effect": { "x2": 1 }, "to": "s1" },
{ "label": "c1", "from": "s1", "effect": { "x2": 2 }, "to": "s2" }
]
}
]
}
programs/deadlock_free_chain.json{
"name": "deadlock_free_chain",
"shared": { "x0": 0, "x1": 0, "keepalive": 0 },
"processes": [
{
"name": "P0", "init": "s0",
"transitions": [
{ "label": "a0", "from": "s0", "effect": { "x0": 1 }, "to": "s1" },
{ "label": "a1", "from": "s1", "effect": { "x0": 2 }, "to": "s2" }
]
},
{
"name": "P1", "init": "s0",
"transitions": [
{ "label": "b0", "from": "s0", "effect": { "x1": 1 }, "to": "s1" },
{ "label": "b1", "from": "s1", "effect": { "x1": 2 }, "to": "s2" }
]
},
{
"name": "Keepalive", "init": "s0",
"transitions": [
{ "label": "tick", "from": "s0", "effect": { "keepalive": "keepalive" }, "to": "s0" }
]
}
]
}
programs/missed_deadlock.json{
"name": "missed_deadlock",
"shared": { "x": 0, "y": 0, "z": 0 },
"processes": [
{
"name": "P0", "init": "s0",
"transitions": [
{ "label": "stutter", "from": "s0", "guard": "x == 0", "effect": { "x": "x" }, "to": "s0" }
]
},
{
"name": "P1", "init": "s0",
"transitions": [
{ "label": "setx", "from": "s0", "guard": "x == 0", "effect": { "x": 1 }, "to": "s1" }
]
},
{
"name": "P2", "init": "s0",
"transitions": [
{ "label": "c0", "from": "s0", "effect": { "y": 1 }, "to": "s1" },
{ "label": "c1", "from": "s1", "effect": { "y": 2 }, "to": "s2" }
]
},
{
"name": "P3", "init": "s0",
"transitions": [
{ "label": "d0", "from": "s0", "effect": { "z": 1 }, "to": "s1" },
{ "label": "d1", "from": "s1", "effect": { "z": 2 }, "to": "s2" }
]
}
]
}
fuzz.mjs#!/usr/bin/env node
// Randomized differential fuzzing: full BFS vs ample-POR.
// Checks verdict AND shortest-deadlock length agree on many random programs.
import { buildModel, bfs, runReduced, analyze } from './por-mc.mjs';
// Deterministic PRNG so runs are reproducible.
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick(rnd, arr) { return arr[Math.floor(rnd() * arr.length)]; }
function int(rnd, n) { return Math.floor(rnd() * n); }
function genProgram(seed) {
const rnd = mulberry32(seed);
const nVars = 2 + int(rnd, 2);
const nProc = 2 + int(rnd, 3);
const vars = Array.from({ length: nVars }, (_, i) => `v${i}`);
const shared = {};
for (const v of vars) shared[v] = int(rnd, 2);
const guardPool = ['true'];
for (const v of vars) guardPool.push(`${v} == 0`, `${v} == 1`, `${v} < 1`, `${v} >= 0`, `${v} != 0`);
const exprPool = ['0', '1'];
for (const v of vars) exprPool.push(v, `1 - ${v}`, `${v} < 1`, `${v} == 0`, `${v} != 0`);
const processes = [];
for (let p = 0; p < nProc; p++) {
const nLoc = 1 + int(rnd, 3);
const transitions = [];
for (let l = 0; l < nLoc; l++) {
const nT = int(rnd, 4);
for (let k = 0; k < nT; k++) {
const effect = {};
const nW = int(rnd, 2);
for (let w = 0; w < nW; w++) effect[pick(rnd, vars)] = pick(rnd, exprPool);
transitions.push({
label: `t${l}_${k}`,
from: l,
guard: pick(rnd, guardPool),
effect,
to: int(rnd, nLoc),
});
}
}
processes.push({ name: `P${p}`, init: 0, transitions });
}
return { name: `fuzz_${seed}`, shared, processes };
}
const N = Number(process.argv[2] || 20000);
let mismatches = 0;
let lengthMismatch = 0;
let reducedNotLess = 0;
let tested = 0;
for (let seed = 1; seed <= N; seed++) {
const prog = genProgram(seed);
let model;
try { model = buildModel(prog); } catch { continue; }
let full, reduced;
try {
full = bfs(model, null);
reduced = runReduced(model);
} catch { continue; }
tested++;
if (full.deadlock !== reduced.deadlock) {
mismatches++;
console.log(`VERDICT MISMATCH seed=${seed} full=${full.deadlock} reduced=${reduced.deadlock}`);
console.log(JSON.stringify(prog));
if (mismatches >= 5) break;
} else if (full.deadlock && full.length !== reduced.length) {
lengthMismatch++;
console.log(`LENGTH MISMATCH seed=${seed} full=${full.length} reduced=${reduced.length}`);
console.log(JSON.stringify(prog));
if (lengthMismatch >= 5) break;
}
if (reduced.exploredTransitions >= full.exploredTransitions) reducedNotLess++;
}
console.log(`\ntested=${tested} verdictMismatches=${mismatches} lengthMismatches=${lengthMismatch} nonStrictReduction=${reducedNotLess}`);
process.exit(mismatches === 0 && lengthMismatch === 0 ? 0 : 1);
verify.mjs#!/usr/bin/env node
// verify.mjs -- end-to-end verification of the model checker.
//
// 1. every shipped program: full == ample-POR verdict and shortest length,
// ample-POR strictly fewer transitions, artifact emitted
// 2. determinism: two independent runs produce byte-identical artifacts
// 3. the counterexample program: dependency-blind POR disagrees with full
// search while ample-POR does not
// 4. randomized differential fuzzing: no verdict/length mismatch
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { analyze, loadProgram } from './por-mc.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const programDir = path.join(here, 'programs');
const files = fs.readdirSync(programDir).filter((f) => f.endsWith('.json')).sort()
.map((f) => path.join(programDir, f));
let failures = 0;
const fail = (msg) => { failures++; console.log(`FAIL ${msg}`); };
const ok = (msg) => console.log(`ok ${msg}`);
// 1 + 2: analyze twice and compare
const first = [];
const second = [];
for (const f of files) {
const prog = loadProgram(f);
first.push(analyze(prog, f));
second.push(analyze(prog, f));
}
const a1 = JSON.stringify(first);
const a2 = JSON.stringify(second);
if (a1 === a2) ok('determinism: two runs produce byte-identical artifacts');
else fail('determinism: artifacts differ between runs');
for (const a of first) {
if (!a.checks.sameVerdict) fail(`${a.program}: verdict differs (full=${a.full.verdict}, por=${a.amplePOR.verdict})`);
else if (!a.checks.sameShortestLength) fail(`${a.program}: shortest length differs (full=${a.full.shortestDeadlockLength}, por=${a.amplePOR.shortestDeadlockLength})`);
else if (!a.checks.strictlyFewerTransitions) fail(`${a.program}: POR did not strictly reduce transitions (full=${a.full.exploredTransitions}, por=${a.amplePOR.exploredTransitions})`);
else ok(`${a.program}: verdict+length preserved, transitions ${a.full.exploredTransitions} -> ${a.amplePOR.exploredTransitions} (ratio ${(a.checks.reductionRatio * 100).toFixed(1)}%)`);
}
// 3: dependency-blind counterexample
const miss = first.find((a) => a.program === 'missed_deadlock');
if (!miss) fail('counterexample program missed_deadlock not found');
else {
if (miss.full.verdict === 'DEADLOCK' && miss.dependencyBlindPOR.verdict === 'DEADLOCK-FREE' && miss.amplePOR.verdict === 'DEADLOCK') {
ok('counterexample: dependency-blind POR misses the real deadlock, ample-POR finds it');
} else {
fail('counterexample: expected naive to miss and ample to find the deadlock');
}
const d = miss.ampleDiagnostic;
if (d && d.naiveRejected && d.rejectReason.length > 0) ok(`counterexample: ample computation rejects the naive reduction (${d.rejectReason.length} reason(s))`);
else fail('counterexample: ample computation did not record a rejection');
}
// 4: fuzz (fixed seed range, deterministic)
try {
const out = execFileSync(process.execPath, [path.join(here, 'fuzz.mjs'), '8000'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const line = out.trim().split('\n').filter((l) => l.startsWith('tested=')).pop();
if (line && /verdictMismatches=0 lengthMismatches=0/.test(line)) ok(`fuzz: ${line}`);
else fail(`fuzz: ${line || 'no output'}`);
} catch (e) {
fail(`fuzz: ${e.message}`);
}
console.log(`\n${failures === 0 ? 'ALL VERIFICATION CHECKS PASSED' : `${failures} CHECK(S) FAILED`}`);
process.exit(failures === 0 ? 0 : 1);
$ node por-mc.mjs
=== deadlock_free_chain (programs/deadlock_free_chain.json) ===
full : DEADLOCK-FREE len=0 transitions=21 states=9
ample : DEADLOCK-FREE len=0 transitions=5 states=5 c3Forced=0
naive : DEADLOCK-FREE len=0 transitions=5
checks : verdict=true length=true fewer=true ratio=76.2% naiveAgrees=true
=== independent_chain (programs/independent_chain.json) ===
full : DEADLOCK len=6 transitions=54 states=27
ample : DEADLOCK len=6 transitions=6 states=7 c3Forced=0
naive : DEADLOCK len=6 transitions=6
checks : verdict=true length=true fewer=true ratio=88.9% naiveAgrees=true
full trace : (init) -> P0.a0 -> P0.a1 -> P1.b0 -> P1.b1 -> P2.c0 -> P2.c1
ample trace: (init) -> P0.a0 -> P0.a1 -> P1.b0 -> P1.b1 -> P2.c0 -> P2.c1
=== missed_deadlock (programs/missed_deadlock.json) ===
full : DEADLOCK len=5 transitions=42 states=18
ample : DEADLOCK len=5 transitions=38 states=18 c3Forced=8
naive : DEADLOCK-FREE len=0 transitions=1
checks : verdict=true length=true fewer=true ratio=9.5% naiveAgrees=false
full trace : (init) -> P1.setx -> P2.c0 -> P2.c1 -> P3.d0 -> P3.d1
ample trace: (init) -> P1.setx -> P2.c0 -> P2.c1 -> P3.d0 -> P3.d1
ample@init : seed=P0.stutter naive=[P0.stutter] rejected -> [P0.stutter,P1.setx]
C1: enabled transition(s) P1.setx depend on P0.stutter and must be in the ample set
C3: ample successor is on the DFS stack (cycle); expanding fully so every reduced cycle has a fully expanded state
allPass=true
The counterexample row is the required demonstration: dependency-blind POR reports DEADLOCK-FREE while the real program deadlocks, and the ample computation records the C1 and C3 reasons that reject the naive reduction. The ample-POR verdict and shortest length match the full search.
artifacts/missed_deadlock.json){
"program": "missed_deadlock",
"source": "programs/missed_deadlock.json",
"full": {
"verdict": "DEADLOCK",
"shortestDeadlockLength": 5,
"exploredTransitions": 42,
"expandedStates": 17,
"reachableStates": 18,
"trace": [
"(init)",
"P1.setx",
"P2.c0",
"P2.c1",
"P3.d0",
"P3.d1"
]
},
"amplePOR": {
"verdict": "DEADLOCK",
"shortestDeadlockLength": 5,
"exploredTransitions": 38,
"expandedStates": 17,
"reachableStates": 18,
"c3ForcedFullExpansions": 8,
"trace": [
"(init)",
"P1.setx",
"P2.c0",
"P2.c1",
"P3.d0",
"P3.d1"
]
},
"dependencyBlindPOR": {
"verdict": "DEADLOCK-FREE",
"shortestDeadlockLength": 0,
"exploredTransitions": 1
},
"checks": {
"sameVerdict": true,
"sameShortestLength": true,
"strictlyFewerTransitions": true,
"reductionRatio": 0.095238,
"naiveVerdictMatchesFull": false
},
"ampleDiagnostic": {
"state": "[{\"x\":0,\"y\":0,\"z\":0},[\"s0\",\"s0\",\"s0\",\"s0\"]]",
"enabled": [
"P0.stutter",
"P1.setx",
"P2.c0",
"P3.d0"
],
"enabledIdx": [0, 1, 2, 4],
"seed": "P0.stutter",
"closure": [
"P0.stutter",
"P1.setx"
],
"naiveCandidate": [
"P0.stutter"
],
"naiveRejected": true,
"rejectReason": [
"C1: enabled transition(s) P1.setx depend on P0.stutter and must be in the ample set",
"C3: ample successor is on the DFS stack (cycle); expanding fully so every reduced cycle has a fully expanded state"
],
"conditions": {
"C0": { "passed": true, "note": "nonempty enabled set" },
"C1": { "passed": true, "note": "ample closed under enabled dependencies" },
"C2": { "passed": true, "note": "ample transitions invisible" },
"C3": { "passed": false, "forcedFull": true, "note": "ample would close a cycle without a fully expanded state" }
}
},
"pass": true
}
The artifact is emitted for every program. Its checks block is the machine-checkable soundness claim:
| field | meaning | required |
|---|---|---|
sameVerdict |
full vs ample-POR deadlock verdict | true |
sameShortestLength |
full vs ample-POR minimum trace length | true |
strictlyFewerTransitions |
ample-POR expanded strictly fewer state/transition pairs | true |
reductionRatio |
(full - por) / full explored transitions |
reported |
naiveVerdictMatchesFull |
whether the dependency-blind POR was sound | false on the counterexample |
$ node verify.mjs
ok determinism: two runs produce byte-identical artifacts
ok deadlock_free_chain: verdict+length preserved, transitions 21 -> 5 (ratio 76.2%)
ok independent_chain: verdict+length preserved, transitions 54 -> 6 (ratio 88.9%)
ok missed_deadlock: verdict+length preserved, transitions 42 -> 38 (ratio 9.5%)
ok counterexample: dependency-blind POR misses the real deadlock, ample-POR finds it
ok counterexample: ample computation rejects the naive reduction (2 reason(s))
ok fuzz: tested=8000 verdictMismatches=0 lengthMismatches=0 nonStrictReduction=7905
ALL VERIFICATION CHECKS PASSED
The fuzzer generates small random programs (2–4 processes, 2–3 shared variables, 1–3 locations, up to 3 transitions per location, guards/effects over the variables) and compares the full BFS against the ample-POR on verdict and shortest deadlock length. Effects are kept in a bounded value set so every program has a finite state space.
$ node fuzz.mjs 20000 # first generator profile
tested=20000 verdictMismatches=0 lengthMismatches=0 nonStrictReduction=19715
$ node fuzz.mjs 15000 # larger profile: 2-4 procs, 1-3 locs, up to 3 transitions/loc
tested=15000 verdictMismatches=0 lengthMismatches=0 nonStrictReduction=14807
Across 35,000 random programs there was no verdict mismatch and no shortest-length mismatch, which is the empirical backing for the soundness differential.
cd por-mc
node verify.mjs # expect: ALL VERIFICATION CHECKS PASSED
node por-mc.mjs # expect: allPass=true
visibleVars turns C2 into a real filter if the checker is later extended to labeled/LTL properties; for pure deadlock detection it is vacuous.eval), so untrusted program JSON cannot execute code.# Evidence - Problem class: js-por-ample-set-deadlock-preservation-differential - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T10:43:22.899Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Build a state-space model checker in Node 20 for a small CSP-style language (JSON input: processes with local state, shared variables, guarded atomic actions) that decides whether the system can deadlock and returns the shortest deadlock trace. Add partial-order reduction using ample/stubborn sets with the classic C0-C3 conditions, and emit a differential artifact per program proving the reduced search is sound: identical verdict, identical shortest deadlock trace length, strictly fewer explored transitions, and the reduction ratio. Include at least one program where a dependency-blind POR either misses the real deadlock or invents a spurious one, show the ample-set computation rejecting that stutter-equivalent reduction, and make the checker deterministic under a fixed traversal order.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "js-por-ample-set-deadlock-preservation-differential", "provider": "openrouter", "solved_at": "2026-09-21T10:43:22.899Z", "version": "20.x"}