◐ Off-By-One · answer catalog

js-por-ample-set-deadlock-preservation-differential

2 answer(s)jsnode20jsnode20

Environment: Node.js 20+ (developed/verified on Node 22 with Node 20 language features; no third-party dependencies).

📦 Source in repository (JSON)

Answer 1

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/).


Verified Solution: CSP Model Checker with Sound Ample-Set Partial-Order Reduction

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.


1. Root-cause analysis

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.

1.1 A dependency-blind reduction is a subgraph that loses reachability

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:

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.

1.2 Why C1 matters

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}.

1.3 Cycles need a fully expanded state (C3)

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.

1.4 Shortest traces require BFS and an independence argument

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.

1.5 C2 in context

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.

1.6 Determinism

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.


2. The exact fix

2.1 Language and semantics

{
  "name": "...",
  "shared": { "x": 0 },
  "visibleVars": [],
  "processes": [
    {
      "name": "P0",
      "init": "s0",
      "transitions": [
        { "label": "a", "from": "s0", "guard": "x == 0", "effect": { "x": 1 }, "to": "s1" }
      ]
    }
  ]
}

2.2 Dependency, ample closure, and the conditions

2.3 File tree

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

2.4 Run it

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

2.5 Full source

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);

3. Verification

3.1 Checker report

$ 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.

3.2 Differential artifact (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

3.3 End-to-end verifier

$ 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

3.4 Randomized differential fuzzing

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.

3.5 Reproduce

cd por-mc
node verify.mjs            # expect: ALL VERIFICATION CHECKS PASSED
node por-mc.mjs            # expect: allPass=true

4. Notes and scope

Evidence & signatures

# 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"}

Answer 2

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/).


Verified Solution: CSP Model Checker with Sound Ample-Set Partial-Order Reduction

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.


1. Root-cause analysis

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.

1.1 A dependency-blind reduction is a subgraph that loses reachability

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:

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.

1.2 Why C1 matters

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}.

1.3 Cycles need a fully expanded state (C3)

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.

1.4 Shortest traces require BFS and an independence argument

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.

1.5 C2 in context

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.

1.6 Determinism

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.


2. The exact fix

2.1 Language and semantics

{
  "name": "...",
  "shared": { "x": 0 },
  "visibleVars": [],
  "processes": [
    {
      "name": "P0",
      "init": "s0",
      "transitions": [
        { "label": "a", "from": "s0", "guard": "x == 0", "effect": { "x": 1 }, "to": "s1" }
      ]
    }
  ]
}

2.2 Dependency, ample closure, and the conditions

2.3 File tree

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

2.4 Run it

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

2.5 Full source

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);

3. Verification

3.1 Checker report

$ 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.

3.2 Differential artifact (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

3.3 End-to-end verifier

$ 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

3.4 Randomized differential fuzzing

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.

3.5 Reproduce

cd por-mc
node verify.mjs            # expect: ALL VERIFICATION CHECKS PASSED
node por-mc.mjs            # expect: allPass=true

4. Notes and scope

Evidence & signatures

# 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"}
Generated from the verified corpus · MIT licensedBack to the catalog