continuationTraces = new java.util.concurrent.ConcurrentHashMap<>();
+
+ /** Whether a trace should still be printed for this property's violation. */
+ public static boolean continuationTraceAllowed(final String property) {
+ if (continuationTraceLimit < 0) {
+ return true;
+ }
+ final int n = continuationTraces.computeIfAbsent(property == null ? "" : property,
+ k -> new java.util.concurrent.atomic.AtomicInteger()).incrementAndGet();
+ return n <= continuationTraceLimit;
+ }
+
+ /**
+ * Whether the violation of this property being reported now will get no
+ * trace: TLC continues past violations and the property already printed
+ * as many traces as the limit allows. Read before
+ * {@link #continuationTraceAllowed} counts the report, under the same lock.
+ */
+ public static boolean continuationTraceCapped(final String property) {
+ if (!continuation || continuationTraceLimit < 0) {
+ return false;
+ }
+ final java.util.concurrent.atomic.AtomicInteger n = continuationTraces.get(property == null ? "" : property);
+ return (n == null ? 0 : n.get()) >= continuationTraceLimit;
+ }
+
+ public static void resetContinuationTraces() {
+ continuationTraces.clear();
+ }
+
// Prints only the state difference in state traces
public static boolean printDiffsOnly = false;
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java
new file mode 100644
index 0000000000..be6f6e94b6
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java
@@ -0,0 +1,780 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.LongAdder;
+
+import tla2sany.parser.SyntaxTreeNode;
+import tla2sany.semantic.SemanticNode;
+import tlc2.tool.Action;
+import tlc2.tool.TLCState;
+import tlc2.util.BitVector;
+import tlc2.util.Context;
+import tlc2.util.IStateWriter;
+import tlc2.util.LongVec;
+import tlc2.value.ValueInputStream;
+import tlc2.value.ValueOutputStream;
+
+/**
+ * What TLC computes and throws away, kept: every reached state's content,
+ * how it was first reached, its predecessors, and the guard conjuncts that
+ * kept transitions from firing.
+ *
+ *
+ * TLC itself stores only fingerprints ({@code FPSet}) and one parent pointer
+ * per state ({@code TLCTrace}); reconstructing a state means re-running the
+ * next-state relation from an initial state. This store sits on the
+ * {@link IStateWriter} hook the checker already calls on every edge
+ * ({@code ModelChecker.isSeenState}, {@code Worker.addElement}) and on every
+ * guard that evaluated false ({@code Worker.addUnsatisfiedState}, routed here
+ * because {@link #isConstrained()} is true), so the checker core is
+ * untouched.
+ *
+ *
+ * State content goes to one append-only file under the metadir, serialised
+ * the way {@code DiskStateQueue} serialises states; the index (fingerprint
+ * to offset, level and first predecessor) and the predecessor lists stay in
+ * memory. Successors a constraint excluded are kept as well, content and
+ * edge, since TLC checks invariants on them and an invariant sweep must too.
+ * Blocked guards are tallied, not logged: per (action, conjunct)
+ * a count, one example state and one example binding. The tallies take no
+ * lock (a false guard is reported far more often than an edge); edges and
+ * states are recorded under the store's lock, which costs a run with many
+ * edges per state noticeable time (about 1.4x on a spec with 3.4M edges,
+ * one worker or four).
+ *
+ *
+ * The in-memory part is not bounded: roughly a hundred bytes of heap per
+ * state and fifty per edge, on top of the checker's own. It suits the specs
+ * one iterates on interactively; for a large run, open the resident with
+ * {@code store: false} (no store, no store queries) or give the JVM the heap.
+ */
+public final class GraphStore implements IStateWriter {
+
+ /** How a state was first reached, and where its content is. */
+ private static final class Entry {
+ final long offset;
+ final int length;
+ final int level;
+ /** Fingerprint of the state this one was first reached from, or 0 for an initial state. */
+ final long predecessor;
+ /** The action that first reached it, or -1. */
+ final int action;
+
+ Entry(long offset, int length, int level, long predecessor, int action) {
+ this.offset = offset;
+ this.length = length;
+ this.level = level;
+ this.predecessor = predecessor;
+ this.action = action;
+ }
+ }
+
+ /** One guard conjunct's (or constraint's) tally of the transitions it disabled. */
+ public static final class Blocked {
+ public final int actionId;
+ public final String action;
+ /** {@code guard}: a conjunct of the action evaluated false; {@code constraint}: a state or action constraint excluded the successor. */
+ public final String kind;
+ public final String location;
+ public final String text;
+ /** The count when this row was read ({@link GraphStore#blocked()} returns snapshots). */
+ public long count;
+ public long exampleFp;
+ public Map exampleBindings;
+ private final LongAdder tally = new LongAdder();
+
+ Blocked(int actionId, String action, String kind, String location, String text) {
+ this.actionId = actionId;
+ this.action = action;
+ this.kind = kind;
+ this.location = location;
+ this.text = text;
+ }
+
+ private Blocked snapshot() {
+ final Blocked b = new Blocked(actionId, action, kind, location, text);
+ b.count = tally.sum();
+ b.exampleFp = exampleFp;
+ b.exampleBindings = exampleBindings;
+ return b;
+ }
+ }
+
+ /**
+ * What a tally is kept per: the action, the conjunct's node (by identity:
+ * it is the same node on every evaluation, so no text or location is
+ * built on the hot path) and whether it is a guard or a constraint.
+ */
+ private static final class TallyKey {
+ final int actionId;
+ final SemanticNode pred;
+ final boolean constraint;
+
+ TallyKey(int actionId, SemanticNode pred, boolean constraint) {
+ this.actionId = actionId;
+ this.pred = pred;
+ this.constraint = constraint;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (!(o instanceof TallyKey)) {
+ return false;
+ }
+ final TallyKey k = (TallyKey) o;
+ return actionId == k.actionId && pred == k.pred && constraint == k.constraint;
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * (31 * actionId + System.identityHashCode(pred)) + (constraint ? 1 : 0);
+ }
+ }
+
+ private final File file;
+ private final RandomAccessFile content;
+ private final Map index = new HashMap<>();
+ /**
+ * Successors a state or action constraint excluded, by fingerprint: they
+ * are not in the model and never expanded, but TLC checks every invariant
+ * on them, so their content is kept for the invariant sweeps.
+ */
+ private final Map excludedIndex = new HashMap<>();
+ /** Fingerprint to (excluded successor fp, action id) pairs, each edge once. */
+ private final Map excludedEdges = new HashMap<>();
+ /** Fingerprint to (predecessor fp, action id, flags) triples. */
+ private final Map predecessors = new HashMap<>();
+ private final Map actions = new HashMap<>();
+ /**
+ * Tallied without the store's lock: every worker reports every false
+ * guard, many times more often than it reports an edge.
+ */
+ private final ConcurrentHashMap blocked = new ConcurrentHashMap<>();
+ private final List initial = new ArrayList<>();
+ /**
+ * Initial states a state constraint excluded, kept in
+ * {@link #excludedIndex}: TLC checks invariants and state-level
+ * properties on them too.
+ */
+ private final java.util.Set excludedInitial = new java.util.LinkedHashSet<>();
+ private long edges;
+ private final LongAdder unsatisfied = new LongAdder();
+ private final LongAdder excluded = new LongAdder();
+ private TLCState empty;
+ /** Serialised states not yet written to {@link #content}; flushed before a read. */
+ private final ByteArrayOutputStream pending = new ByteArrayOutputStream(1 << 16);
+ /** Bytes in {@link #content} plus {@link #pending}: the next state's offset. */
+ private long length;
+ private static final int FLUSH_AT = 1 << 20;
+
+ public GraphStore(final String metadir) throws IOException {
+ this.file = new File(metadir, "basis.states");
+ this.content = new RandomAccessFile(this.file, "rw");
+ this.content.setLength(0);
+ }
+
+ /** The state every stored one is read into a copy of. */
+ private TLCState empty() {
+ if (empty == null) {
+ empty = TLCState.Empty.createEmpty();
+ }
+ return empty;
+ }
+
+ // ─── what the checker writes ────────────────────────────────────────
+
+ @Override
+ public void writeState(final TLCState state) {
+ // An initial state.
+ final long fp = state.fingerPrint();
+ final byte[] data = serialise(state);
+ synchronized (this) {
+ if (!index.containsKey(fp)) {
+ store(fp, data, 1, 0, -1);
+ initial.add(fp);
+ }
+ }
+ }
+
+ @Override
+ public void writeExcludedInitial(final TLCState state) {
+ final long fp = state.fingerPrint();
+ final byte[] data = serialise(state);
+ synchronized (this) {
+ if (!excludedIndex.containsKey(fp)) {
+ excludedIndex.put(fp, new Entry(append(data), data.length, 1, 0, -1));
+ }
+ excludedInitial.add(fp);
+ }
+ }
+
+ @Override
+ public synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags) {
+ writeState(state, successor, stateFlags, (Action) null);
+ }
+
+ @Override
+ public void writeState(final TLCState state, final TLCState successor, final short stateFlags,
+ final Action action) {
+ if (isSet(stateFlags, IsNotInModel)) {
+ return;
+ }
+ // Fingerprinting and serialising run outside the lock: every worker
+ // writes every edge here, so the lock covers only the maps.
+ final long from = state.fingerPrint();
+ final long to = successor.fingerPrint();
+ final int actionId = action == null ? -1 : action.getId();
+ // Keep the content of the write that won TLC's fingerprint-set put
+ // (IsUnseen): that is the state TLC enqueued and whose successors the
+ // store records. Under a VIEW or SYMMETRY another worker may reach the
+ // same fingerprint with a different concrete state, and its (IsSeen)
+ // write can take this lock first.
+ final byte[] data = isSet(stateFlags, IsUnseen) ? serialise(successor) : null;
+ synchronized (this) {
+ if (action != null) {
+ actions.putIfAbsent(actionId, action);
+ }
+ edges++;
+ if (data != null && !index.containsKey(to)) {
+ final Entry pred = index.get(from);
+ store(to, data, pred == null ? 2 : pred.level + 1, from, actionId);
+ }
+ final LongVec preds = predecessors.computeIfAbsent(to, k -> new LongVec(4));
+ preds.addElement(from);
+ preds.addElement(actionId);
+ preds.addElement(stateFlags);
+ }
+ }
+
+ /**
+ * A state or action constraint {@code pred} excluded {@code successor}:
+ * the worker calls this only for constraints. Tallied apart from the
+ * guards, since the successor was generated and then dropped.
+ */
+ @Override
+ public void writeState(final TLCState state, final TLCState successor, final short stateFlags,
+ final Action action, final SemanticNode pred) {
+ excluded.increment();
+ tally(state, action, pred, null, true);
+ writeExcluded(state, successor, action);
+ }
+
+ /**
+ * Keep {@code successor}, which a constraint excluded when {@code action}
+ * generated it from {@code state}: its content (once per fingerprint) and
+ * the edge (once per source and action). The worker reports an excluded
+ * successor once per constraint it fails; the repeats add nothing.
+ */
+ public void writeExcluded(final TLCState state, final TLCState successor, final Action action) {
+ final long from = state.fingerPrint();
+ final long to = successor.fingerPrint();
+ final int actionId = action == null ? -1 : action.getId();
+ final boolean known;
+ synchronized (this) {
+ known = excludedIndex.containsKey(to);
+ if (known && hasExcludedEdge(from, to, actionId)) {
+ return;
+ }
+ }
+ final byte[] data = known ? null : serialise(successor);
+ synchronized (this) {
+ if (action != null) {
+ actions.putIfAbsent(actionId, action);
+ }
+ if (data != null && !excludedIndex.containsKey(to)) {
+ final Entry pred = index.get(from);
+ excludedIndex.put(to, new Entry(append(data), data.length, pred == null ? 2 : pred.level + 1, from,
+ actionId));
+ }
+ if (!hasExcludedEdge(from, to, actionId)) {
+ final LongVec edges = excludedEdges.computeIfAbsent(from, k -> new LongVec(2));
+ edges.addElement(to);
+ edges.addElement(actionId);
+ }
+ }
+ }
+
+ /** Caller holds the lock. */
+ private boolean hasExcludedEdge(final long from, final long to, final int actionId) {
+ final LongVec edges = excludedEdges.get(from);
+ if (edges == null) {
+ return false;
+ }
+ for (int i = 0; i < edges.size(); i += 2) {
+ if (edges.elementAt(i) == to && edges.elementAt(i + 1) == actionId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * A guard conjunct evaluated false: the transition {@code action} was not
+ * enabled at {@code state} because of {@code pred}, under the quantifier
+ * bindings in {@code c}. Tallied per (action, conjunct).
+ */
+ @Override
+ public void writeUnsatisfied(final TLCState state, final Action action, final TLCState successor,
+ final SemanticNode pred, final Context c) {
+ unsatisfied.increment();
+ tally(state, action, pred, c, false);
+ }
+
+ private void tally(final TLCState state, final Action action, final SemanticNode pred, final Context c,
+ final boolean constraint) {
+ final int actionId = action == null ? -1 : action.getId();
+ final TallyKey key = new TallyKey(actionId, pred, constraint);
+ Blocked b = blocked.get(key);
+ if (b == null) {
+ // The row's text, location and example are built once, by
+ // whichever worker reports the conjunct first.
+ b = blocked.computeIfAbsent(key, k -> {
+ final Blocked n = new Blocked(actionId, action == null ? "?" : action.getNameOfDefault(),
+ constraint ? "constraint" : "guard", pred == null ? "?" : String.valueOf(pred.getLocation()),
+ text(pred));
+ try {
+ n.exampleFp = state.fingerPrint();
+ } catch (final RuntimeException e) {
+ n.exampleFp = 0;
+ }
+ if (c != null) {
+ final Map bindings = new HashMap<>();
+ c.toMap().forEach((name, v) -> bindings.put(name.toString(), v.toString()));
+ n.exampleBindings = bindings;
+ }
+ return n;
+ });
+ }
+ b.tally.increment();
+ }
+
+ /** The source text of a semantic node, or its location when the parse tree is gone. */
+ public static String text(final SemanticNode node) {
+ if (node == null) {
+ return "";
+ }
+ if (node.getTreeNode() instanceof SyntaxTreeNode) {
+ return ((SyntaxTreeNode) node.getTreeNode()).getHumanReadableImage();
+ }
+ return String.valueOf(node.getLocation());
+ }
+
+ @Override
+ public void writeState(final TLCState state, final TLCState successor, final short stateFlags,
+ final Visualization visualization) {
+ // Stuttering steps carry no new state.
+ }
+
+ @Override
+ public void writeState(final TLCState state, final TLCState successor, final BitVector actionChecks,
+ final int from, final int length, final short stateFlags) {
+ writeState(state, successor, stateFlags, (Action) null);
+ }
+
+ @Override
+ public void writeState(final TLCState state, final TLCState successor, final BitVector actionChecks,
+ final int from, final int length, final short stateFlags, final Visualization visualization) {
+ writeState(state, successor, stateFlags, (Action) null);
+ }
+
+ /**
+ * The checker closes its state writer when a run ends; the store outlives
+ * the run, so this only flushes. {@link #dispose()} releases it.
+ */
+ @Override
+ public synchronized void close() {
+ try {
+ flush();
+ content.getFD().sync();
+ } catch (final IOException e) {
+ // Nothing to report to.
+ }
+ }
+
+ /**
+ * Release the store for good: close its file, delete it, and delete its
+ * directory when nothing else is left in it (a refresh's own metadir; the
+ * checker's metadir holds TLC's files too and stays).
+ */
+ public synchronized void dispose() {
+ try {
+ content.close();
+ } catch (final IOException e) {
+ // Deleting below is what matters.
+ }
+ pending.reset();
+ delete(file);
+ final File dir = file.getParentFile();
+ if (dir != null) {
+ final String[] left = dir.list();
+ if (left != null && left.length == 0) {
+ delete(dir);
+ }
+ }
+ }
+
+ /**
+ * Delete {@code f}, retrying briefly: on Windows another process (a virus
+ * scanner, the indexer) can hold a freshly written file open for a moment,
+ * and the delete fails until it lets go. Left to the JVM's exit otherwise.
+ */
+ static void delete(final File f) {
+ for (int attempt = 0; attempt < 20; attempt++) {
+ if (f.delete() || !f.exists()) {
+ return;
+ }
+ try {
+ Thread.sleep(25);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ f.deleteOnExit();
+ }
+
+ @Override
+ public String getDumpFileName() {
+ return file.getPath();
+ }
+
+ @Override
+ public boolean isNoop() {
+ return false;
+ }
+
+ @Override
+ public boolean isDot() {
+ return false;
+ }
+
+ /** True, so the worker routes blocked guards here. */
+ @Override
+ public boolean isConstrained() {
+ return true;
+ }
+
+ @Override
+ public synchronized void snapshot() throws IOException {
+ flush();
+ content.getFD().sync();
+ }
+
+ // ─── storing and reading content ────────────────────────────────────
+
+ /**
+ * A state's variable values as bytes. Only the values: the TLCState
+ * header (worker id, uid, level) is unset on the states the writer hook
+ * receives, and the nat encodings reject negative values.
+ */
+ private static byte[] serialise(final TLCState state) {
+ final Serialiser ser = SERIALISER.get();
+ try {
+ // The stream first: resetting it flushes what it still buffers,
+ // which after a write that threw part way is a partial value that
+ // must not land in front of this state.
+ ser.vos.reset();
+ ser.bytes.reset();
+ for (final tla2sany.semantic.OpDeclNode var : state.getVars()) {
+ final tlc2.value.IValue value = state.lookup(var.getName());
+ if (value == null) {
+ throw new IOException("unassigned variable " + var.getName() + " in a stored state");
+ }
+ value.write(ser.vos);
+ }
+ ser.vos.reset();
+ return ser.bytes.toByteArray();
+ } catch (final IOException e) {
+ throw new RuntimeException("basis.states: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * One per worker thread: a value stream costs an 8 KiB buffer to build,
+ * and every stored state is serialised on the worker that reached it.
+ */
+ private static final class Serialiser {
+ final ByteArrayOutputStream bytes = new ByteArrayOutputStream(256);
+ final ValueOutputStream vos;
+
+ Serialiser() {
+ try {
+ vos = new ValueOutputStream(bytes, false);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ private static final ThreadLocal SERIALISER = ThreadLocal.withInitial(Serialiser::new);
+
+ /** Append serialised content; the file write is batched. Caller holds the lock. */
+ private void store(final long fp, final byte[] data, final int level, final long predecessor,
+ final int action) {
+ index.put(fp, new Entry(append(data), data.length, level, predecessor, action));
+ }
+
+ /** Append serialised content and return its offset. Caller holds the lock. */
+ private long append(final byte[] data) {
+ final long offset = length;
+ pending.write(data, 0, data.length);
+ length += data.length;
+ if (pending.size() >= FLUSH_AT) {
+ try {
+ flush();
+ } catch (final IOException e) {
+ throw new RuntimeException("basis.states: " + e.getMessage(), e);
+ }
+ }
+ return offset;
+ }
+
+ /** A state in the model, else an excluded successor, else null. Caller holds the lock. */
+ private Entry entry(final long fp) {
+ final Entry e = index.get(fp);
+ return e != null ? e : excludedIndex.get(fp);
+ }
+
+ private void flush() throws IOException {
+ if (pending.size() == 0) {
+ return;
+ }
+ content.seek(content.length());
+ pending.writeTo(new java.io.OutputStream() {
+ @Override
+ public void write(final int b) throws IOException {
+ content.write(b);
+ }
+
+ @Override
+ public void write(final byte[] b, final int off, final int len) throws IOException {
+ content.write(b, off, len);
+ }
+ });
+ pending.reset();
+ }
+
+ /** The stored state (in the model, or an excluded successor) with this fingerprint, or null. */
+ public synchronized TLCState read(final long fp) {
+ final Entry e = entry(fp);
+ if (e == null) {
+ return null;
+ }
+ try {
+ flush();
+ final byte[] data = new byte[e.length];
+ content.seek(e.offset);
+ content.readFully(data);
+ final ValueInputStream vis = new ValueInputStream(new ByteArrayInputStream(data));
+ TLCState state = empty().createEmpty();
+ for (final tla2sany.semantic.OpDeclNode var : state.getVars()) {
+ state = state.bind(var.getName(), vis.read());
+ }
+ vis.close();
+ return state;
+ } catch (final IOException e2) {
+ throw new RuntimeException("basis.states: " + e2.getMessage(), e2);
+ }
+ }
+
+ public synchronized boolean contains(final long fp) {
+ return index.containsKey(fp);
+ }
+
+ public synchronized Integer level(final long fp) {
+ final Entry e = entry(fp);
+ return e == null ? null : e.level;
+ }
+
+ /** Every stored fingerprint, in no particular order. */
+ public synchronized long[] fingerprints() {
+ final long[] out = new long[index.size()];
+ int i = 0;
+ for (final Long fp : index.keySet()) {
+ out[i++] = fp;
+ }
+ return out;
+ }
+
+ /**
+ * The fingerprints from an initial state to {@code fp} along first
+ * predecessors, with the action id taken at each step (-1 for the initial
+ * state). Null when {@code fp} is not stored.
+ */
+ public synchronized long[][] pathTo(final long fp) {
+ if (entry(fp) == null) {
+ return null;
+ }
+ final List reversed = new ArrayList<>();
+ long cur = fp;
+ while (true) {
+ // An excluded successor ends a path; every state before it is in the model.
+ final Entry e = cur == fp ? entry(cur) : index.get(cur);
+ if (e == null) {
+ break;
+ }
+ reversed.add(new long[] { cur, e.action });
+ if (e.action < 0 || e.predecessor == cur) {
+ break;
+ }
+ cur = e.predecessor;
+ }
+ final long[][] path = new long[reversed.size()][];
+ for (int i = 0; i < path.length; i++) {
+ path[i] = reversed.get(path.length - 1 - i);
+ }
+ return path;
+ }
+
+ /** Whether {@code fp} was kept as an excluded successor (it may also be in the model). */
+ public synchronized boolean isExcluded(final long fp) {
+ return excludedIndex.containsKey(fp);
+ }
+
+ /** The excluded successors not also in the model: TLC checked invariants on them, and never expanded them. */
+ public synchronized long[] excludedFingerprints() {
+ final List out = new ArrayList<>();
+ for (final Long fp : excludedIndex.keySet()) {
+ if (!index.containsKey(fp)) {
+ out.add(fp);
+ }
+ }
+ final long[] a = new long[out.size()];
+ for (int i = 0; i < a.length; i++) {
+ a[i] = out.get(i);
+ }
+ return a;
+ }
+
+ /** (excluded successor fp, action id) pairs generated from {@code fp}. */
+ public synchronized long[][] excludedSuccessors(final long fp) {
+ final LongVec v = excludedEdges.get(fp);
+ if (v == null) {
+ return new long[0][];
+ }
+ final long[][] out = new long[v.size() / 2][];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = new long[] { v.elementAt(2 * i), v.elementAt(2 * i + 1) };
+ }
+ return out;
+ }
+
+ /** Excluded successors kept, whether or not they are also in the model. */
+ public synchronized long excludedStates() {
+ return excludedIndex.size();
+ }
+
+ /** (predecessor fp, action id, flags) triples recorded into {@code fp}. */
+ public synchronized long[][] predecessorsOf(final long fp) {
+ final LongVec v = predecessors.get(fp);
+ if (v == null) {
+ return new long[0][];
+ }
+ final long[][] out = new long[v.size() / 3][];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = new long[] { v.elementAt(3 * i), v.elementAt(3 * i + 1), v.elementAt(3 * i + 2) };
+ }
+ return out;
+ }
+
+ public synchronized Action action(final int id) {
+ return actions.get(id);
+ }
+
+ /** Snapshots of the tallies, guards and constraints alike. */
+ public List blocked() {
+ final List out = new ArrayList<>();
+ for (final Blocked b : blocked.values()) {
+ out.add(b.snapshot());
+ }
+ out.sort((a, b) -> Long.compare(b.count, a.count));
+ return out;
+ }
+
+ /** The initial states' fingerprints, in the order they were written. */
+ public synchronized long[] initialFingerprints() {
+ final long[] out = new long[initial.size()];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = initial.get(i);
+ }
+ return out;
+ }
+
+ /** The initial states a state constraint excluded, in the order they were written. */
+ public synchronized long[] excludedInitialFingerprints() {
+ final long[] out = new long[excludedInitial.size()];
+ int i = 0;
+ for (final Long fp : excludedInitial) {
+ out[i++] = fp;
+ }
+ return out;
+ }
+
+ /** The variable names the stored states were written with, in order. */
+ public synchronized String[] variableNames() {
+ final TLCState e = empty();
+ final tla2sany.semantic.OpDeclNode[] vars = e.getVars();
+ final String[] out = new String[vars.length];
+ for (int i = 0; i < vars.length; i++) {
+ out[i] = vars[i].getName().toString();
+ }
+ return out;
+ }
+
+ public synchronized long states() {
+ return index.size();
+ }
+
+ public synchronized long edges() {
+ return edges;
+ }
+
+ /** Guard conjuncts that evaluated false. */
+ public long unsatisfied() {
+ return unsatisfied.sum();
+ }
+
+ /** Successors a state or action constraint excluded. */
+ public long excluded() {
+ return excluded.sum();
+ }
+
+ public synchronized long initialStates() {
+ return initial.size();
+ }
+
+ public synchronized long bytes() {
+ return length;
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java
new file mode 100644
index 0000000000..2455b230f1
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java
@@ -0,0 +1,997 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import tla2sany.semantic.ASTConstants;
+import tla2sany.semantic.ExprNode;
+import tla2sany.semantic.FormalParamNode;
+import tla2sany.semantic.OpApplNode;
+import tla2sany.semantic.OpArgNode;
+import tla2sany.semantic.OpDeclNode;
+import tla2sany.semantic.OpDefNode;
+import tla2sany.semantic.SemanticNode;
+import tla2sany.semantic.Subst;
+import tla2sany.semantic.SubstInNode;
+import tla2sany.semantic.SymbolNode;
+import tlc2.tool.Action;
+import tlc2.tool.StateVec;
+import tlc2.tool.TLCState;
+import tlc2.tool.impl.Tool;
+import tlc2.util.Context;
+import tlc2.util.Vect;
+
+/**
+ * Re-explore after a spec edit, doing only the work the edit requires.
+ *
+ *
+ * The edited spec is parsed into a new {@link Tool}. Every action, invariant,
+ * initial predicate and constraint gets a signature: its
+ * own source text, the text of every user definition it reaches
+ * (transitively, across modules), and the values its context binds (the
+ * {@code p} of an action split out of {@code \E p \in S : A(p)}). Actions are
+ * paired with the old ones by name and signature; invariants by name. If the
+ * variables, the initial predicate or a state or action constraint changed,
+ * the spec has a VIEW or a SYMMETRY set, or anything explored reads {@code TLCGet} (whose
+ * values depend on the path to a state, not the state), nothing can be
+ * reused and the caller runs a fresh exploration instead; so does the caller
+ * when the model config changed or the old exploration did not finish.
+ *
+ *
+ * Otherwise the old store's graph is replayed: the states reachable from the
+ * initial states along edges of unchanged actions survive, each copied into
+ * a new store with those edges; surviving states are re-expanded under the
+ * changed and added actions only; states reached for the first time are
+ * expanded under every action. Every new state is checked against every
+ * invariant, and every surviving state against the changed invariants.
+ * Successors a constraint excludes are checked too, as TLC checks them, and
+ * kept in the store unexpanded; edges to them under unchanged actions are
+ * carried like any other.
+ * Successor generation, constraints and invariant evaluation all go through
+ * the new {@link Tool}, so the store is a cache of TLC's own answers, never
+ * an oracle of its own. Copying an edge is sound only because the old store
+ * holds a fully explored graph under the same constraints: every in-model
+ * successor of a stored state under an unchanged action is already an edge.
+ *
+ *
+ * Not covered here: liveness (the tableau is not rebuilt), deadlock
+ * reporting, and the blocked-guard tallies for the re-expanded states
+ * (successor generation here does not pass through the constrained writer).
+ */
+public final class Incremental {
+
+ /** How an old action maps to the new spec. */
+ public static final class ActionDiff {
+ public final List unchanged = new ArrayList<>();
+ public final List changed = new ArrayList<>();
+ public final List added = new ArrayList<>();
+ public final List removed = new ArrayList<>();
+ /** Old action id to the new action, for unchanged actions. */
+ final Map carried = new HashMap<>();
+ /** New actions to re-expand surviving states under. */
+ final List reexpand = new ArrayList<>();
+ public final List changedInvariants = new ArrayList<>();
+ public final List changedDefinitions = new ArrayList<>();
+ public String fullRerunReason;
+ }
+
+ public static final class Violation {
+ public final String invariant;
+ public final long fp;
+ public final int level;
+
+ Violation(String invariant, long fp, int level) {
+ this.invariant = invariant;
+ this.fp = fp;
+ this.level = level;
+ }
+ }
+
+ public static final class Result {
+ public long survivors;
+ public long dropped;
+ public long reexpanded;
+ public long newStates;
+ public long edgesCopied;
+ public long edgesGenerated;
+ public boolean budgetExhausted;
+ public final List violations = new ArrayList<>();
+ public String error;
+ /**
+ * Set when a stored state does not read back as the state it was
+ * stored as (its fingerprint under the edited spec differs): nothing
+ * copied from the old store can be trusted, so the caller restarts.
+ */
+ public String restartReason;
+ }
+
+ private Incremental() {
+ }
+
+ private static String text(final Action a) {
+ return a.pred == null ? "" : GraphStore.text(a.pred);
+ }
+
+ /** Pair the old and new specs' actions, invariants and definitions. */
+ public static ActionDiff diff(final Tool oldTool, final Tool newTool) {
+ final ActionDiff d = new ActionDiff();
+ // Variables, from each tool's own declarations: TLC's static variable
+ // table (behind the store's decoding) already belongs to the new parse.
+ if (!variableNames(oldTool).equals(variableNames(newTool))) {
+ d.fullRerunReason = "the variables changed";
+ return d;
+ }
+ // Definitions of the root module, by name (reported, not decisive:
+ // the signatures below decide what changed).
+ final Map oldDefs = definitions(oldTool);
+ final Map newDefs = definitions(newTool);
+ for (final Map.Entry e : newDefs.entrySet()) {
+ final String before = oldDefs.get(e.getKey());
+ if (before == null || !before.equals(e.getValue())) {
+ d.changedDefinitions.add(e.getKey());
+ }
+ }
+ for (final String name : oldDefs.keySet()) {
+ if (!newDefs.containsKey(name)) {
+ d.changedDefinitions.add(name);
+ }
+ }
+ // What every explored state and edge was filtered or identified by.
+ if (!initSignature(oldTool).equals(initSignature(newTool))) {
+ d.fullRerunReason = "the initial predicate changed";
+ return d;
+ }
+ if (!constraintSignature(oldTool).equals(constraintSignature(newTool))) {
+ d.fullRerunReason = "a state or action constraint changed";
+ return d;
+ }
+ for (final Tool t : new Tool[] { oldTool, newTool }) {
+ final String reason = fingerprintAbstraction(t);
+ if (reason != null) {
+ d.fullRerunReason = reason;
+ return d;
+ }
+ }
+ if (!substitutionSignature(oldTool).equals(substitutionSignature(newTool))) {
+ d.fullRerunReason = "a definition the config substitutes with <- changed";
+ return d;
+ }
+ for (final Tool t : new Tool[] { oldTool, newTool }) {
+ final String reason = tlcGetUse(t);
+ if (reason != null) {
+ d.fullRerunReason = reason;
+ return d;
+ }
+ }
+ // Actions.
+ final Map> oldByKey = new HashMap<>();
+ final Map oldSig = new HashMap<>();
+ for (final Action a : oldTool.getActions()) {
+ oldByKey.computeIfAbsent(a.getNameOfDefault(), k -> new ArrayList<>()).add(a);
+ oldSig.put(a.getId(), signature(a));
+ }
+ // Exact (name, signature) pairs first, over every new action, so an
+ // edited action cannot claim an old one that a later new action
+ // matches exactly: the disjuncts of an unnamed Next all share its
+ // name, and inserting one in front would otherwise shift every pair.
+ final Set matchedOld = new HashSet<>();
+ final Action[] newActions = newTool.getActions();
+ final Action[] exact = new Action[newActions.length];
+ for (int i = 0; i < newActions.length; i++) {
+ final String sig = signature(newActions[i]);
+ for (final Action o : oldByKey.getOrDefault(newActions[i].getNameOfDefault(), List.of())) {
+ if (!matchedOld.contains(o.getId()) && oldSig.get(o.getId()).equals(sig)) {
+ matchedOld.add(o.getId());
+ exact[i] = o;
+ break;
+ }
+ }
+ }
+ // Then the rest: a same-named old action left over makes it changed.
+ for (int i = 0; i < newActions.length; i++) {
+ final Action n = newActions[i];
+ final String name = n.getNameOfDefault();
+ if (exact[i] != null) {
+ d.carried.put(exact[i].getId(), n);
+ d.unchanged.add(name);
+ } else if (pairUnmatched(oldByKey.getOrDefault(name, List.of()), matchedOld)) {
+ // Same name, different signature.
+ d.changed.add(name);
+ d.reexpand.add(n);
+ } else {
+ d.added.add(name);
+ d.reexpand.add(n);
+ }
+ }
+ for (final Action o : oldTool.getActions()) {
+ if (!matchedOld.contains(o.getId())) {
+ d.removed.add(o.getNameOfDefault());
+ }
+ }
+ // Invariants.
+ final Map oldInv = new HashMap<>();
+ final String[] oldNames = oldTool.getInvNames();
+ final Action[] oldInvs = oldTool.getInvariants();
+ for (int i = 0; i < oldInvs.length; i++) {
+ oldInv.put(i < oldNames.length ? oldNames[i] : oldInvs[i].getNameOfDefault(), signature(oldInvs[i]));
+ }
+ final String[] newNames = newTool.getInvNames();
+ final Action[] newInvs = newTool.getInvariants();
+ for (int i = 0; i < newInvs.length; i++) {
+ final String name = i < newNames.length ? newNames[i] : newInvs[i].getNameOfDefault();
+ if (!signature(newInvs[i]).equals(oldInv.get(name))) {
+ d.changedInvariants.add(name);
+ }
+ }
+ return d;
+ }
+
+ /**
+ * Why a replay cannot be trusted because {@code tool} reads TLC's
+ * registers, or null. {@code TLCGet("level")} and its siblings depend on
+ * how a state was reached, not only on its fingerprint: an added action
+ * can shorten the path to a state and bring successors a level-bounded
+ * constraint excluded back into the model, which copied edges never
+ * re-examine. Replay also evaluates without the predecessor TLC sets.
+ */
+ private static String tlcGetUse(final Tool tool) {
+ for (final Action a : tool.getActions()) {
+ if (reachesTLCGet(a.pred)) {
+ return "the action " + a.getNameOfDefault() + " reads TLCGet, which depends on the path to a state";
+ }
+ }
+ final Vect init = tool.getInitStateSpec();
+ for (int i = 0; i < init.size(); i++) {
+ if (reachesTLCGet(init.elementAt(i).pred)) {
+ return "the initial predicate reads TLCGet, which depends on the path to a state";
+ }
+ }
+ for (final ExprNode c : tool.getModelConstraints()) {
+ if (reachesTLCGet(c)) {
+ return "a state constraint reads TLCGet, which depends on the path to a state";
+ }
+ }
+ for (final ExprNode c : tool.getActionConstraints()) {
+ if (reachesTLCGet(c)) {
+ return "an action constraint reads TLCGet, which depends on the path to a state";
+ }
+ }
+ for (final Action a : tool.getInvariants()) {
+ if (reachesTLCGet(a.pred)) {
+ return "the invariant " + a.getNameOfDefault() + " reads TLCGet, which depends on the path to a state";
+ }
+ }
+ // The definitions the config substitutes in are bound as tool
+ // objects, so the walks above never reach them (see
+ // substitutionSignature): check each one itself.
+ final Map byName = new HashMap<>();
+ final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs();
+ for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) {
+ byName.put(def.getName().toString(), def);
+ }
+ final Map rhs = new TreeMap<>(tool.getModelConfig().getOverrides());
+ final Map, ?> modOverrides = tool.getModelConfig().getModOverrides();
+ for (final Map.Entry, ?> m : modOverrides.entrySet()) {
+ for (final Map.Entry, ?> e : ((Map, ?>) m.getValue()).entrySet()) {
+ rhs.put(m.getKey() + "!" + e.getKey(), String.valueOf(e.getValue()));
+ }
+ }
+ for (final Map.Entry e : rhs.entrySet()) {
+ final OpDefNode def = byName.get(e.getValue());
+ if (def == null) {
+ continue;
+ }
+ final Map reached = new TreeMap<>();
+ reachDefinition(def, reached, new HashSet<>());
+ if (pathDependent(reached)) {
+ return "the config substitutes " + e.getKey() + " <- " + e.getValue()
+ + ", which reads TLCGet or the behaviour, which depend on the path to a state";
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Operators whose value depends on how a state was reached: TLC's
+ * registers, and TLCExt's behaviour-so-far operators (overridden in Java
+ * to read the predecessor chain, which a replay does not set).
+ */
+ private static final Set PATH_DEPENDENT = Set.of("TLC!TLCGet", "TLCExt!Trace", "TLCExt!CounterExample");
+
+ private static boolean pathDependent(final Map reached) {
+ for (final String name : PATH_DEPENDENT) {
+ if (reached.containsKey(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean reachesTLCGet(final SemanticNode node) {
+ final Map reached = new TreeMap<>();
+ reach(node, reached, new HashSet<>());
+ return pathDependent(reached);
+ }
+
+ /** Claim the first old action of {@code candidates} not yet paired; false when none is left. */
+ private static boolean pairUnmatched(final List candidates, final Set matchedOld) {
+ for (final Action o : candidates) {
+ if (matchedOld.add(o.getId())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static List variableNames(final Tool tool) {
+ final List out = new ArrayList<>();
+ for (final OpDeclNode v : tool.getSpecProcessor().getVariablesNodes()) {
+ out.add(v.getName().toString());
+ }
+ return out;
+ }
+
+ private static Map definitions(final Tool tool) {
+ final Map out = new HashMap<>();
+ final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs();
+ if (defs == null) {
+ return out;
+ }
+ for (final OpDefNode def : defs) {
+ if (def.getBody() == null) {
+ continue;
+ }
+ out.put(def.getName().toString(), GraphStore.text(def.getBody()));
+ }
+ return out;
+ }
+
+ private static String signature(final Action a) {
+ return signature(a.pred, a.con);
+ }
+
+ /**
+ * The text of {@code node}, the name, formal parameters and text of every
+ * user definition it reaches (transitively, in name order), the
+ * substitutions of every INSTANCE it passes through and the values
+ * {@code con} binds.
+ * Two nodes with equal signatures denote the same predicate under the same
+ * constant values, which the config pins.
+ */
+ private static String signature(final SemanticNode node, final Context con) {
+ final StringBuilder sb = new StringBuilder(GraphStore.text(node));
+ final Map reached = new TreeMap<>();
+ reach(node, reached, new HashSet<>());
+ for (final Map.Entry e : reached.entrySet()) {
+ sb.append('\n').append(e.getKey()).append(" == ").append(e.getValue());
+ }
+ if (con != null && con != Context.Empty) {
+ sb.append("\nwith ").append(con);
+ }
+ return sb.toString();
+ }
+
+ private static void reach(final SemanticNode node, final Map reached,
+ final Set seen) {
+ if (node == null || !seen.add(node)) {
+ return;
+ }
+ if (node instanceof OpApplNode) {
+ reachDefinition(((OpApplNode) node).getOperator(), reached, seen);
+ } else if (node instanceof OpArgNode) {
+ reachDefinition(((OpArgNode) node).getOp(), reached, seen);
+ } else if (node instanceof SubstInNode) {
+ // INSTANCE ... WITH a <- e: which parameter each expression
+ // replaces is not in any definition's text.
+ final StringBuilder with = new StringBuilder();
+ for (final Subst s : ((SubstInNode) node).getSubsts()) {
+ with.append(s.getOp().getName()).append(" <- ").append(GraphStore.text(s.getExpr())).append(", ");
+ }
+ // Keyed by content, not location, so moving the text is no edit.
+ reached.put("WITH " + with, "");
+ }
+ final SemanticNode[] children = node.getChildren();
+ if (children != null) {
+ for (final SemanticNode c : children) {
+ reach(c, reached, seen);
+ }
+ }
+ }
+
+ private static void reachDefinition(final SymbolNode op, final Map reached,
+ final Set seen) {
+ if (!(op instanceof OpDefNode)) {
+ return;
+ }
+ final OpDefNode def = (OpDefNode) op;
+ if (def.getKind() != ASTConstants.UserDefinedOpKind || def.getBody() == null) {
+ return;
+ }
+ // Keyed by module too: two modules may define the same name.
+ final String module = def.getLocation() == null ? "" : def.getLocation().source() + "!";
+ // The formal parameters too: F(a, b) == a - b and F(b, a) == a - b
+ // share their body text.
+ final StringBuilder params = new StringBuilder("(");
+ for (final FormalParamNode p : def.getParams()) {
+ params.append(p.getName()).append('/').append(p.getArity()).append(", ");
+ }
+ reached.put(module + def.getName(), params.append(") ").append(GraphStore.text(def.getBody())).toString());
+ reach(def.getBody(), reached, seen);
+ }
+
+ private static String initSignature(final Tool tool) {
+ final StringBuilder sb = new StringBuilder();
+ final Vect init = tool.getInitStateSpec();
+ for (int i = 0; i < init.size(); i++) {
+ sb.append(signature(init.elementAt(i))).append('\n');
+ }
+ return sb.toString();
+ }
+
+ private static String constraintSignature(final Tool tool) {
+ final StringBuilder sb = new StringBuilder();
+ for (final ExprNode c : tool.getModelConstraints()) {
+ sb.append("state ").append(signature(c, null)).append('\n');
+ }
+ for (final ExprNode c : tool.getActionConstraints()) {
+ sb.append("action ").append(signature(c, null)).append('\n');
+ }
+ return sb.toString();
+ }
+
+ /**
+ * The definitions the config substitutes in ({@code CONSTANT N <- Def},
+ * {@code Op <- Def}, {@code Op <- [M] Def}). TLC binds them as tool
+ * objects, so the syntactic walk in {@link #signature} never reaches them:
+ * an edit to {@code Def} would leave every action's signature unchanged.
+ */
+ private static String substitutionSignature(final Tool tool) {
+ final Map byName = new HashMap<>();
+ final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs();
+ for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) {
+ byName.put(def.getName().toString(), def);
+ }
+ final Map subst = new TreeMap<>();
+ final Map overrides = tool.getModelConfig().getOverrides();
+ for (final Map.Entry e : overrides.entrySet()) {
+ subst.put(e.getKey(), substituted(e.getValue(), byName));
+ }
+ final Map, ?> modOverrides = tool.getModelConfig().getModOverrides();
+ for (final Map.Entry, ?> m : modOverrides.entrySet()) {
+ for (final Map.Entry, ?> e : ((Map, ?>) m.getValue()).entrySet()) {
+ subst.put(m.getKey() + "!" + e.getKey(), substituted(String.valueOf(e.getValue()), byName));
+ }
+ }
+ final StringBuilder sb = new StringBuilder();
+ for (final Map.Entry e : subst.entrySet()) {
+ sb.append(e.getKey()).append(" <- ").append(e.getValue()).append('\n');
+ }
+ return sb.toString();
+ }
+
+ private static String substituted(final String rhs, final Map byName) {
+ final OpDefNode def = byName.get(rhs);
+ return def == null || def.getBody() == null ? rhs : rhs + " == " + signature(def.getBody(), null);
+ }
+
+ /**
+ * Why a replay cannot be trusted because {@code tool} fingerprints states
+ * through a VIEW or a SYMMETRY set, or null. A fingerprint then names
+ * several concrete states and TLC explores the one that reached it first.
+ * The store keeps that one's content, which a copied edge from another
+ * state need not generate, so a replay can explore a representative the
+ * edited spec never reaches.
+ */
+ private static String fingerprintAbstraction(final Tool tool) {
+ if (tool.getViewSpec() != null) {
+ return "the spec has a VIEW, under which a stored state may not be the one a copied edge reaches";
+ }
+ final String symmetry = tool.getModelConfig().getSymmetry();
+ if (tool.getSymmetryPerms() != null || (symmetry != null && !symmetry.isEmpty())) {
+ return "the spec has a SYMMETRY set, under which a stored state may not be the one a copied edge reaches";
+ }
+ return null;
+ }
+
+ /**
+ * Replay the old graph into {@code newStore} under {@code newTool},
+ * re-expanding only where the edit reaches. Stops at the first violation
+ * unless {@code continueOnViolation}, or when the budget runs out.
+ */
+ public static Result replay(final Tool newTool, final GraphStore oldStore, final GraphStore newStore,
+ final ActionDiff diff, final long budgetMs, final boolean continueOnViolation) {
+ final Result r = new Result();
+ final long started = System.currentTimeMillis();
+ final Action[] invariants = newTool.getInvariants();
+ final String[] invNames = newTool.getInvNames();
+ final Set changedInv = new HashSet<>(diff.changedInvariants);
+ // Forward adjacency of the old graph restricted to carried actions:
+ // fp -> (succ fp, new action) pairs.
+ final Map> forward = new HashMap<>();
+ for (final long fp : oldStore.fingerprints()) {
+ for (final long[] p : oldStore.predecessorsOf(fp)) {
+ final Action carried = diff.carried.get((int) p[1]);
+ if (carried == null) {
+ continue;
+ }
+ forward.computeIfAbsent(p[0], k -> new ArrayList<>()).add(new long[] { fp, carried.getId() });
+ }
+ }
+ final ArrayDeque queue = new ArrayDeque<>();
+ final Set seen = new HashSet<>();
+ // Initial states are unchanged (the init predicate is), so they seed.
+ for (final long fp : oldStore.initialFingerprints()) {
+ final TLCState s = carry(newTool, oldStore, fp, r);
+ if (r.restartReason != null) {
+ return r;
+ }
+ if (s == null) {
+ continue;
+ }
+ newStore.writeState(s);
+ seen.add(fp);
+ queue.add(fp);
+ }
+ final Set oldStates = new HashSet<>();
+ for (final long fp : oldStore.fingerprints()) {
+ oldStates.add(fp);
+ }
+ boolean stop = false;
+ // Initial states the (unchanged) constraints exclude: never explored,
+ // but TLC checks invariants on them, so the changed ones are checked.
+ // All are stored before any is checked, so the state-level properties
+ // see every initial state however the replay stops.
+ final List excludedInitial = new ArrayList<>();
+ for (final long fp : oldStore.excludedInitialFingerprints()) {
+ final TLCState s = carry(newTool, oldStore, fp, r);
+ if (r.restartReason != null) {
+ return r;
+ }
+ if (s != null) {
+ newStore.writeExcludedInitial(s);
+ excludedInitial.add(s);
+ }
+ }
+ for (final TLCState s : excludedInitial) {
+ if (checkInvariants(newTool, s, s.fingerPrint(), 1, invariants, invNames, changedInv, continueOnViolation, r)) {
+ stop = true;
+ break;
+ }
+ }
+ while (!queue.isEmpty() && !stop) {
+ if (System.currentTimeMillis() - started > budgetMs) {
+ r.budgetExhausted = true;
+ break;
+ }
+ final long fp = queue.poll();
+ final TLCState state = rebind(newTool, newStore.read(fp));
+ // No VIEW or SYMMETRY (see fingerprintAbstraction): a fingerprint
+ // names one state, so a stored one keeps its old edges.
+ final boolean survivor = oldStates.contains(fp);
+ if (survivor) {
+ r.survivors++;
+ // Carried edges: copy successors and their content.
+ try {
+ for (final long[] e : forward.getOrDefault(fp, List.of())) {
+ final long to = e[0];
+ final Action a = newTool.getActions()[actionIndex(newTool, (int) e[1])];
+ final TLCState succ = newStore.contains(to) ? newStore.read(to)
+ : carry(newTool, oldStore, to, r);
+ if (r.restartReason != null) {
+ return r;
+ }
+ if (succ == null) {
+ continue;
+ }
+ final boolean unseen = !newStore.contains(to);
+ newStore.writeState(state, succ,
+ unseen ? tlc2.util.IStateWriter.IsUnseen : tlc2.util.IStateWriter.IsSeen, a);
+ r.edgesCopied++;
+ if (seen.add(to)) {
+ queue.add(to);
+ }
+ }
+ } catch (final Throwable t) {
+ r.error = "copying edges: " + t;
+ break;
+ }
+ // Changed invariants on a survivor.
+ if (checkInvariants(newTool, state, fp, newStore.level(fp), invariants, invNames, changedInv,
+ continueOnViolation, r)) {
+ break;
+ }
+ // Carried edges to excluded successors: the same successors under
+ // the same constraints, so still excluded. TLC checked every
+ // invariant on them; the changed ones are checked again.
+ try {
+ for (final long[] e : oldStore.excludedSuccessors(fp)) {
+ final Action a = diff.carried.get((int) e[1]);
+ if (a == null) {
+ continue;
+ }
+ final long to = e[0];
+ final boolean fresh = !newStore.contains(to) && !newStore.isExcluded(to);
+ final TLCState succ = fresh ? carry(newTool, oldStore, to, r) : newStore.read(to);
+ if (r.restartReason != null) {
+ return r;
+ }
+ if (succ == null) {
+ continue;
+ }
+ newStore.writeExcluded(state, succ, a);
+ r.edgesCopied++;
+ if (fresh && checkInvariants(newTool, succ, to, newStore.level(to), invariants, invNames,
+ changedInv, continueOnViolation, r)) {
+ stop = true;
+ break;
+ }
+ }
+ } catch (final Throwable t) {
+ r.error = "copying excluded edges: " + t;
+ break;
+ }
+ if (stop) {
+ break;
+ }
+ if (!diff.reexpand.isEmpty()) {
+ r.reexpanded++;
+ }
+ stop = expand(newTool, newStore, state, fp, diff.reexpand, invariants, invNames, continueOnViolation,
+ seen, queue, r);
+ } else {
+ r.newStates++;
+ stop = expand(newTool, newStore, state, fp, List.of(newTool.getActions()), invariants, invNames,
+ continueOnViolation, seen, queue, r);
+ }
+ }
+ r.dropped = oldStates.size() - r.survivors;
+ return r;
+ }
+
+ private static int actionIndex(final Tool tool, final int id) {
+ final Action[] actions = tool.getActions();
+ for (int i = 0; i < actions.length; i++) {
+ if (actions[i].getId() == id) {
+ return i;
+ }
+ }
+ throw new IllegalStateException("no action with id " + id);
+ }
+
+ /**
+ * The old store's state {@code fp} rebuilt against the new spec, or null
+ * when it is not stored. Copying it is sound only if it is the same state:
+ * when its fingerprint under the new spec differs (a value that decodes
+ * differently now, such as a model value renumbered by the parse), sets
+ * {@link Result#restartReason} and returns null.
+ */
+ private static TLCState carry(final Tool tool, final GraphStore oldStore, final long fp, final Result r) {
+ final TLCState s;
+ final long now;
+ try {
+ s = rebind(tool, oldStore.read(fp));
+ if (s == null) {
+ return null;
+ }
+ now = s.fingerPrint();
+ } catch (final RuntimeException e) {
+ r.restartReason = "a stored state does not decode under the edited spec (" + e + "), so the old graph cannot be carried";
+ return null;
+ }
+ if (now != fp) {
+ r.restartReason = "a stored state does not read back as the same state under the edited spec (its fingerprint changed), so the old graph cannot be carried";
+ return null;
+ }
+ return s;
+ }
+
+ /** A stored state rebuilt against the new spec's variable set. */
+ private static TLCState rebind(final Tool tool, final TLCState s) {
+ if (s == null) {
+ return null;
+ }
+ TLCState out = TLCState.Empty.createEmpty();
+ for (final tla2sany.semantic.OpDeclNode v : out.getVars()) {
+ final tlc2.value.IValue value = s.lookup(v.getName());
+ if (value != null) {
+ out = out.bind(v.getName(), value);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Expand one state under {@code actions}; returns true to stop. As TLC's
+ * worker does, every invariant is checked on a successor seen for the
+ * first time, including one a constraint excludes from the model: the
+ * excluded one is kept in the store, not expanded.
+ */
+ private static boolean expand(final Tool tool, final GraphStore store, final TLCState state, final long fp,
+ final List actions, final Action[] invariants, final String[] invNames,
+ final boolean continueOnViolation, final Set seen, final ArrayDeque queue, final Result r) {
+ for (final Action a : actions) {
+ final StateVec next;
+ try {
+ next = tool.getNextStatesUnrecorded(a, state);
+ } catch (final Throwable t) {
+ r.error = a.getNameOfDefault() + ": " + t;
+ return true;
+ }
+ for (int i = 0; i < next.size(); i++) {
+ final TLCState succ = next.elementAt(i);
+ if (!succ.allAssigned()) {
+ r.error = a.getNameOfDefault() + " left " + succ.getUnassigned() + " unassigned";
+ return true;
+ }
+ boolean inModel;
+ try {
+ inModel = inModel(tool, state, succ);
+ } catch (final Throwable t) {
+ r.error = "constraint: " + t;
+ return true;
+ }
+ final long to = succ.fingerPrint();
+ if (!inModel) {
+ final boolean fresh = !store.contains(to) && !store.isExcluded(to);
+ store.writeExcluded(state, succ, a);
+ if (fresh && checkInvariants(tool, succ, to, store.level(to), invariants, invNames, null,
+ continueOnViolation, r)) {
+ return true;
+ }
+ continue;
+ }
+ final boolean unseen = !store.contains(to);
+ store.writeState(state, succ, unseen ? tlc2.util.IStateWriter.IsUnseen : tlc2.util.IStateWriter.IsSeen, a);
+ r.edgesGenerated++;
+ if (unseen && checkInvariants(tool, succ, to, store.level(to), invariants, invNames, null,
+ continueOnViolation, r)) {
+ return true;
+ }
+ if (seen.add(to)) {
+ queue.add(to);
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether {@code succ}, reached from {@code state}, satisfies every state
+ * and action constraint, as {@link Tool#isInModel(TLCState)} and
+ * {@link Tool#isInActions(TLCState, TLCState)} decide it. Those read the
+ * constraints' cost models, which only a tool a checker ran has, so a
+ * refreshed tool evaluates them without one.
+ */
+ private static boolean inModel(final Tool tool, final TLCState state, final TLCState succ) {
+ for (final ExprNode c : tool.getModelConstraints()) {
+ if (!bool(tool.eval(c, Context.Empty, succ, tlc2.tool.coverage.CostModel.DO_NOT_RECORD), c)) {
+ return false;
+ }
+ }
+ for (final ExprNode c : tool.getActionConstraints()) {
+ if (!bool(tool.eval(c, Context.Empty, state, succ, tlc2.tool.EvalControl.Clear,
+ tlc2.tool.coverage.CostModel.DO_NOT_RECORD), c)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean bool(final tlc2.value.IValue v, final ExprNode constraint) {
+ if (!(v instanceof tlc2.value.impl.BoolValue)) {
+ throw new IllegalStateException("constraint " + GraphStore.text(constraint) + " is not a boolean: " + v);
+ }
+ return ((tlc2.value.impl.BoolValue) v).val;
+ }
+
+ /**
+ * Check the invariants named in {@code only} (every one when null) on
+ * {@code state}, recording violations; returns true to stop, on an error
+ * or on a violation unless {@code continueOnViolation}.
+ */
+ private static boolean checkInvariants(final Tool tool, final TLCState state, final long fp, final Integer level,
+ final Action[] invariants, final String[] invNames, final Set only,
+ final boolean continueOnViolation, final Result r) {
+ for (int k = 0; k < invariants.length; k++) {
+ final String name = k < invNames.length ? invNames[k] : invariants[k].getNameOfDefault();
+ if (only != null && !only.contains(name)) {
+ continue;
+ }
+ boolean holds;
+ try {
+ holds = holds(tool, invariants[k], state);
+ } catch (final Throwable t) {
+ r.error = name + ": " + t;
+ return true;
+ }
+ if (!holds) {
+ r.violations.add(new Violation(name, fp, level == null ? -1 : level));
+ if (!continueOnViolation) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether the state predicate {@code inv} holds in {@code state}, as
+ * {@link Tool#isValid(Action, TLCState)} decides it but without counting
+ * the evaluation in the invariant's coverage: a sweep over the store is
+ * not part of the run whose coverage is reported.
+ */
+ static boolean holds(final Tool tool, final Action inv, final TLCState state) {
+ final tlc2.value.IValue v = tool.eval(inv.pred, inv.con, state, tlc2.tool.coverage.CostModel.DO_NOT_RECORD);
+ if (!(v instanceof tlc2.value.impl.BoolValue)) {
+ throw new IllegalStateException("invariant " + inv.getNameOfDefault() + " is not a boolean: " + v);
+ }
+ return ((tlc2.value.impl.BoolValue) v).val;
+ }
+
+ /** One invariant's verdict over a whole store. */
+ public static final class Sweep {
+ public final String invariant;
+ public long violations;
+ public Long firstFp;
+ public Integer firstLevel;
+ public String error;
+
+ Sweep(String invariant) {
+ this.invariant = invariant;
+ }
+ }
+
+ /**
+ * Evaluate every invariant on every stored state, excluded successors and
+ * excluded initial states included, as TLC evaluates them: exact per-invariant
+ * verdicts for the refreshed graph, the first violation being the one at
+ * the lowest level. Costs one evaluation per (state, invariant), no
+ * successor generation.
+ */
+ public static List sweep(final Tool tool, final GraphStore store) {
+ return sweep(tool, store, null);
+ }
+
+ /**
+ * {@link #sweep(Tool, GraphStore)} over the invariants named in
+ * {@code only} (every invariant when null); one {@link Sweep} per
+ * configured invariant, in order, with those left out untouched.
+ */
+ public static List sweep(final Tool tool, final GraphStore store, final Set only) {
+ final Action[] invariants = tool.getInvariants();
+ final String[] names = tool.getInvNames();
+ final List out = new ArrayList<>();
+ final boolean[] wanted = new boolean[invariants.length];
+ for (int k = 0; k < invariants.length; k++) {
+ final String name = k < names.length ? names[k] : invariants[k].getNameOfDefault();
+ out.add(new Sweep(name));
+ wanted[k] = only == null || only.contains(name);
+ }
+ // The excluded successors too: TLC checks invariants on them.
+ final long[] inModel = store.fingerprints();
+ final long[] excluded = store.excludedFingerprints();
+ final long[] all = java.util.Arrays.copyOf(inModel, inModel.length + excluded.length);
+ System.arraycopy(excluded, 0, all, inModel.length, excluded.length);
+ for (final long fp : all) {
+ final TLCState state = rebind(tool, store.read(fp));
+ if (state == null) {
+ continue;
+ }
+ final Integer level = store.level(fp);
+ for (int k = 0; k < invariants.length; k++) {
+ final Sweep sw = out.get(k);
+ if (!wanted[k] || sw.error != null) {
+ continue;
+ }
+ try {
+ if (!holds(tool, invariants[k], state)) {
+ sw.violations++;
+ if (sw.firstLevel == null || (level != null && level < sw.firstLevel)) {
+ sw.firstLevel = level;
+ sw.firstFp = fp;
+ }
+ }
+ } catch (final Throwable t) {
+ sw.error = t.getMessage() == null ? t.toString() : t.getMessage();
+ }
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Evaluate every state-level PROPERTY (TLC's implied inits, which it
+ * checks on the initial states only) on the stored initial states, those a
+ * state constraint excluded included: one
+ * {@link Sweep} per implied init, in order.
+ */
+ public static List impliedInits(final Tool tool, final GraphStore store) {
+ final Action[] inits = tool.getImpliedInits();
+ final String[] names = tool.getImpliedInitNames();
+ final List out = new ArrayList<>();
+ for (int k = 0; k < inits.length; k++) {
+ out.add(new Sweep(k < names.length ? names[k] : inits[k].getNameOfDefault()));
+ }
+ final long[] inModel = store.initialFingerprints();
+ final long[] excluded = store.excludedInitialFingerprints();
+ final long[] all = java.util.Arrays.copyOf(inModel, inModel.length + excluded.length);
+ System.arraycopy(excluded, 0, all, inModel.length, excluded.length);
+ for (final long fp : all) {
+ final TLCState state = rebind(tool, store.read(fp));
+ if (state == null) {
+ continue;
+ }
+ for (int k = 0; k < inits.length; k++) {
+ final Sweep sw = out.get(k);
+ if (sw.error != null) {
+ continue;
+ }
+ try {
+ if (!holds(tool, inits[k], state)) {
+ sw.violations++;
+ if (sw.firstFp == null) {
+ sw.firstFp = fp;
+ sw.firstLevel = 1;
+ }
+ }
+ } catch (final Throwable t) {
+ sw.error = t.getMessage() == null ? t.toString() : t.getMessage();
+ }
+ }
+ }
+ return out;
+ }
+
+ public static JsonObject diffJson(final ActionDiff d) {
+ final JsonObject o = new JsonObject();
+ o.add("unchanged", names(d.unchanged));
+ o.add("changed", names(d.changed));
+ o.add("added", names(d.added));
+ o.add("removed", names(d.removed));
+ o.add("changed_invariants", names(d.changedInvariants));
+ o.add("changed_definitions", names(d.changedDefinitions));
+ if (d.fullRerunReason != null) {
+ o.addProperty("full_rerun_reason", d.fullRerunReason);
+ }
+ return o;
+ }
+
+ private static JsonArray names(final List list) {
+ final JsonArray a = new JsonArray();
+ for (final String s : list) {
+ a.add(s);
+ }
+ return a;
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java
new file mode 100644
index 0000000000..0cfc375586
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java
@@ -0,0 +1,416 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
+
+import tlc2.module.Json;
+import tlc2.output.EC;
+import tlc2.output.IMessagePrinterRecorder;
+import tlc2.tool.TLCState;
+import tlc2.tool.TLCStateInfo;
+import tlc2.value.IValue;
+import util.UniqueString;
+
+/**
+ * Keeps what TLC reports, typed, instead of printing it.
+ *
+ * TLC's {@link tlc2.output.MP} hands every message to the registered
+ * recorder as its error code plus the objects the message was built from,
+ * before rendering them to text. Counterexample states arrive as
+ * {@link TLCStateInfo} objects, so a trace can be handed on as values rather
+ * than re-parsed out of the printed form. Everything else is kept as the
+ * code and its parameters' string forms, for the artifact.
+ */
+public final class Recorder implements IMessagePrinterRecorder {
+
+ /** A counterexample being assembled from TLC_STATE_PRINT2 messages. */
+ public static final class Trace {
+ /** Why TLC printed a trace: the code that opened it. */
+ public int code;
+ /** The invariant or property named by that code, when it names one. */
+ public String property;
+ public final List states = new ArrayList<>();
+ /** True once TLC said the trace ends in stuttering. */
+ public boolean stuttering;
+ /** The ordinal the lasso loops back to, or null. */
+ public Integer lassoTo;
+ /**
+ * For a violation in an initial state: the state as TLC printed it
+ * into the report. TLC prints no trace for such a violation, so the
+ * one-state trace is completed from this by {@link Recorder#completeInitial}.
+ */
+ public String initialText;
+ }
+
+ /** Whether a code reports a violation in an initial state, which TLC prints no trace for. */
+ private static boolean isInitialViolation(final int code) {
+ return code == EC.TLC_INVARIANT_VIOLATED_INITIAL || code == EC.TLC_PROPERTY_VIOLATED_INITIAL;
+ }
+
+ /**
+ * Whether a code opens a violation report, whose behaviour TLC prints
+ * once. An evaluation failure's behaviour may be printed twice (the rerun
+ * that rebuilds its call stack).
+ */
+ private static boolean isViolationReport(final int code) {
+ switch (code) {
+ case EC.TLC_INVARIANT_VIOLATED_INITIAL:
+ case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR:
+ case EC.TLC_INVARIANT_VIOLATED_LEVEL:
+ case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR:
+ case EC.TLC_TEMPORAL_PROPERTY_VIOLATED:
+ case EC.TLC_PROPERTY_VIOLATED_INITIAL:
+ case EC.TLC_DEADLOCK_REACHED:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private final List messages = new ArrayList<>();
+ private Trace trace;
+ private Trace finishedTrace;
+ /** Every counterexample TLC printed, in order (several under continuation). */
+ private final List traces = new ArrayList<>();
+ /** How often each property was reported violated. */
+ private final java.util.LinkedHashMap violationCounts = new java.util.LinkedHashMap<>();
+ /** Reports past the per-property trace cap, per property: counted, not kept as messages. */
+ private final java.util.LinkedHashMap untraced = new java.util.LinkedHashMap<>();
+ /** Invariants whose evaluation failed, to the first failure's message. */
+ private final java.util.LinkedHashMap evaluationFailures = new java.util.LinkedHashMap<>();
+ private JsonObject finalStats;
+ private int outcome = EC.NO_ERROR;
+ private String outcomeProperty;
+
+ @Override
+ public synchronized void record(final int code, final Object... objects) {
+ if ((code == EC.TLC_INVARIANT_VIOLATED_BEHAVIOR || code == EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR)
+ && objects != null && objects.length > 0) {
+ final String property = String.valueOf(objects[0]);
+ if (tlc2.TLCGlobals.continuationTraceCapped(property)) {
+ // Past the per-property trace cap under continuation: no trace
+ // follows, so the report is counted, not kept. A run whose
+ // invariant fails on most states would otherwise keep a
+ // message and an empty trace per state.
+ if (outcome == EC.NO_ERROR) {
+ outcome = code;
+ outcomeProperty = property;
+ }
+ violationCounts.merge(property, 1, Integer::sum);
+ untraced.merge(property, 1L, Long::sum);
+ trace = null;
+ return;
+ }
+ }
+ final JsonObject message = new JsonObject();
+ message.addProperty("code", code);
+ final JsonArray params = new JsonArray();
+ if (objects != null) {
+ for (final Object o : objects) {
+ if (o instanceof TLCStateInfo) {
+ // A stuttering tail (TLC_STATE_PRINT3) comes with a null state.
+ final TLCStateInfo info = (TLCStateInfo) o;
+ params.add(info.state == null ? JsonParser.parseString("null") : stateInfo(info));
+ } else if (o instanceof TLCState) {
+ params.add(state((TLCState) o));
+ } else if (o instanceof Object[]) {
+ final JsonArray inner = new JsonArray();
+ for (final Object i : (Object[]) o) {
+ inner.add(String.valueOf(i));
+ }
+ params.add(inner);
+ } else {
+ params.add(String.valueOf(o));
+ }
+ }
+ }
+ message.add("params", params);
+ messages.add(message);
+
+ switch (code) {
+ case EC.TLC_INVARIANT_VIOLATED_INITIAL:
+ case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR:
+ case EC.TLC_INVARIANT_VIOLATED_LEVEL:
+ case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR:
+ case EC.TLC_TEMPORAL_PROPERTY_VIOLATED:
+ case EC.TLC_PROPERTY_VIOLATED_INITIAL:
+ case EC.TLC_DEADLOCK_REACHED:
+ case EC.TLC_INVARIANT_EVALUATION_FAILED:
+ final String property = objects != null && objects.length > 0 && !(objects[0] instanceof TLCState)
+ && !(objects[0] instanceof TLCStateInfo)
+ ? String.valueOf(objects[0])
+ : null;
+ if (outcome == EC.NO_ERROR) {
+ outcome = code;
+ outcomeProperty = property;
+ }
+ if (code == EC.TLC_INVARIANT_EVALUATION_FAILED) {
+ // The invariant did not evaluate: that is no verdict on it, so
+ // it is kept apart from the violations.
+ evaluationFailures.putIfAbsent(property == null ? "" : property,
+ objects != null && objects.length > 1 ? String.valueOf(objects[1]) : "");
+ } else {
+ violationCounts.merge(property == null ? "" : property, 1, Integer::sum);
+ }
+ trace = new Trace();
+ trace.code = code;
+ trace.property = property;
+ if (isInitialViolation(code) && objects != null && objects.length > 1) {
+ trace.initialText = String.valueOf(objects[1]);
+ }
+ traces.add(trace);
+ break;
+ case EC.TLC_BEHAVIOR_UP_TO_THIS_POINT:
+ // The behaviour is printed from its first state on. When TLC prints
+ // it again for the same report (an evaluation error re-run to
+ // rebuild its call stack), the reprint replaces what came before.
+ // A violation's trace is printed once: a behaviour printed after it
+ // belongs to a later report that opened with no code of its own (a
+ // next-state evaluation error under continuation), so it starts a
+ // trace of its own rather than overwriting the violation's.
+ if (trace != null && !trace.states.isEmpty() && isViolationReport(trace.code)) {
+ trace = new Trace();
+ trace.code = code;
+ } else if (trace != null && !trace.states.isEmpty()) {
+ trace.states.clear();
+ trace.stuttering = false;
+ trace.lassoTo = null;
+ }
+ break;
+ case EC.TLC_STATE_PRINT1:
+ // A single state (an initial-state violation): no ordinal.
+ if (trace == null) {
+ trace = new Trace();
+ trace.code = code;
+ }
+ // MP.printState wraps the state in a TLCStateInfo; a bare TLCState
+ // is kept too, for any caller that records it directly.
+ final JsonObject single = objects == null || objects.length == 0 ? null
+ : objects[0] instanceof TLCStateInfo && ((TLCStateInfo) objects[0]).state != null
+ ? stateInfo((TLCStateInfo) objects[0])
+ : objects[0] instanceof TLCState ? state((TLCState) objects[0]) : null;
+ if (single != null) {
+ single.addProperty("ordinal", trace.states.size() + 1);
+ trace.states.add(single);
+ }
+ finishedTrace = trace;
+ break;
+ case EC.TLC_STATE_PRINT2:
+ if (trace == null) {
+ trace = new Trace();
+ trace.code = code;
+ }
+ if (objects != null && objects.length >= 2 && objects[0] instanceof TLCStateInfo
+ && ((TLCStateInfo) objects[0]).state != null) {
+ final JsonObject s = stateInfo((TLCStateInfo) objects[0]);
+ s.addProperty("ordinal", objects[1] instanceof Integer ? (Integer) objects[1] : trace.states.size() + 1);
+ trace.states.add(s);
+ }
+ finishedTrace = trace;
+ break;
+ case EC.TLC_STATE_PRINT3:
+ if (trace != null) {
+ trace.stuttering = true;
+ finishedTrace = trace;
+ }
+ break;
+ case EC.TLC_BACK_TO_STATE:
+ if (trace != null && objects != null && objects.length >= 1) {
+ try {
+ trace.lassoTo = objects[0] instanceof TLCStateInfo && objects.length >= 2
+ ? (Integer) objects[1]
+ : Integer.parseInt(String.valueOf(objects[0]));
+ } catch (final NumberFormatException e) {
+ trace.lassoTo = null;
+ }
+ finishedTrace = trace;
+ }
+ break;
+ case EC.TLC_STATS:
+ if (objects != null && objects.length >= 3) {
+ finalStats = new JsonObject();
+ finalStats.addProperty("generated", parseLong(objects[0]));
+ finalStats.addProperty("distinct", parseLong(objects[1]));
+ finalStats.addProperty("queue", parseLong(objects[2]));
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ private static Long parseLong(final Object o) {
+ try {
+ return Long.parseLong(String.valueOf(o).replace(",", ""));
+ } catch (final NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /** Forget every counterexample and verdict, for a run that starts over. */
+ public synchronized void reset() {
+ messages.clear();
+ trace = null;
+ finishedTrace = null;
+ traces.clear();
+ violationCounts.clear();
+ untraced.clear();
+ evaluationFailures.clear();
+ finalStats = null;
+ outcome = EC.NO_ERROR;
+ outcomeProperty = null;
+ }
+
+ /** The messages recorded so far, oldest first, and forget them. */
+ public synchronized JsonArray drainMessages() {
+ final JsonArray out = new JsonArray();
+ for (final JsonObject m : messages) {
+ out.add(m);
+ }
+ messages.clear();
+ return out;
+ }
+
+ /**
+ * Give every violation in an initial state its one-state trace. TLC prints
+ * the state only into the report's text, so {@code resolve} maps that text
+ * to the state as a typed value ({@link #state}), or returns null, and the
+ * state is then kept as TLC printed it, under {@code "tla"}. Such a trace
+ * becomes the last complete counterexample when no other has been printed.
+ */
+ public synchronized void completeInitial(final java.util.function.Function resolve) {
+ for (final Trace t : traces) {
+ if (!isInitialViolation(t.code) || !t.states.isEmpty() || t.initialText == null) {
+ continue;
+ }
+ JsonObject s = resolve == null ? null : resolve.apply(t.initialText);
+ if (s == null) {
+ s = new JsonObject();
+ s.addProperty("tla", t.initialText);
+ }
+ s.addProperty("action", "");
+ s.addProperty("ordinal", 1);
+ t.states.add(s);
+ if (finishedTrace == null) {
+ finishedTrace = t;
+ }
+ }
+ }
+
+ /** The last complete counterexample, or null. */
+ public synchronized Trace trace() {
+ return finishedTrace;
+ }
+
+ /** Every counterexample TLC printed so far, oldest first. */
+ public synchronized List traces() {
+ return new ArrayList<>(traces);
+ }
+
+ /** Property name to the number of times TLC reported it violated. */
+ public synchronized Map violationCounts() {
+ return new java.util.LinkedHashMap<>(violationCounts);
+ }
+
+ /** Invariant name to the message of its first failed evaluation. */
+ public synchronized Map evaluationFailures() {
+ return new java.util.LinkedHashMap<>(evaluationFailures);
+ }
+
+ /** Property name to the reports past its trace cap, which carry no message or trace. */
+ public synchronized JsonObject untracedReports() {
+ final JsonObject out = new JsonObject();
+ untraced.forEach(out::addProperty);
+ return out;
+ }
+
+ /** The `TLC_STATS` line TLC prints at the end of a run, or null. */
+ public synchronized JsonObject finalStats() {
+ return finalStats;
+ }
+
+ /** The first violation code, or {@link EC#NO_ERROR}. */
+ public synchronized int outcome() {
+ return outcome;
+ }
+
+ public synchronized String outcomeProperty() {
+ return outcomeProperty;
+ }
+
+ /** Render a trace state with its action and the value of every variable. */
+ public static JsonObject stateInfo(final TLCStateInfo info) {
+ final JsonObject s = state(info.state);
+ s.addProperty("action", String.valueOf(info.info));
+ if (info.fp != null) {
+ s.addProperty("fp", info.fp);
+ }
+ return s;
+ }
+
+ /**
+ * A state as `{"fp": ..., "vars": {name: value}}`. Values go through the
+ * Json module's encoder (records, tuples and sequences become objects and
+ * arrays); a value it cannot encode keeps its TLA+ printed form under
+ * `"tla"`.
+ */
+ public static JsonObject state(final TLCState state) {
+ final JsonObject s = new JsonObject();
+ try {
+ s.addProperty("fp", state.fingerPrint());
+ } catch (final RuntimeException e) {
+ // A state with unassigned variables has no fingerprint.
+ }
+ final JsonObject vars = new JsonObject();
+ final Map vals = state.getVals();
+ if (vals != null) {
+ for (final Map.Entry e : vals.entrySet()) {
+ vars.add(e.getKey().toString(), value(e.getValue()));
+ }
+ }
+ s.add("vars", vars);
+ return s;
+ }
+
+ public static JsonElement value(final IValue value) {
+ if (value == null) {
+ return JsonParser.parseString("null");
+ }
+ try {
+ return JsonParser.parseString(Json.toJson(value).val.toString());
+ } catch (final Exception e) {
+ final JsonObject o = new JsonObject();
+ o.add("tla", new JsonPrimitive(value.toString()));
+ return o;
+ }
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java
new file mode 100644
index 0000000000..3d2f364f97
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java
@@ -0,0 +1,1915 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import tlc2.TLCGlobals;
+import tlc2.output.EC;
+import tlc2.output.MP;
+import tlc2.tool.Action;
+import tlc2.tool.ModelChecker;
+import tlc2.tool.TLCState;
+import tlc2.tool.fp.FPSetConfiguration;
+import tlc2.tool.fp.FPSetFactory;
+import tlc2.util.FP64;
+import tlc2.tool.impl.FastTool;
+import tlc2.tool.impl.Tool;
+import tlc2.tool.EvalControl;
+import tlc2.tool.StateVec;
+import tlc2.tool.coverage.CostModel;
+import tlc2.util.Context;
+import tla2sany.semantic.OpDefNode;
+import tlc2.debug.TLCDebuggerExpression;
+import tlc2.value.IValue;
+import tlc2.value.impl.BoolValue;
+import util.FileUtil;
+import util.SimpleFilenameToStream;
+import util.ToolIO;
+
+/**
+ * TLC as a resident service: one JVM, one parsed spec, one state graph, kept
+ * alive between requests and driven over JSON lines.
+ *
+ *
+ * Each request is one JSON object on a line of standard input; each reply is
+ * one JSON object on a line of standard output. Everything TLC would print
+ * goes to standard error instead, so the protocol owns stdout. Requests:
+ *
+ *
+ * - {@code open}: parse {@code spec} (a .tla path) with {@code config} (a
+ * .cfg path, default the spec's), build the checker, and answer with the
+ * catalogue: actions, invariants, implied actions, temporal properties and
+ * variables. Nothing is explored yet. {@code workers} sets the thread count,
+ * {@code metadir} where TLC keeps its state files, {@code deadlock} (default
+ * true) whether deadlocks are violations, {@code store} (default true)
+ * whether to keep the {@link GraphStore} the store queries and refresh
+ * need (it costs heap per state and edge, and time: every edge is
+ * recorded under one lock).
+ * - {@code check}: explore, resuming where the last check stopped, until
+ * the reachable graph is exhausted, a violation is found, or the budget runs
+ * out: {@code budget_ms} of wall time, {@code budget_states} distinct
+ * states. The reply carries {@code finished}, the {@code verdict}, the
+ * counterexample {@code trace} when there is one, the statistics, and the
+ * typed messages TLC produced during the call.
+ * - {@code stats}: the counters, without exploring.
+ * - {@code close}: stop, and exit the process.
+ *
+ *
+ *
+ * TLC is built around static state ({@link TLCGlobals}, {@link MP}), so one
+ * process serves one spec: a second {@code open} is refused.
+ */
+public final class Resident {
+
+ private final Recorder recorder = new Recorder();
+ private Tool tool;
+ /**
+ * The tool the checker (or simulator) ran: its cost model holds the
+ * coverage counters. A refresh replaces {@link #tool} with one no checker
+ * ran, so coverage is read from this one.
+ */
+ private Tool runTool;
+ private GraphStore store;
+ private ModelChecker checker;
+ /** True after an incremental refresh: the store is current, the checker is not. */
+ private boolean refreshed;
+ /** Whether the last adopted refresh explored its whole graph; meaningful once {@link #refreshed}. */
+ private boolean refreshComplete;
+ /**
+ * The last fully explored graph and the tool it was explored under: what
+ * the next refresh replays from. Set by the first refresh from a finished
+ * run and advanced only by a refresh that explored its whole graph, so a
+ * refresh cut short (by its budget or a first violation) leaves the next
+ * one something sound to copy edges from.
+ */
+ private Tool baseTool;
+ private GraphStore baseStore;
+ /** False when opened with {@code store: false}: no store, no store queries. */
+ private boolean storing = true;
+ /**
+ * Set when a refresh that did not replace the tool left TLC's static
+ * tables unfit for the old spec: why a restart is needed. Every request
+ * that evaluates against the spec is refused from then on.
+ */
+ private String restartRequired;
+ /** The model config's text when the store was built, to detect edits. */
+ private String configText;
+ private String specDir;
+ private String mainFile;
+ private String configName;
+ private int workers;
+ private boolean checkDeadlock;
+ /** Set when opened in simulate mode: random behaviours instead of a graph. */
+ private tlc2.tool.Simulator simulator;
+ private Thread simulatorThread;
+ private volatile Integer simulatorResult;
+ private volatile Throwable simulatorFailure;
+ /** A budget stop ends the simulator for good; later calls report it. */
+ private boolean simulationStoppedByBudget;
+ private long simulationMs;
+ private Thread checkerThread;
+ /**
+ * Whether the run explored past violations when it last explored: what
+ * decides if a run that ended on a violation still covered the graph.
+ * {@link TLCGlobals#continuation} is not it, since a later request may
+ * set that after the run ended.
+ */
+ private boolean runContinuation;
+ /**
+ * Whether any part of the run explored past violations. Once it has, TLC
+ * checked no further invariant on the states that violated one, so an
+ * invariant it never reported may still fail there, even if a later call
+ * resumed the run without continuation.
+ */
+ private boolean everContinued;
+ private String metadir;
+ /** The metadir the session's own run was given at open; a refresh moves {@link #metadir} on. */
+ private String runMetadir;
+ private volatile Integer resultCode;
+ private volatile Throwable checkerFailure;
+ private long openedAt;
+ private long exploringMs;
+
+ public static void main(final String[] args) throws IOException {
+ // The protocol owns stdout; TLC's own printing goes to stderr.
+ final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.out), true,
+ StandardCharsets.UTF_8.name());
+ final PrintStream sink = new PrintStream(new FileOutputStream(FileDescriptor.err), true,
+ StandardCharsets.UTF_8.name());
+ System.setOut(sink);
+ ToolIO.out = sink;
+ ToolIO.err = sink;
+ ToolIO.setMode(ToolIO.TOOL);
+
+ final Resident resident = install();
+
+ final JsonObject ready = new JsonObject();
+ ready.addProperty("event", "ready");
+ ready.addProperty("tlc_version", TLCGlobals.Version.number());
+ ready.addProperty("tlc_revision", TLCGlobals.Version.revisionOrDev());
+ protocol.println(ready);
+
+ final BufferedReader in = new BufferedReader(
+ new InputStreamReader(System.in, StandardCharsets.UTF_8));
+ String line;
+ while ((line = in.readLine()) != null) {
+ if (line.isBlank()) {
+ continue;
+ }
+ JsonObject request;
+ try {
+ request = JsonParser.parseString(line).getAsJsonObject();
+ } catch (final RuntimeException e) {
+ protocol.println(error(null, "invalid_request", "not a JSON object: " + e.getMessage()));
+ continue;
+ }
+ final JsonElement id = request.get("id");
+ final String command = string(request, "command", "");
+ JsonObject reply;
+ try {
+ reply = resident.dispatch(command, request);
+ } catch (final Throwable t) {
+ reply = error(id, "internal", t.toString());
+ reply.add("messages", resident.recorder.drainMessages());
+ }
+ if (id != null) {
+ reply.add("id", id);
+ }
+ reply.addProperty("command", command);
+ protocol.println(reply);
+ if ("close".equals(command)) {
+ break;
+ }
+ }
+ resident.shutdown();
+ System.exit(0);
+ }
+
+ /** A resident whose recorder receives TLC's messages; TLC's statics allow one per process. */
+ static Resident install() {
+ final Resident resident = new Resident();
+ MP.setRecorder(resident.recorder);
+ return resident;
+ }
+
+ /** Serve one request, as a line of standard input would be served (the tests' entry). */
+ JsonObject serve(final JsonObject request) throws Exception {
+ return dispatch(string(request, "command", ""), request);
+ }
+
+ private JsonObject dispatch(final String command, final JsonObject request) throws Exception {
+ switch (command) {
+ case "check":
+ case "trace":
+ case "neighbours":
+ case "eval":
+ case "screen":
+ case "refresh": {
+ final JsonObject refusal = restartRefusal();
+ if (refusal != null) {
+ return refusal;
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ switch (command) {
+ case "open":
+ return open(request);
+ case "check":
+ return check(request);
+ case "stats": {
+ final JsonObject reply = ok();
+ reply.add("stats", stats());
+ markStale(reply);
+ return reply;
+ }
+ case "trace":
+ return trace(request);
+ case "neighbours":
+ return neighbours(request);
+ case "eval":
+ return eval(request);
+ case "screen":
+ return screen(request);
+ case "guard_profile":
+ return guardProfile();
+ case "refresh":
+ return refresh(request);
+ case "simulate":
+ return simulate(request);
+ case "coverage": {
+ if (runTool == null) {
+ return notOpen();
+ }
+ final JsonObject reply = ok();
+ reply.add("coverage", tlc2.tool.coverage.CoverageWalk.walk(runTool));
+ if (refreshed) {
+ reply.addProperty("stale", true);
+ reply.addProperty("stale_reason",
+ "the store was refreshed incrementally; coverage is the last full run's, over the spec as it was then");
+ }
+ return reply;
+ }
+ case "registers": {
+ if (checker == null) {
+ return notOpen();
+ }
+ final JsonObject reply = ok();
+ reply.add("stats", stats());
+ reply.add("registers", registers());
+ markStale(reply);
+ return reply;
+ }
+ case "store": {
+ final JsonObject reply = ok();
+ reply.add("store", storeInfo());
+ return reply;
+ }
+ case "close":
+ return ok();
+ default:
+ return error(null, "unknown_command", "no such request: " + command);
+ }
+ }
+
+ // ─── open ───────────────────────────────────────────────────────────
+
+ private JsonObject open(final JsonObject request) {
+ if (tool != null) {
+ return error(null, "already_open", "this process already serves " + tool.getRootFile()
+ + "; close it and start another");
+ }
+ final String spec = string(request, "spec", null);
+ if (spec == null) {
+ return error(null, "invalid_request", "open needs `spec`, the path of a .tla file");
+ }
+ final File specFile = new File(spec).getAbsoluteFile();
+ if (!specFile.isFile()) {
+ return error(null, "no_such_file", "no spec at " + specFile);
+ }
+ final String specDir = specFile.getParent() + FileUtil.separator;
+ final String mainFile = specFile.getName().replaceFirst("\\.tla$", "");
+ String config = string(request, "config", null);
+ if (config == null) {
+ config = mainFile;
+ } else {
+ final File c = new File(config).getAbsoluteFile();
+ config = c.getName().replaceFirst("\\.cfg$", "");
+ if (!c.getParentFile().equals(specFile.getParentFile())) {
+ return error(null, "invalid_request",
+ "config must live next to the spec (TLC resolves it in the spec's directory)");
+ }
+ }
+ final int workers = request.has("workers") ? request.get("workers").getAsInt()
+ : Runtime.getRuntime().availableProcessors();
+ final boolean deadlock = !request.has("deadlock") || request.get("deadlock").getAsBoolean();
+ final boolean coverage = !request.has("coverage") || request.get("coverage").getAsBoolean();
+ final int fpIndex = request.has("fp_index") ? request.get("fp_index").getAsInt() : 0;
+ storing = !request.has("store") || request.get("store").getAsBoolean();
+ if (request.has("metadir")) {
+ TLCGlobals.metaDir = new File(request.get("metadir").getAsString()).getAbsolutePath()
+ + FileUtil.separator;
+ }
+
+ final String mode = string(request, "mode", "check");
+ if (mode.equals("simulate")) {
+ return openSimulate(request, specFile, specDir, mainFile, config, workers, deadlock);
+ }
+ openedAt = System.currentTimeMillis();
+ recorder.drainMessages();
+ this.specDir = specDir;
+ this.mainFile = mainFile;
+ this.configName = config;
+ this.workers = workers;
+ try {
+ TLCGlobals.setNumWorkers(workers);
+ // Coverage is read into `static final` fields when ModelChecker and
+ // Worker load, so it is decided here, before either class is used.
+ // The interval only paces TLC's own printing, which goes to stderr.
+ TLCGlobals.coverageInterval = coverage ? Integer.MAX_VALUE : -1;
+ FP64.Init(fpIndex);
+ metadir = FileUtil.makeMetaDir(new Date(openedAt), specDir, null);
+ runMetadir = metadir;
+ tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.MC,
+ new HashMap<>());
+ runTool = tool;
+ final boolean checkDeadlock = deadlock && tool.getModelConfig().getCheckDeadlock();
+ this.checkDeadlock = checkDeadlock;
+ configText = readConfig();
+ store = storing ? new GraphStore(metadir) : null;
+ checker = new ModelChecker(tool, metadir,
+ storing ? store : new tlc2.util.NoopStateWriter(), checkDeadlock, null,
+ FPSetFactory.getFPSetInitialized(new FPSetConfiguration(), metadir, specFile.getName()),
+ openedAt);
+ TLCGlobals.mainChecker = checker;
+ } catch (final Throwable t) {
+ tool = null;
+ runTool = null;
+ checker = null;
+ final JsonObject reply = error(null, "open_failed", t.toString());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ final JsonObject reply = ok();
+ reply.addProperty("spec", specFile.getPath());
+ reply.addProperty("root_module", tool.getRootName());
+ reply.addProperty("metadir", metadir);
+ reply.addProperty("workers", workers);
+ reply.addProperty("check_deadlock", deadlock && tool.getModelConfig().getCheckDeadlock());
+ reply.addProperty("coverage", coverage);
+ reply.addProperty("fp_index", fpIndex);
+ reply.addProperty("store", storing);
+ reply.add("catalogue", catalogue());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ /**
+ * Open for random simulation: a Tool in simulation mode and a
+ * {@link tlc2.tool.Simulator} with {@code depth} (default 100 steps per
+ * behaviour), {@code traces} (default unbounded: run until stopped or a
+ * violation) and {@code seed} (default random, reported).
+ */
+ private JsonObject openSimulate(final JsonObject request, final File specFile, final String specDir,
+ final String mainFile, final String config, final int workers, final boolean deadlock) {
+ openedAt = System.currentTimeMillis();
+ recorder.drainMessages();
+ this.specDir = specDir;
+ this.mainFile = mainFile;
+ this.configName = config;
+ this.workers = workers;
+ final int depth = request.has("depth") ? request.get("depth").getAsInt() : 100;
+ final long traces = request.has("traces") ? request.get("traces").getAsLong() : Long.MAX_VALUE;
+ final tlc2.util.RandomGenerator rng = new tlc2.util.RandomGenerator();
+ final long seed = request.has("seed") ? request.get("seed").getAsLong() : rng.nextLong();
+ rng.setSeed(seed);
+ try {
+ TLCGlobals.setNumWorkers(workers);
+ TLCGlobals.coverageInterval = -1;
+ FP64.Init(0);
+ tlc2.value.RandomEnumerableValues.setSeed(seed);
+ metadir = FileUtil.makeMetaDir(new Date(openedAt), specDir, null);
+ runMetadir = metadir;
+ tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.Simulation,
+ new HashMap<>());
+ runTool = tool;
+ // A non-null traceActions sizes the per-worker action-pair counters;
+ // anything but BASIC/FULL keeps TLC from writing its dot files.
+ simulator = new tlc2.tool.Simulator(tool, metadir, null, deadlock, depth, traces, "STATS", rng, seed,
+ new SimpleFilenameToStream(specDir), workers);
+ TLCGlobals.simulator = simulator;
+ } catch (final Throwable t) {
+ tool = null;
+ runTool = null;
+ simulator = null;
+ final JsonObject reply = error(null, "open_failed", t.toString());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+ final JsonObject reply = ok();
+ reply.addProperty("mode", "simulate");
+ reply.addProperty("spec", specFile.getPath());
+ reply.addProperty("root_module", tool.getRootName());
+ reply.addProperty("metadir", metadir);
+ reply.addProperty("workers", workers);
+ reply.addProperty("depth", depth);
+ reply.addProperty("traces", traces == Long.MAX_VALUE ? null : traces);
+ reply.addProperty("seed", seed);
+ reply.addProperty("extended_statistics", tlc2.tool.Simulator.EXTENDED_STATISTICS);
+ reply.add("catalogue", catalogue());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ /**
+ * Run random behaviours until {@code traces} of them, a violation, or
+ * {@code budget_ms}; then report what the recorder saw, the simulator's
+ * statistics and the action-pair follow matrix. A run that found nothing
+ * proves nothing: the verdict says so.
+ */
+ private JsonObject simulate(final JsonObject request) throws InterruptedException {
+ if (simulator == null) {
+ return error(null, "not_simulating", "open with mode simulate first");
+ }
+ final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : 60_000L;
+ final long started = System.currentTimeMillis();
+ if (simulationStoppedByBudget) {
+ // Simulator.stop() is final: there is nothing to resume, so the
+ // stopped run is reported again rather than as a completed one.
+ final JsonObject reply = simulationReply(true);
+ reply.addProperty("resumable", false);
+ return reply;
+ }
+ if (simulatorThread == null) {
+ simulatorThread = new Thread(() -> {
+ try {
+ simulatorResult = simulator.simulate();
+ } catch (final Throwable t) {
+ simulatorFailure = t;
+ }
+ }, "tlc-resident-simulator");
+ simulatorThread.setDaemon(true);
+ simulatorThread.start();
+ }
+ boolean stopped = false;
+ while (simulatorThread.isAlive()) {
+ if (System.currentTimeMillis() - started >= budgetMs) {
+ simulator.stop();
+ stopped = true;
+ simulationStoppedByBudget = true;
+ simulatorThread.join(10_000);
+ break;
+ }
+ simulatorThread.join(20);
+ }
+ simulationMs += System.currentTimeMillis() - started;
+ return simulationReply(stopped);
+ }
+
+ private JsonObject simulationReply(final boolean stopped) {
+ final JsonObject reply = ok();
+ final boolean finished = !simulatorThread.isAlive();
+ reply.addProperty("finished", finished);
+ reply.addProperty("stopped_by_budget", stopped);
+ final int outcome = recorder.outcome();
+ final Integer code = simulatorResult;
+ if (finished && code != null) {
+ reply.addProperty("result_code", code);
+ }
+ if (simulatorFailure != null) {
+ reply.addProperty("verdict", "error");
+ reply.addProperty("error", simulatorFailure.toString());
+ } else if (outcome != EC.NO_ERROR) {
+ reply.addProperty("verdict", verdict(EC.GENERAL, outcome));
+ } else if (finished && !simulationStoppedByBudget && code != null && code != EC.NO_ERROR) {
+ // The simulator ended on an error no violation code reports (the
+ // next-state relation failing to evaluate): an error, not a clean run.
+ reply.addProperty("verdict", "error");
+ } else {
+ reply.addProperty("verdict", "no_violation_found");
+ }
+ final String property = recorder.outcomeProperty();
+ if (property != null) {
+ reply.addProperty("violated", property);
+ }
+ // No store in simulation: an initial state's violation keeps TLC's text.
+ recorder.completeInitial(null);
+ // The last complete behaviour TLC printed, as `check` reports it:
+ // an evaluation error's behaviour opens no violation trace.
+ final Recorder.Trace trace = recorder.trace();
+ if (trace != null) {
+ reply.add("trace", traceJson(trace));
+ }
+ final JsonArray all = new JsonArray();
+ for (final Recorder.Trace t : recorder.traces()) {
+ all.add(traceJson(t));
+ }
+ reply.add("traces", all);
+ try {
+ reply.add("statistics", Recorder.value(simulator.getStatistics(null)));
+ } catch (final Throwable t) {
+ reply.addProperty("statistics_error", t.toString());
+ }
+ try {
+ reply.add("action_flow", simulator.actionFlowAsJson());
+ } catch (final Throwable t) {
+ reply.addProperty("action_flow_error", t.toString());
+ }
+ reply.addProperty("simulation_ms", simulationMs);
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ private JsonObject catalogue() {
+ final JsonObject c = new JsonObject();
+ final JsonArray actions = new JsonArray();
+ for (final Action a : tool.getActions()) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("id", a.getId());
+ o.addProperty("name", a.getNameOfDefault());
+ o.addProperty("location", a.getLocation());
+ o.addProperty("internal", a.isInternal());
+ actions.add(o);
+ }
+ c.add("actions", actions);
+ c.add("invariants", names(tool.getInvNames()));
+ c.add("implied_actions", names(tool.getImpliedActNames()));
+ // State-level PROPERTY formulas: checked on the initial states only.
+ c.add("implied_inits", names(tool.getImpliedInitNames()));
+ final JsonArray temporals = new JsonArray();
+ for (final Action a : tool.getTemporals()) {
+ temporals.add(a.getNameOfDefault());
+ }
+ c.add("temporal_properties", temporals);
+ final JsonArray implied = new JsonArray();
+ for (final Action a : tool.getImpliedTemporals()) {
+ implied.add(a.getNameOfDefault());
+ }
+ c.add("implied_temporals", implied);
+ final JsonArray vars = new JsonArray();
+ if (TLCState.Empty != null) {
+ for (final String v : TLCState.Empty.getVarsAsStrings()) {
+ vars.add(v);
+ }
+ }
+ c.add("variables", vars);
+ c.addProperty("state_constraints", tool.getModelConstraints().length);
+ c.addProperty("action_constraints", tool.getActionConstraints().length);
+ c.addProperty("symmetry", tool.getSymmetryPerms() != null);
+ return c;
+ }
+
+ private static JsonArray names(final String[] names) {
+ final JsonArray out = new JsonArray();
+ if (names != null) {
+ for (final String n : names) {
+ out.add(n);
+ }
+ }
+ return out;
+ }
+
+ // ─── check ──────────────────────────────────────────────────────────
+
+ private JsonObject check(final JsonObject request) throws InterruptedException {
+ if (checker == null) {
+ return error(null, "not_open", "open a spec first");
+ }
+ if (refreshed) {
+ return error(null, "refreshed",
+ "this session was refreshed incrementally: its store is current and the store queries serve it, but TLC's own checker is not; open a new session for a full run");
+ }
+ final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : Long.MAX_VALUE;
+ final long budgetStates = request.has("budget_states") ? request.get("budget_states").getAsLong()
+ : Long.MAX_VALUE;
+ final long distinctAtStart = checker.getDistinctStatesGenerated();
+ final long started = System.currentTimeMillis();
+ // Traces per violated property under continuation (default 1: the
+ // first counterexample of each; later violations are counted only).
+ // Regenerating a trace re-runs the next-state relation from an initial
+ // state, which dominates a run whose invariant fails on most states.
+ TLCGlobals.continuationTraceLimit = request.has("traces_per_property")
+ ? request.get("traces_per_property").getAsInt()
+ : 1;
+ if (checkerThread == null) {
+ // The cap is per run: a resumed run keeps its count, so a later
+ // call does not print another trace for a property already traced.
+ TLCGlobals.resetContinuationTraces();
+ }
+
+ if (resultCode == null && checkerFailure == null) {
+ if (request.has("continue")) {
+ // Keep exploring past a violation, so one run reports every
+ // invariant's verdict. Process-global, as TLC's -continue is,
+ // and set only for a run that is about to explore: an ended
+ // run keeps the value it ran under.
+ TLCGlobals.continuation = request.get("continue").getAsBoolean();
+ }
+ runContinuation = TLCGlobals.continuation;
+ everContinued |= runContinuation;
+ if (checkerThread == null) {
+ checkerThread = new Thread(() -> {
+ try {
+ resultCode = checker.modelCheck();
+ } catch (final Throwable t) {
+ checkerFailure = t;
+ } finally {
+ // A run that ends before any worker starts (an
+ // initial state violates an invariant, init fails to
+ // evaluate) never finishes the queue, so a budget
+ // suspend waiting for the workers would wait forever.
+ // Finishing it wakes that wait; after a run that
+ // reached the workers it is already finished.
+ checker.theStateQueue.finishAll();
+ }
+ }, "tlc-resident-checker");
+ checkerThread.setDaemon(true);
+ checkerThread.start();
+ } else {
+ checker.resume();
+ }
+ // Wait for the run to end or the budget to run out. The queue's
+ // suspend blocks until every worker has parked, so on return the
+ // counters are quiescent. The initial states are generated before
+ // any worker runs and cannot be paused: a budget that runs out
+ // then takes effect once they are done (or the run has ended).
+ boolean suspended = false;
+ while (checkerThread.isAlive()) {
+ final long now = System.currentTimeMillis();
+ final boolean overTime = now - started >= budgetMs;
+ final boolean overStates = checker.getDistinctStatesGenerated() - distinctAtStart >= budgetStates;
+ if (overTime || overStates) {
+ checker.suspend();
+ suspended = true;
+ break;
+ }
+ checkerThread.join(20);
+ }
+ exploringMs += System.currentTimeMillis() - started;
+ if (!suspended) {
+ checkerThread.join();
+ }
+ }
+
+ final JsonObject reply = ok();
+ final boolean finished = !checkerThread.isAlive();
+ reply.addProperty("finished", finished);
+ reply.addProperty("budget_exhausted", !finished);
+ if (checkerFailure != null) {
+ reply.addProperty("verdict", "error");
+ reply.addProperty("error", checkerFailure.toString());
+ } else if (finished) {
+ reply.addProperty("result_code", resultCode);
+ String verdict = verdict(resultCode, recorder.outcome());
+ final Integer error = errorCode();
+ if (error != null) {
+ // An evaluation error cut some expansion short, whatever code
+ // the run ended on and whatever was violated before it.
+ reply.addProperty("error_code", error);
+ if (!"evaluation_failed".equals(verdict)) {
+ verdict = "error";
+ reply.addProperty("error",
+ "TLC hit an error (code " + error + ") that left states unexpanded; the messages say what");
+ }
+ }
+ reply.addProperty("verdict", verdict);
+ } else {
+ reply.addProperty("verdict", "unfinished");
+ }
+ final String property = recorder.outcomeProperty();
+ if (property != null) {
+ reply.addProperty("violated", property);
+ }
+ recorder.completeInitial(initialStates());
+ final Recorder.Trace trace = recorder.trace();
+ if (trace != null) {
+ reply.add("trace", traceJson(trace));
+ }
+ // Under the per-property trace cap a violation past the cap is
+ // reported (and counted in the verdicts) but carries no states;
+ // those entries are not listed as traces.
+ final JsonArray all = new JsonArray();
+ for (final Recorder.Trace t : recorder.traces()) {
+ if (!t.states.isEmpty()) {
+ all.add(traceJson(t));
+ }
+ }
+ reply.add("traces", all);
+ // Reports past the cap are counted here instead of listed as messages.
+ reply.add("untraced_reports", recorder.untracedReports());
+ reply.add("invariants", invariantVerdicts(finished));
+ reply.addProperty("continuation", runContinuation);
+ reply.add("stats", stats());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ /**
+ * Maps the text TLC printed an initial state as, in a report of a
+ * violation there, to that state from the store: every initial state is
+ * stored before TLC checks it, those a constraint excludes included. The
+ * text is matched against each stored initial state printed as TLC
+ * printed it (through the ALIAS, if any), so the match is TLC's own
+ * state. Null without a store; the recorder then keeps the text.
+ */
+ private java.util.function.Function initialStates() {
+ if (store == null) {
+ return null;
+ }
+ final Map byText = new HashMap<>();
+ return text -> {
+ if (byText.isEmpty()) {
+ final long[] in = store.initialFingerprints();
+ final long[] ex = store.excludedInitialFingerprints();
+ final long[] all = java.util.Arrays.copyOf(in, in.length + ex.length);
+ System.arraycopy(ex, 0, all, in.length, ex.length);
+ for (final long fp : all) {
+ final TLCState s = store.read(fp);
+ if (s == null) {
+ continue;
+ }
+ try {
+ byText.putIfAbsent(tool.evalAlias(s, s).toString(), s);
+ } catch (final RuntimeException e) {
+ // An ALIAS that does not evaluate here: that state keeps its text.
+ }
+ }
+ }
+ final TLCState s = byText.get(text);
+ return s == null ? null : Recorder.state(s);
+ };
+ }
+
+ private static JsonObject traceJson(final Recorder.Trace trace) {
+ final JsonObject t = new JsonObject();
+ t.addProperty("code", trace.code);
+ t.addProperty("property", trace.property);
+ t.addProperty("length", trace.states.size());
+ t.addProperty("stuttering", trace.stuttering);
+ if (trace.lassoTo != null) {
+ t.addProperty("lasso_to", trace.lassoTo);
+ }
+ final JsonArray states = new JsonArray();
+ for (final JsonObject s : trace.states) {
+ states.add(s);
+ }
+ t.add("states", states);
+ return t;
+ }
+
+ /**
+ * One verdict per configured invariant. A violated one carries the level
+ * and last action of the first counterexample TLC printed for it, and how
+ * many times it was reported (more than one only under continuation). One
+ * that was never reported is `no_violation_found` when the run finished
+ * without error and `not_evaluated` otherwise: a run that stopped at the
+ * first violation, or at its budget, evaluated it on some states only.
+ */
+ private JsonArray invariantVerdicts(final boolean finished) {
+ final JsonArray out = new JsonArray();
+ final Map counts = recorder.violationCounts();
+ final Map failures = recorder.evaluationFailures();
+ final List traces = recorder.traces();
+ final boolean exhausted = finished && explorationComplete();
+ // Under continuation TLC reports the first invariant a state violates
+ // and checks no further invariant on that state. An invariant never
+ // reported may then fail only on states an earlier one failed on, so
+ // its silence is no verdict: it is evaluated over the stored graph.
+ final java.util.Set unreported = new java.util.LinkedHashSet<>();
+ boolean anyViolated = false;
+ for (final String name : tool.getInvNames()) {
+ if (counts.containsKey(name)) {
+ anyViolated = true;
+ } else if (!failures.containsKey(name)) {
+ unreported.add(name);
+ }
+ }
+ // Any part of the run under continuation, not only its last call: a
+ // run resumed without it keeps the states it already skipped past.
+ final boolean skipped = exhausted && everContinued && anyViolated && !unreported.isEmpty();
+ final Map swept = new HashMap<>();
+ if (skipped && store != null) {
+ for (final Incremental.Sweep sw : Incremental.sweep(tool, store, unreported)) {
+ if (unreported.contains(sw.invariant)) {
+ swept.put(sw.invariant, sw);
+ }
+ }
+ }
+ for (final String name : tool.getInvNames()) {
+ final JsonObject v = new JsonObject();
+ v.addProperty("name", name);
+ final Integer n = counts.get(name);
+ final String failure = failures.get(name);
+ if (n != null || failure != null) {
+ if (n != null) {
+ v.addProperty("verdict", "violated");
+ v.addProperty("reports", n);
+ } else {
+ // It did not evaluate on some state: no verdict either way.
+ v.addProperty("verdict", "not_evaluable");
+ }
+ if (failure != null) {
+ v.addProperty("error", failure);
+ }
+ // The first trace of the verdict's own kind.
+ final boolean violation = n != null;
+ for (final Recorder.Trace t : traces) {
+ if (name.equals(t.property)
+ && violation == (t.code != EC.TLC_INVARIANT_EVALUATION_FAILED)) {
+ v.addProperty("level", t.states.size());
+ if (!t.states.isEmpty()) {
+ v.add("action", t.states.get(t.states.size() - 1).get("action"));
+ }
+ break;
+ }
+ }
+ } else if (skipped) {
+ final Incremental.Sweep sw = swept.get(name);
+ if (sw == null) {
+ v.addProperty("verdict", "not_evaluated");
+ v.addProperty("reason",
+ "under continuation TLC checks no further invariant on a state that violates one, and without a store the skipped states cannot be revisited");
+ } else if (sw.error != null) {
+ v.addProperty("verdict", "not_evaluable");
+ v.addProperty("error", sw.error);
+ } else if (sw.violations > 0) {
+ // Found over the store, not reported by TLC: no trace.
+ v.addProperty("verdict", "violated");
+ v.addProperty("reports", sw.violations);
+ v.addProperty("level", sw.firstLevel);
+ v.addProperty("fp", sw.firstFp);
+ v.addProperty("source", "store");
+ } else {
+ v.addProperty("verdict", "no_violation_found");
+ }
+ } else {
+ v.addProperty("verdict", exhausted ? "no_violation_found" : "not_evaluated");
+ }
+ out.add(v);
+ }
+ return out;
+ }
+
+ private static String verdict(final int code, final int outcome) {
+ switch (outcome) {
+ case EC.TLC_INVARIANT_VIOLATED_INITIAL:
+ case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR:
+ case EC.TLC_INVARIANT_VIOLATED_LEVEL:
+ return "invariant_violated";
+ case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR:
+ return "action_property_violated";
+ case EC.TLC_TEMPORAL_PROPERTY_VIOLATED:
+ return "temporal_property_violated";
+ case EC.TLC_PROPERTY_VIOLATED_INITIAL:
+ // A PROPERTY that is false in an initial state.
+ return "property_violated";
+ case EC.TLC_DEADLOCK_REACHED:
+ return "deadlock";
+ case EC.TLC_INVARIANT_EVALUATION_FAILED:
+ return "evaluation_failed";
+ default:
+ return code == EC.NO_ERROR ? "ok" : "error";
+ }
+ }
+
+ /**
+ * Whether the checker explored the whole reachable graph: it ended on its
+ * own with nothing left in its queue, and on a code that never cuts a
+ * state's expansion short. A deadlock is found after the state's (empty)
+ * expansion and a temporal violation after the graph is complete; an
+ * invariant or action-property violation aborts the expansion it occurs
+ * in unless TLC continues past violations. Any other error may have.
+ */
+ private boolean explorationComplete() {
+ if (checkerThread == null || checkerThread.isAlive() || checkerFailure != null || resultCode == null
+ || checker.getStateQueueSize() != 0 || errorCode() != null) {
+ return false;
+ }
+ switch (resultCode) {
+ case EC.NO_ERROR:
+ case EC.TLC_DEADLOCK_REACHED:
+ case EC.TLC_TEMPORAL_PROPERTY_VIOLATED:
+ return true;
+ case EC.TLC_INVARIANT_VIOLATED_INITIAL:
+ case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR:
+ case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR:
+ return runContinuation;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * The first error the run hit that is not a violation, or null. Under
+ * continuation TLC keeps going after an evaluation error and a later code
+ * overwrites it (the state whose expansion the error aborted is then
+ * reported as a deadlock), so the result code alone does not say whether
+ * every state was fully expanded; the checker keeps every code it set.
+ */
+ private Integer errorCode() {
+ if (checker == null) {
+ return null;
+ }
+ for (final int code : checker.getErrorCodes()) {
+ if (!isViolation(code)) {
+ return code;
+ }
+ }
+ return null;
+ }
+
+ /** Whether a result code reports a property violated (or a deadlock), not an error. */
+ private static boolean isViolation(final int code) {
+ switch (code) {
+ case EC.TLC_INVARIANT_VIOLATED_INITIAL:
+ case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR:
+ case EC.TLC_INVARIANT_VIOLATED_LEVEL:
+ case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR:
+ case EC.TLC_TEMPORAL_PROPERTY_VIOLATED:
+ case EC.TLC_PROPERTY_VIOLATED_INITIAL:
+ case EC.TLC_DEADLOCK_REACHED:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ // ─── stats ──────────────────────────────────────────────────────────
+
+ private JsonObject stats() {
+ final JsonObject s = new JsonObject();
+ if (checker == null) {
+ return s;
+ }
+ final JsonObject fin = recorder.finalStats();
+ if (fin != null && resultCode != null) {
+ // After the run the fingerprint set is closed; the counts TLC
+ // printed at the end are the ones on record.
+ s.add("generated", fin.get("generated"));
+ s.add("distinct", fin.get("distinct"));
+ s.add("queue", fin.get("queue"));
+ } else {
+ s.addProperty("generated", checker.getStatesGenerated());
+ s.addProperty("distinct", checker.getDistinctStatesGenerated());
+ s.addProperty("queue", checker.getStateQueueSize());
+ }
+ s.addProperty("initial", checker.getInitialStatesGenerated());
+ s.addProperty("diameter", checker.getProgress());
+ s.addProperty("exploring_ms", exploringMs);
+ s.addProperty("since_open_ms", System.currentTimeMillis() - openedAt);
+ s.addProperty("running", checkerThread != null && checkerThread.isAlive());
+ s.addProperty("finished", resultCode != null);
+ s.add("store", storeInfo());
+ return s;
+ }
+
+ /**
+ * The counters TLC keeps beyond the headline statistics: whether the
+ * reachable graph was exhausted and what stopped the run, the workers'
+ * out-degree distribution (bucketed, the top bucket saturating at 32),
+ * the fingerprint set's own statistics, the fingerprint-collision
+ * probability, and the liveness checkers in play.
+ */
+ private JsonObject registers() {
+ final JsonObject r = new JsonObject();
+ final boolean finished = resultCode != null;
+ // The same judgement a refresh relies on: an error (an invariant or
+ // the next-state relation failing to evaluate) ends the run with
+ // states unexplored, continuation or not.
+ final boolean exhausted = finished && explorationComplete();
+ r.addProperty("finished", finished);
+ r.addProperty("exhausted", exhausted);
+ r.addProperty("stopped_by", checkerFailure != null || (finished && errorCode() != null) ? "error"
+ : !finished ? (checkerThread == null ? "not_started" : "budget")
+ // Under continuation a run that reported violations still ends
+ // on NO_ERROR, so the recorder, not the code, says if it found any.
+ : exhausted ? (resultCode == EC.NO_ERROR && recorder.violationCounts().isEmpty() ? "exhausted"
+ : "exhausted_with_violations")
+ : isViolation(resultCode) ? "violation" : "error");
+ if (finished) {
+ r.addProperty("result_code", resultCode);
+ }
+ r.addProperty("workers", TLCGlobals.getNumWorkers());
+ r.addProperty("continuation", runContinuation);
+ r.addProperty("coverage", tlc2.tool.coverage.CoverageWalk.enabled());
+ // Out-degree across workers.
+ final JsonObject outDegree = new JsonObject();
+ long observations = 0;
+ int min = Integer.MAX_VALUE;
+ int max = -1;
+ double weightedMean = 0;
+ final java.util.TreeMap samples = new java.util.TreeMap<>();
+ for (final tlc2.tool.IWorker w : checker.getWorkers()) {
+ if (!(w instanceof tlc2.tool.Worker)) {
+ continue;
+ }
+ final tlc2.util.statistics.IBucketStatistics b = ((tlc2.tool.Worker) w).getOutDegree();
+ if (b == null || b.getObservations() == 0) {
+ continue;
+ }
+ observations += b.getObservations();
+ min = Math.min(min, b.getMin());
+ max = Math.max(max, b.getMax());
+ weightedMean += b.getMean() * b.getObservations();
+ b.getSamples().forEach((k, v) -> samples.merge(k, v, Long::sum));
+ }
+ if (observations > 0) {
+ outDegree.addProperty("observations", observations);
+ outDegree.addProperty("min", min);
+ outDegree.addProperty("max", max);
+ outDegree.addProperty("mean", weightedMean / observations);
+ outDegree.addProperty("saturated_at", 32);
+ final JsonObject buckets = new JsonObject();
+ samples.forEach((k, v) -> buckets.addProperty(String.valueOf(k), v));
+ outDegree.add("buckets", buckets);
+ }
+ r.add("out_degree", outDegree);
+ // The fingerprint set.
+ final JsonObject fpset = new JsonObject();
+ fpset.addProperty("implementation", checker.theFPSet.getClass().getSimpleName());
+ if (checker.theFPSet instanceof tlc2.tool.fp.FPSetStatistic) {
+ final tlc2.tool.fp.FPSetStatistic f = (tlc2.tool.fp.FPSetStatistic) checker.theFPSet;
+ try {
+ fpset.addProperty("table_count", f.getTblCnt());
+ fpset.addProperty("disk_lookups", f.getDiskLookupCnt());
+ fpset.addProperty("memory_hits", f.getMemHitCnt());
+ fpset.addProperty("disk_hits", f.getDiskHitCnt());
+ fpset.addProperty("disk_writes", f.getDiskWriteCnt());
+ fpset.addProperty("flush_time_ms", f.getFlushTime());
+ fpset.addProperty("bytes", f.sizeof());
+ } catch (final RuntimeException e) {
+ fpset.addProperty("error", e.toString());
+ }
+ }
+ r.add("fpset", fpset);
+ final long generated = checker.getStatesGenerated();
+ final long distinct = checker.getDistinctStatesGenerated();
+ if (distinct > 0 && generated > 0) {
+ r.addProperty("fp_collision_probability",
+ tlc2.tool.AbstractChecker.calculateOptimisticProbability(distinct, generated));
+ }
+ // Liveness.
+ final JsonObject liveness = new JsonObject();
+ liveness.addProperty("enabled", TLCGlobals.doLiveness());
+ liveness.addProperty("temporal_properties", tool.getTemporals().length);
+ r.add("liveness", liveness);
+ return r;
+ }
+
+ private JsonObject storeInfo() {
+ final JsonObject o = new JsonObject();
+ if (store == null) {
+ return o;
+ }
+ o.addProperty("states", store.states());
+ o.addProperty("initial", store.initialStates());
+ o.addProperty("edges", store.edges());
+ o.addProperty("unsatisfied", store.unsatisfied());
+ o.addProperty("excluded", store.excluded());
+ o.addProperty("excluded_states", store.excludedStates());
+ o.addProperty("bytes", store.bytes());
+ return o;
+ }
+
+ // ─── the store's queries ────────────────────────────────────────────
+
+ private JsonObject notOpen() {
+ if (simulator != null) {
+ return error(null, "no_store", "this is a simulate session, which keeps no store; open a check session to query one");
+ }
+ if (tool != null && !storing) {
+ return error(null, "no_store", "this session was opened with store: false; reopen with the store to query it");
+ }
+ return error(null, "not_open", "open a spec first");
+ }
+
+ private static Long fpOf(final JsonObject request, final String key) {
+ if (!request.has(key) || request.get(key).isJsonNull()) {
+ return null;
+ }
+ final JsonElement e = request.get(key);
+ try {
+ return e.getAsJsonPrimitive().isNumber() ? e.getAsLong() : Long.parseLong(e.getAsString());
+ } catch (final RuntimeException ex) {
+ return null;
+ }
+ }
+
+ private String actionName(final long id) {
+ final Action a = store.action((int) id);
+ return a == null ? (id < 0 ? "" : "action#" + id) : a.getNameOfDefault();
+ }
+
+ /**
+ * Re-parse the spec after an edit and re-explore only what the edit
+ * reaches (see {@link Incremental}). A change to the variables, the
+ * initial predicate, a constraint or the config, a VIEW or SYMMETRY set,
+ * a spec that reads {@code TLCGet}, or a first run that did not
+ * finish, leaves nothing to carry:
+ * the reply asks for a restart and a full run.
+ *
+ *
+ * The replay starts from the last fully explored graph ({@link #baseStore}),
+ * not necessarily the current store: a refresh cut short by its budget or
+ * a first violation is served to the store queries but not replayed from.
+ * A replay that fails (the edited spec does not evaluate) is not adopted:
+ * the current tool and store stay. Neither is one whose assumptions fail:
+ * TLC would not explore it. State-level {@code PROPERTY} formulas are
+ * checked on the initial states, as TLC checks them.
+ *
+ *
+ * A paused checker is left parked, not stopped: stopping ends its run as
+ * if it had finished. Parsing the edited spec rebinds TLC's static
+ * variable tables, so when the new tool is not adopted they are rebound
+ * to the old one, and the parked run may resume only if that is exact.
+ */
+ private JsonObject refresh(final JsonObject request) throws Exception {
+ if (simulator != null) {
+ return error(null, "not_checking",
+ "refresh replays a model-checking store; a simulate session has none. Open a new session");
+ }
+ if (tool == null) {
+ return notOpen();
+ }
+ final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : 60_000L;
+ final boolean cont = request.has("continue") && request.get("continue").getAsBoolean();
+ final long started = System.currentTimeMillis();
+ recorder.drainMessages();
+ // Decided before parsing, so these paths leave TLC's statics alone.
+ final String currentConfig = readConfig();
+ String before = null;
+ if (!storing) {
+ before = "the session was opened without a store, so there is no graph to replay";
+ } else if (currentConfig == null || !currentConfig.equals(configText)) {
+ before = "the model config changed";
+ } else if (!refreshed && !explorationComplete()) {
+ before = "the previous exploration did not finish, so the store is not the whole graph";
+ }
+ if (before != null) {
+ return fullRerun(ok(), before, started);
+ }
+ if (baseStore == null) {
+ // The first refresh, from a run that explored the whole graph.
+ baseTool = tool;
+ baseStore = store;
+ }
+ // Parsing rebinds the process-global name slots and resets the
+ // model-value table; kept to put them back if the edited spec is not
+ // adopted.
+ final Statics slots = statics();
+ final Tool newTool;
+ try {
+ newTool = new FastTool(mainFile, configName, new SimpleFilenameToStream(specDir), Tool.Mode.MC,
+ new HashMap<>());
+ } catch (final Throwable t) {
+ final JsonObject reply = error(null, "parse_failed", t.toString());
+ reply.add("messages", recorder.drainMessages());
+ // The current tool and store stay in place.
+ rebindStatics(tool, slots);
+ return reply;
+ }
+ final Incremental.ActionDiff diff = Incremental.diff(baseTool, newTool);
+ final JsonObject reply = ok();
+ reply.add("diff", Incremental.diffJson(diff));
+ reply.addProperty("front_end_ms", System.currentTimeMillis() - started);
+ if (diff.fullRerunReason == null && !slots.modelValues.isPrefixOf(tlc2.value.impl.ModelValue.snapshot())) {
+ // Stored states name a model value by its index in the table the
+ // parse rebuilt; a renumbered one would decode as another value.
+ diff.fullRerunReason = "the model values were renumbered, so stored states would decode as other states";
+ }
+ if (diff.fullRerunReason != null) {
+ rebindStatics(tool, slots);
+ return fullRerun(reply, diff.fullRerunReason, started);
+ }
+ reply.addProperty("mode", "incremental");
+ // TLC checks the assumptions before it explores anything; an edited
+ // ASSUME is checked here too, since the constants it constrains are
+ // the config's and a refresh never runs the checker's own check.
+ final int assumptions = newTool.checkAssumptions();
+ if (assumptions != EC.NO_ERROR) {
+ rebindStatics(tool, slots);
+ reply.addProperty("adopted", false);
+ reply.addProperty("finished", false);
+ reply.addProperty("complete", false);
+ reply.addProperty("verdict",
+ assumptions == EC.TLC_ASSUMPTION_FALSE ? "assumption_false" : "assumption_evaluation_failed");
+ reply.addProperty("error", assumptions == EC.TLC_ASSUMPTION_FALSE
+ ? "an assumption of the edited spec is false; the messages name it"
+ : "an assumption of the edited spec did not evaluate; the messages say why");
+ reply.addProperty("duration_ms", System.currentTimeMillis() - started);
+ reply.add("store", storeInfo());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+ reply.addProperty("assumptions", "hold");
+ reply.addProperty("replayed_from", baseStore == store ? "current" : "last_complete");
+ final String newMetadir = FileUtil.makeMetaDir(new Date(System.currentTimeMillis()), specDir, null);
+ final GraphStore newStore = new GraphStore(newMetadir);
+ Incremental.Result r;
+ try {
+ r = Incremental.replay(newTool, baseStore, newStore, diff, budgetMs, cont);
+ } catch (final Throwable t) {
+ // Anything the replay did not turn into an error of its own still
+ // leaves the edited spec unadopted, and is cleaned up below.
+ r = new Incremental.Result();
+ r.error = t.toString();
+ }
+ reply.addProperty("survivors", r.survivors);
+ reply.addProperty("dropped", r.dropped);
+ reply.addProperty("reexpanded", r.reexpanded);
+ reply.addProperty("new_states", r.newStates);
+ reply.addProperty("edges_copied", r.edgesCopied);
+ reply.addProperty("edges_generated", r.edgesGenerated);
+ reply.addProperty("budget_exhausted", r.budgetExhausted);
+ if (r.restartReason != null) {
+ // A stored state did not read back as itself: nothing can be carried.
+ newStore.dispose();
+ rebindStatics(tool, slots);
+ reply.addProperty("adopted", false);
+ return fullRerun(reply, r.restartReason, started);
+ }
+ if (r.error != null) {
+ // The edited spec does not evaluate; keep serving what was there.
+ newStore.dispose();
+ rebindStatics(tool, slots);
+ reply.addProperty("adopted", false);
+ reply.addProperty("finished", false);
+ reply.addProperty("complete", false);
+ reply.addProperty("error", r.error);
+ reply.addProperty("duration_ms", System.currentTimeMillis() - started);
+ reply.add("store", storeInfo());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+ final GraphStore previous = store;
+ tool = newTool;
+ store = newStore;
+ metadir = newMetadir;
+ refreshed = true;
+ // Only a replay that ran to the end holds the whole graph: one cut
+ // by its budget or a first violation leaves states whose successors
+ // were never generated. Such a store is served, not replayed from.
+ final boolean stoppedAtViolation = !cont && !r.violations.isEmpty();
+ final boolean complete = !r.budgetExhausted && !stoppedAtViolation;
+ refreshComplete = complete;
+ if (complete) {
+ final GraphStore oldBase = baseStore;
+ baseTool = newTool;
+ baseStore = newStore;
+ retire(oldBase);
+ }
+ retire(previous);
+ reply.addProperty("adopted", true);
+ reply.addProperty("finished", !r.budgetExhausted);
+ reply.addProperty("complete", complete);
+ reply.addProperty("stopped_at_first_violation", stoppedAtViolation);
+ final JsonArray violations = new JsonArray();
+ for (final Incremental.Violation v : r.violations) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("invariant", v.invariant);
+ o.addProperty("fp", v.fp);
+ o.addProperty("level", v.level);
+ violations.add(o);
+ }
+ // Per-invariant verdicts: exact over the refreshed store when the
+ // replay explored the whole graph (one evaluation per state and
+ // invariant), else not_evaluated.
+ final JsonArray invs = new JsonArray();
+ if (complete) {
+ final JsonArray exact = new JsonArray();
+ for (final Incremental.Sweep sw : Incremental.sweep(tool, store)) {
+ final JsonObject v = new JsonObject();
+ v.addProperty("name", sw.invariant);
+ if (sw.error != null) {
+ v.addProperty("verdict", "not_evaluable");
+ v.addProperty("error", sw.error);
+ } else if (sw.violations > 0) {
+ v.addProperty("verdict", "violated");
+ v.addProperty("reports", sw.violations);
+ v.addProperty("level", sw.firstLevel);
+ v.addProperty("fp", sw.firstFp);
+ final JsonObject o = new JsonObject();
+ o.addProperty("invariant", sw.invariant);
+ o.addProperty("fp", sw.firstFp);
+ o.addProperty("level", sw.firstLevel);
+ o.addProperty("count", sw.violations);
+ exact.add(o);
+ } else {
+ v.addProperty("verdict", "no_violation_found");
+ }
+ invs.add(v);
+ }
+ reply.add("violations", exact);
+ } else {
+ for (final String name : tool.getInvNames()) {
+ final JsonObject v = new JsonObject();
+ v.addProperty("name", name);
+ Incremental.Violation first = null;
+ for (final Incremental.Violation x : r.violations) {
+ if (x.invariant.equals(name)) {
+ first = x;
+ break;
+ }
+ }
+ if (first != null) {
+ v.addProperty("verdict", "violated");
+ v.addProperty("level", first.level);
+ v.addProperty("fp", first.fp);
+ } else {
+ v.addProperty("verdict", "not_evaluated");
+ }
+ invs.add(v);
+ }
+ reply.add("violations", violations);
+ }
+ reply.add("invariants", invs);
+ // State-level PROPERTY formulas, which TLC checks on the initial
+ // states only. The initial predicate is unchanged, so every initial
+ // state is in the refreshed store, whatever stopped the replay.
+ final JsonArray props = new JsonArray();
+ for (final Incremental.Sweep sw : Incremental.impliedInits(tool, store)) {
+ final JsonObject v = new JsonObject();
+ v.addProperty("name", sw.invariant);
+ if (sw.error != null) {
+ v.addProperty("verdict", "not_evaluable");
+ v.addProperty("error", sw.error);
+ } else if (sw.violations > 0) {
+ v.addProperty("verdict", "violated_initially");
+ v.addProperty("reports", sw.violations);
+ v.addProperty("fp", sw.firstFp);
+ } else {
+ v.addProperty("verdict", "no_violation_found");
+ }
+ props.add(v);
+ }
+ reply.add("implied_inits", props);
+ reply.add("unchecked", unchecked());
+ reply.addProperty("duration_ms", System.currentTimeMillis() - started);
+ reply.add("store", storeInfo());
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ /**
+ * What a refresh does not recheck, so a caller does not read the
+ * invariant verdicts as the whole answer (it does check the assumptions
+ * and the state-level properties, under {@code implied_inits}): temporal properties (the
+ * liveness tableau is not rebuilt), implied actions, deadlock, and the
+ * blocked-guard tallies (not recorded during a replay).
+ */
+ private JsonObject unchecked() {
+ final JsonObject o = new JsonObject();
+ final JsonArray temporals = new JsonArray();
+ for (final Action a : tool.getTemporals()) {
+ temporals.add(a.getNameOfDefault());
+ }
+ for (final Action a : tool.getImpliedTemporals()) {
+ temporals.add(a.getNameOfDefault());
+ }
+ o.add("temporal_properties", temporals);
+ o.add("implied_actions", names(tool.getImpliedActNames()));
+ o.addProperty("deadlock", checkDeadlock);
+ o.addProperty("guard_tallies", true);
+ return o;
+ }
+
+ /**
+ * After an incremental refresh the checker's counters and registers
+ * describe the run before it, not the refreshed store: say so, and say
+ * whether the store now holds the whole graph, as the refresh reported.
+ */
+ private void markStale(final JsonObject reply) {
+ if (!refreshed) {
+ return;
+ }
+ reply.addProperty("stale", true);
+ reply.addProperty("stale_reason",
+ "the store was refreshed incrementally; the counters and registers are the last full run's, over the spec as it was then (only `store` is current)");
+ reply.addProperty("refresh_complete", refreshComplete);
+ }
+
+ /** Release a store nothing refers to any more. */
+ private void retire(final GraphStore s) {
+ if (s != null && s != store && s != baseStore) {
+ s.dispose();
+ }
+ }
+
+ /**
+ * Nothing can be carried. A second checker in this JVM trips over TLC's
+ * per-process state (worker and trace bookkeeping), so the caller
+ * restarts the resident for the full run; the old tool and store stay in
+ * place until then.
+ */
+ private JsonObject fullRerun(final JsonObject reply, final String reason, final long started) {
+ reply.addProperty("mode", "full");
+ reply.addProperty("restart_required", true);
+ reply.addProperty("reason", reason);
+ reply.addProperty("duration_ms", System.currentTimeMillis() - started);
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ /** The model config's text, or null when it cannot be read. */
+ private String readConfig() {
+ try {
+ return new String(java.nio.file.Files.readAllBytes(new File(specDir, configName + ".cfg").toPath()),
+ StandardCharsets.UTF_8);
+ } catch (final IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Rebind TLC's static variable tables to {@code old} after parsing a spec
+ * that was not adopted. Parsing assigns each variable name its slot and
+ * sets the variable count, the empty state and the state's tool, and it
+ * resets the model-value table; this puts the old spec's back, the
+ * model-value table and every name's slot as {@code saved} recorded
+ * them before the parse (a definition of the old spec that the new one
+ * declares a variable would otherwise evaluate as that variable, and a
+ * stored model value would decode against the edited spec's
+ * numbering). Should a definition still read as a variable, nothing that
+ * evaluates against the spec is served any more.
+ */
+ private void rebindStatics(final Tool old, final Statics saved) {
+ // Every name's slot as it was before the parse: a definition of the
+ // old spec that the edited one declares a variable gets its definition
+ // slot back. A name first seen in the parse gets none.
+ for (final util.UniqueString u : util.UniqueString.internTbl.toMap().values()) {
+ final Integer loc = saved.slots.get(u);
+ u.setLoc(loc == null ? -1 : loc);
+ }
+ // The model values the stored states were serialised against: the
+ // parse reset the table, and a stored state names a model value by
+ // its index there.
+ tlc2.value.impl.ModelValue.restore(saved.modelValues);
+ final tla2sany.semantic.OpDeclNode[] vars = old.getSpecProcessor().getVariablesNodes();
+ for (int i = 0; i < vars.length; i++) {
+ vars[i].getName().setLoc(i);
+ }
+ util.UniqueString.setVariableCount(vars.length);
+ tlc2.tool.TLCStateMut.setVariables(vars);
+ tlc2.tool.TLCStateMut.setTool(old);
+ final OpDefNode[] defs = old.getSpecProcessor().getRootModule().getOpDefs();
+ for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) {
+ if (def.getName().getVarLoc() >= 0) {
+ restartRequired = "parsing the edited spec reassigned the definition " + def.getName();
+ return;
+ }
+ }
+ }
+
+ /** What a parse overwrites in TLC's process-global state, taken before it. */
+ private static final class Statics {
+ /** Every interned name's slot in a state or the definition table (-1 for none). */
+ final Map slots = new java.util.IdentityHashMap<>();
+ /** The model-value table, which every parse resets. */
+ final tlc2.value.impl.ModelValue.Table modelValues = tlc2.value.impl.ModelValue.snapshot();
+ }
+
+ private static Statics statics() {
+ final Statics out = new Statics();
+ for (final util.UniqueString u : util.UniqueString.internTbl.toMap().values()) {
+ // A slot is either a variable's or a definition's.
+ out.slots.put(u, Math.max(u.getVarLoc(), u.getDefnLoc()));
+ }
+ return out;
+ }
+
+ /**
+ * The refusal every request that evaluates against the spec gives once a
+ * refresh left TLC's statics unfit for it, or null.
+ */
+ private JsonObject restartRefusal() {
+ if (restartRequired == null) {
+ return null;
+ }
+ return error(null, "restart_required",
+ "this session cannot evaluate against its spec any more: " + restartRequired + "; open a new session");
+ }
+
+ /** The path from an initial state to a stored fingerprint. */
+ private JsonObject trace(final JsonObject request) {
+ if (store == null) {
+ return notOpen();
+ }
+ final Long fp = fpOf(request, "fp");
+ if (fp == null) {
+ return error(null, "invalid_request", "trace needs `fp`, a stored fingerprint");
+ }
+ final long[][] path = store.pathTo(fp);
+ if (path == null) {
+ return error(null, "unknown_state", "no stored state with fingerprint " + fp);
+ }
+ final JsonArray states = new JsonArray();
+ for (int i = 0; i < path.length; i++) {
+ final TLCState state = store.read(path[i][0]);
+ final JsonObject s = state == null ? new JsonObject() : Recorder.state(state);
+ s.addProperty("ordinal", i + 1);
+ s.addProperty("fp", path[i][0]);
+ s.addProperty("action", actionName(path[i][1]));
+ states.add(s);
+ }
+ final JsonObject reply = ok();
+ reply.addProperty("fp", fp);
+ reply.addProperty("length", path.length);
+ reply.addProperty("shortest", TLCGlobals.getNumWorkers() == 1);
+ reply.add("states", states);
+ return reply;
+ }
+
+ /** Recorded predecessors of a stored state and, freshly computed, its successors per action. */
+ private JsonObject neighbours(final JsonObject request) {
+ if (store == null) {
+ return notOpen();
+ }
+ final Long fp = fpOf(request, "fp");
+ if (fp == null) {
+ return error(null, "invalid_request", "neighbours needs `fp`, a stored fingerprint");
+ }
+ final TLCState state = store.read(fp);
+ if (state == null) {
+ return error(null, "unknown_state", "no stored state with fingerprint " + fp);
+ }
+ final JsonObject reply = ok();
+ reply.addProperty("fp", fp);
+ reply.addProperty("level", store.level(fp));
+ reply.add("state", Recorder.state(state));
+ final JsonArray preds = new JsonArray();
+ for (final long[] p : store.predecessorsOf(fp)) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("fp", p[0]);
+ o.addProperty("action", actionName(p[1]));
+ o.addProperty("action_id", p[1]);
+ o.addProperty("seen_before", (p[2] & tlc2.util.IStateWriter.IsSeen) != 0);
+ preds.add(o);
+ }
+ reply.add("predecessors", preds);
+ final JsonArray succs = new JsonArray();
+ int enabled = 0;
+ for (final Action a : tool.getActions()) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("action", a.getNameOfDefault());
+ o.addProperty("action_id", a.getId());
+ try {
+ // Not counted in the run's coverage: this query is not part of the run.
+ final StateVec next = tool.getNextStatesUnrecorded(a, state);
+ o.addProperty("enabled", next.size() > 0);
+ o.addProperty("successors", next.size());
+ if (next.size() > 0) {
+ enabled++;
+ }
+ final JsonArray fps = new JsonArray();
+ for (int i = 0; i < next.size(); i++) {
+ final TLCState succ = next.elementAt(i);
+ final JsonObject so = new JsonObject();
+ long sfp = 0;
+ try {
+ sfp = succ.fingerPrint();
+ } catch (final RuntimeException e) {
+ so.addProperty("unassigned", true);
+ }
+ so.addProperty("fp", sfp);
+ so.addProperty("stored", store.contains(sfp));
+ so.add("changed", changed(state, succ));
+ fps.add(so);
+ }
+ o.add("states", fps);
+ } catch (final Throwable t) {
+ o.addProperty("enabled", (Boolean) null);
+ o.addProperty("error", t.toString());
+ }
+ succs.add(o);
+ }
+ reply.addProperty("enabled_actions", enabled);
+ reply.add("successors", succs);
+ return reply;
+ }
+
+ /** The variables whose values differ between two states. */
+ private static JsonArray changed(final TLCState a, final TLCState b) {
+ final JsonArray out = new JsonArray();
+ final Map va = a.getVals();
+ final Map vb = b.getVals();
+ if (va == null || vb == null) {
+ return out;
+ }
+ for (final Map.Entry e : vb.entrySet()) {
+ final IValue before = va.get(e.getKey());
+ if (before == null || !before.equals(e.getValue())) {
+ out.add(e.getKey().toString());
+ }
+ }
+ return out;
+ }
+
+ /** Parse a caller expression against the root module, as the debugger does. */
+ private OpDefNode parse(final String expression) throws Exception {
+ final tlc2.tool.impl.SpecProcessor proc = tool.getSpecProcessor();
+ final OpDefNode def = TLCDebuggerExpression.process(proc, proc.getRootModule(), expression);
+ if (def == null) {
+ throw new IllegalArgumentException("could not parse expression: " + expression);
+ }
+ return def;
+ }
+
+ /** Evaluate an expression in a stored state, or over a stored state pair. */
+ private JsonObject eval(final JsonObject request) {
+ if (store == null) {
+ return notOpen();
+ }
+ final String expression = string(request, "expr", null);
+ final Long fp = fpOf(request, "fp");
+ if (expression == null || fp == null) {
+ return error(null, "invalid_request", "eval needs `expr` and `fp` (and optionally `fp2` for a primed expression)");
+ }
+ final TLCState s0 = store.read(fp);
+ if (s0 == null) {
+ return error(null, "unknown_state", "no stored state with fingerprint " + fp);
+ }
+ final Long fp2 = fpOf(request, "fp2");
+ TLCState s1 = null;
+ if (fp2 != null) {
+ s1 = store.read(fp2);
+ if (s1 == null) {
+ return error(null, "unknown_state", "no stored state with fingerprint " + fp2);
+ }
+ }
+ final JsonObject reply = ok();
+ reply.addProperty("expr", expression);
+ reply.addProperty("fp", fp);
+ final long started = System.currentTimeMillis();
+ try {
+ final OpDefNode def = parse(expression);
+ final IValue value = s1 == null ? tool.eval(def.getBody(), Context.Empty, s0)
+ : tool.eval(def.getBody(), Context.Empty, s0, s1, EvalControl.Clear, CostModel.DO_NOT_RECORD);
+ reply.addProperty("evaluated", true);
+ reply.add("value", Recorder.value(value));
+ reply.addProperty("tla", value.toString());
+ } catch (final Throwable t) {
+ reply.addProperty("evaluated", false);
+ reply.addProperty("error", t.getMessage() == null ? t.toString() : t.getMessage());
+ }
+ reply.addProperty("duration_ms", System.currentTimeMillis() - started);
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ /** Evaluate candidate state predicates over every stored state. */
+ private JsonObject screen(final JsonObject request) throws InterruptedException {
+ if (store == null) {
+ return notOpen();
+ }
+ if (!request.has("candidates") || !request.get("candidates").isJsonArray()) {
+ return error(null, "invalid_request", "screen needs `candidates`, a list of state predicates");
+ }
+ final long budgetMs = request.has("budget_ms") ? request.get("budget_ms").getAsLong() : 60_000L;
+ final JsonArray candidates = request.getAsJsonArray("candidates");
+ final int n = candidates.size();
+ final String[] texts = new String[n];
+ final OpDefNode[] defs = new OpDefNode[n];
+ final String[] errors = new String[n];
+ final long[] violations = new long[n];
+ final Long[] firstViolation = new Long[n];
+ final long[] evaluated = new long[n];
+ for (int i = 0; i < n; i++) {
+ texts[i] = candidates.get(i).getAsString();
+ try {
+ defs[i] = parse(texts[i]);
+ } catch (final Throwable t) {
+ errors[i] = t.getMessage() == null ? t.toString() : t.getMessage();
+ }
+ }
+ if (checkerThread != null && checkerThread.isAlive()) {
+ checker.suspend();
+ }
+ final long started = System.currentTimeMillis();
+ final long[] fps = store.fingerprints();
+ int scanned = 0;
+ boolean budgetHit = false;
+ for (final long fp : fps) {
+ if (System.currentTimeMillis() - started > budgetMs) {
+ budgetHit = true;
+ break;
+ }
+ final TLCState state = store.read(fp);
+ if (state == null) {
+ continue;
+ }
+ scanned++;
+ for (int i = 0; i < n; i++) {
+ if (defs[i] == null || errors[i] != null) {
+ continue;
+ }
+ try {
+ final IValue v = tool.eval(defs[i].getBody(), Context.Empty, state);
+ evaluated[i]++;
+ if (!(v instanceof BoolValue)) {
+ errors[i] = "not a boolean at fingerprint " + fp + ": " + v;
+ } else if (!((BoolValue) v).val) {
+ violations[i]++;
+ if (firstViolation[i] == null) {
+ firstViolation[i] = fp;
+ }
+ }
+ } catch (final Throwable t) {
+ errors[i] = (t.getMessage() == null ? t.toString() : t.getMessage()) + " at fingerprint " + fp;
+ }
+ }
+ }
+ final JsonObject reply = ok();
+ reply.addProperty("stored", fps.length);
+ reply.addProperty("scanned", scanned);
+ reply.addProperty("budget_exhausted", budgetHit);
+ reply.addProperty("duration_ms", System.currentTimeMillis() - started);
+ final JsonArray results = new JsonArray();
+ for (int i = 0; i < n; i++) {
+ final JsonObject r = new JsonObject();
+ r.addProperty("expr", texts[i]);
+ if (errors[i] != null) {
+ r.addProperty("verdict", "not_evaluable");
+ r.addProperty("error", errors[i]);
+ } else if (violations[i] > 0) {
+ r.addProperty("verdict", "violated");
+ r.addProperty("violations", violations[i]);
+ r.addProperty("first_violation_fp", firstViolation[i]);
+ r.addProperty("first_violation_level", store.level(firstViolation[i]));
+ } else {
+ r.addProperty("verdict", budgetHit ? "no_violation_in_scanned" : "holds_on_stored");
+ }
+ r.addProperty("evaluated", evaluated[i]);
+ results.add(r);
+ }
+ reply.add("results", results);
+ reply.add("messages", recorder.drainMessages());
+ return reply;
+ }
+
+ /** The guard conjuncts that kept transitions from firing, most often first. */
+ private JsonObject guardProfile() {
+ if (store == null) {
+ return notOpen();
+ }
+ final JsonObject reply = ok();
+ final JsonArray rows = new JsonArray();
+ for (final GraphStore.Blocked b : store.blocked()) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("action", b.action);
+ o.addProperty("action_id", b.actionId);
+ o.addProperty("kind", b.kind);
+ o.addProperty("location", b.location);
+ o.addProperty("text", b.text);
+ o.addProperty("count", b.count);
+ o.addProperty("example_fp", b.exampleFp);
+ if (b.exampleBindings != null) {
+ final JsonObject bindings = new JsonObject();
+ b.exampleBindings.forEach(bindings::addProperty);
+ o.add("example_bindings", bindings);
+ }
+ rows.add(o);
+ }
+ reply.addProperty("unsatisfied", store.unsatisfied());
+ reply.addProperty("excluded", store.excluded());
+ reply.add("blocked", rows);
+ reply.addProperty("note",
+ "a guard row counts how often the subexpression evaluated false while TLC generated the action's successors, "
+ + "attributed to the first false conjunct in TLC's evaluation order. Under a disjunction each "
+ + "false disjunct is counted, even when another disjunct let the action fire. A constraint row "
+ + "counts successors the action generated that a state or action constraint then excluded");
+ if (refreshed) {
+ reply.addProperty("stale", true);
+ reply.addProperty("stale_reason",
+ "the store was refreshed incrementally, and guards are not tallied during a replay; reopen for a fresh profile");
+ }
+ return reply;
+ }
+
+ void shutdown() {
+ if (simulator != null && simulatorThread != null && simulatorThread.isAlive()) {
+ simulator.stop();
+ try {
+ simulatorThread.join(5000);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ if (checker != null && checkerThread != null && checkerThread.isAlive()) {
+ checker.stop();
+ try {
+ checkerThread.join(5000);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ // The stores' files (and a refresh's own metadir) live under the
+ // spec's directory; nothing reads them once the session ends.
+ if (checkerThread == null || !checkerThread.isAlive()) {
+ if (baseStore != null && baseStore != store) {
+ baseStore.dispose();
+ }
+ if (store != null) {
+ store.dispose();
+ }
+ baseStore = null;
+ store = null;
+ // TLC deletes its run's metadir when the run ends, but on Windows
+ // a file another process still holds (a virus scanner) makes that
+ // fail; the session created the directory, so it finishes the job.
+ if (runMetadir != null) {
+ if (checker != null) {
+ for (final tlc2.tool.IWorker w : checker.getWorkers()) {
+ if (w instanceof tlc2.tool.Worker) {
+ try {
+ ((tlc2.tool.Worker) w).closeTrace();
+ } catch (final IOException e) {
+ // Deleting below is what matters.
+ }
+ }
+ }
+ }
+ deleteTree(new File(runMetadir));
+ final File root = new File(runMetadir).getParentFile();
+ final String[] left = root == null ? null : root.list();
+ if (left != null && left.length == 0) {
+ GraphStore.delete(root);
+ }
+ }
+ }
+ }
+
+ private static void deleteTree(final File f) {
+ final File[] children = f.listFiles();
+ if (children != null) {
+ for (final File c : children) {
+ deleteTree(c);
+ }
+ }
+ if (f.exists()) {
+ GraphStore.delete(f);
+ }
+ }
+
+ // ─── replies ────────────────────────────────────────────────────────
+
+ private static JsonObject ok() {
+ final JsonObject o = new JsonObject();
+ o.addProperty("ok", true);
+ return o;
+ }
+
+ private static JsonObject error(final JsonElement id, final String code, final String message) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("ok", false);
+ o.addProperty("error_code", code);
+ o.addProperty("error", message);
+ if (id != null) {
+ o.add("id", id);
+ }
+ return o;
+ }
+
+ private static String string(final JsonObject o, final String key, final String dflt) {
+ return o.has(key) && !o.get(key).isJsonNull() ? o.get(key).getAsString() : dflt;
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java
index 8417f57759..b1a1835687 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/AbstractChecker.java
@@ -61,6 +61,14 @@ public abstract class AbstractChecker
protected TLCState predErrState;
protected TLCState errState;
protected int errorCode;
+ /**
+ * Basis: every code {@link #setErrState} or {@link #setError} accepted, in
+ * order. Under continuation a later code overwrites {@link #errorCode}
+ * (a next-state evaluation error is followed by the deadlock of the state
+ * whose expansion it aborted), so the code the run ends on need not name
+ * every error it hit. Guarded by this checker's monitor.
+ */
+ private final List errorCodes = new ArrayList<>();
protected boolean done;
protected boolean keepCallStack;
protected final boolean checkDeadlock;
@@ -177,15 +185,22 @@ public boolean setErrState(TLCState curState, TLCState succState, boolean keepCa
this.predErrState = curState;
this.errState = (succState == null) ? curState : succState;
this.errorCode = errorCode;
+ this.errorCodes.add(errorCode);
this.done = true;
this.keepCallStack = keepCallStack;
return true;
}
+ /** Basis: every error code this run set, in order (see {@link #errorCodes}). */
+ public synchronized List getErrorCodes() {
+ return new ArrayList<>(this.errorCodes);
+ }
+
public void setError(boolean keepCallStack, int errorCode) {
assert Thread.holdsLock(this) : "Caller thread has to hold monitor!";
IdThread.resetCurrentState();
this.errorCode = errorCode;
+ this.errorCodes.add(errorCode);
this.done = true;
this.keepCallStack = keepCallStack;
}
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java
index 39e463ed8d..035259ffda 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Defns.java
@@ -30,6 +30,8 @@ public class Defns implements ToolGlobals, Serializable
{
private int defnIdx;
private Object[] table;
+ /** One past the highest slot any table in this process has handed out. */
+ private static int allocated;
/**
* Constructs the storage of initial size + 32
@@ -87,7 +89,14 @@ public void put(UniqueString key, Object val)
int loc = key.getDefnLoc();
if (loc == -1)
{
- loc = defnIdx++;
+ // Basis: a name's slot lives on its (process-global) UniqueString,
+ // so a second parse in one JVM must not hand a new name a slot an
+ // earlier parse gave another name that this table also holds.
+ synchronized (Defns.class) {
+ loc = Math.max(defnIdx, allocated);
+ defnIdx = loc + 1;
+ allocated = defnIdx;
+ }
key.setLoc(loc);
}
if (loc >= this.table.length)
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java
index 5ac60aafcf..12b2fed118 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java
@@ -509,7 +509,9 @@ private final boolean doNextCheckInvariants(final ITool tool, final TLCState cur
{
MP.printError(EC.TLC_INVARIANT_VIOLATED_BEHAVIOR,
tool.getInvNames()[k]);
- this.trace.printTrace(curState, succState);
+ if (TLCGlobals.continuationTraceAllowed(tool.getInvNames()[k])) {
+ this.trace.printTrace(curState, succState);
+ }
return false;
}
} else {
@@ -541,7 +543,9 @@ private final boolean doNextCheckImplied(final ITool tool, final TLCState curSta
{
MP.printError(EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR, tool
.getImpliedActNames()[k]);
- this.trace.printTrace(curState, succState);
+ if (TLCGlobals.continuationTraceAllowed(tool.getImpliedActNames()[k])) {
+ this.trace.printTrace(curState, succState);
+ }
return false;
}
} else {
@@ -696,7 +700,7 @@ public final int doPeriodicWork() throws Exception
return EC.NO_ERROR;
}
- if (this.theStateQueue.suspendAll())
+ if (this.periodicSuspend())
{
// Run liveness checking, if needed:
// The ratio set in TLCGlobals defines an upper bound for the
@@ -724,7 +728,7 @@ public final int doPeriodicWork() throws Exception
checkpoint();
} else {
// Just resume worker threads when checkpointing is skipped
- this.theStateQueue.resumeAll();
+ this.periodicResume();
}
}
return EC.NO_ERROR;
@@ -743,7 +747,7 @@ protected void checkpoint() throws IOException {
}
// Resume the workers' state-space exploration, which potentially mutates
// the intern table and liveness graph.
- this.theStateQueue.resumeAll();
+ this.periodicResume();
// commit checkpoint:
this.theStateQueue.commitChkpt();
this.trace.commitChkpt();
@@ -1057,16 +1061,71 @@ public void stop() {
}
}
+ /**
+ * True while a caller of {@link #suspend()} holds the workers parked. The
+ * periodic work (checkpoints, liveness checks) suspends and resumes the
+ * queue on its own; it must not resume workers someone else has parked.
+ */
+ private boolean held = false;
+ /** True while the periodic work has the workers parked. */
+ private boolean periodicParked = false;
+ /**
+ * Held by whoever waits in {@code theStateQueue.suspendAll()}. The queue
+ * wakes a single waiter when the last worker parks, so a second thread
+ * waiting there at the same time (the periodic work and a caller of
+ * {@link #suspend()}) would never be woken. Not the checker's monitor:
+ * workers take that on their way to the barrier.
+ */
+ private final Object suspendLock = new Object();
+
+ private boolean suspendQueue() {
+ synchronized (this.suspendLock) {
+ return this.theStateQueue.suspendAll();
+ }
+ }
+
+ private boolean periodicSuspend() {
+ synchronized (this) {
+ this.periodicParked = true;
+ }
+ final boolean suspended = this.suspendQueue();
+ if (!suspended) {
+ synchronized (this) {
+ this.periodicParked = false;
+ }
+ }
+ return suspended;
+ }
+
+ private void periodicResume() {
+ synchronized (this) {
+ this.periodicParked = false;
+ if (!this.held) {
+ this.theStateQueue.resumeAll();
+ }
+ }
+ }
+
public void suspend() {
synchronized (this) {
- this.theStateQueue.suspendAll();
+ this.held = true;
+ }
+ // Basis: wait for the workers outside this monitor. A worker reporting
+ // a violation (or the end of the run) takes it before it can reach the
+ // queue's barrier, so waiting while holding it deadlocks.
+ this.suspendQueue();
+ synchronized (this) {
this.notifyAll();
}
}
public void resume() {
synchronized (this) {
- this.theStateQueue.resumeAll();
+ this.held = false;
+ // Periodic work in progress resumes the workers when it is done.
+ if (!this.periodicParked) {
+ this.theStateQueue.resumeAll();
+ }
this.notifyAll();
}
}
@@ -1088,6 +1147,11 @@ public TLCStateInfo[] getTraceInfo(final TLCState from, TLCState to) throws IOEx
/* (non-Javadoc)
* @see tlc2.tool.AbstractChecker#getStateQueueSize()
*/
+ /** The workers of the current run, for their statistics; empty before it. */
+ public IWorker[] getWorkers() {
+ return this.workers == null ? new IWorker[0] : this.workers;
+ }
+
@Override
public long getStateQueueSize() {
return theStateQueue.size();
@@ -1179,6 +1243,10 @@ public Object addElement(final TLCState curState) {
liveCheck.addInitState(tool.noDebug(), curState, fp);
}
}
+ } else {
+ // Basis: its invariants are checked below, so a writer
+ // that sweeps invariants later needs it too.
+ allStateWriter.writeExcludedInitial(curState);
}
// Check properties of the state:
if (!seen || forceChecks) {
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java
index a8af10c0e3..95339f3faa 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Simulator.java
@@ -725,6 +725,38 @@ private ActionFlowGraphSnapshot(final Action[] actions, final long[][] actionSta
private enum ActionContexts { KEEP, REDUCE }
+ /**
+ * The action-pair follow matrix as data: for actions i and j (reduced to
+ * one vertex per definition), how often j followed i across every worker's
+ * traces. Populated only when {@link #EXTENDED_STATISTICS} was set at
+ * class load (the {@code tlc2.tool.Simulator.extendedStatistics} system
+ * property); otherwise every count is zero, and the caller must not read
+ * those zeros as observations.
+ */
+ public com.google.gson.JsonObject actionFlowAsJson() {
+ final com.google.gson.JsonObject out = new com.google.gson.JsonObject();
+ out.addProperty("extended_statistics", EXTENDED_STATISTICS);
+ if (workers == null || workers.isEmpty()) {
+ return out;
+ }
+ final ActionFlowGraphSnapshot snap = getActionFlowGraphSnapshot(ActionContexts.REDUCE);
+ final com.google.gson.JsonArray names = new com.google.gson.JsonArray();
+ for (final Action a : snap.actions) {
+ names.add(a.getNameOfDefault());
+ }
+ out.add("actions", names);
+ final com.google.gson.JsonArray rows = new com.google.gson.JsonArray();
+ for (final long[] row : snap.actionStats) {
+ final com.google.gson.JsonArray r = new com.google.gson.JsonArray();
+ for (final long v : row) {
+ r.add(v);
+ }
+ rows.add(r);
+ }
+ out.add("follows", rows);
+ return out;
+ }
+
private ActionFlowGraphSnapshot getActionFlowGraphSnapshot(final ActionContexts contexts) {
// The number of actions is expected to be low (dozens commons and hundreds are
// rare). This is why the code below isn't optimized for performance.
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java
index c58bcd0661..8484f02918 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java
@@ -286,6 +286,16 @@ final void setLevel(int level) {
* cache in BufferedRandomAccessFile hasn't been flushed out.
*/
+ /**
+ * Close this worker's trace file. A run's cleanup closes the shared trace
+ * but not the workers' files; a process that exits never notices, but one
+ * that outlives its run (the resident) must close them before it can
+ * delete the metadir on Windows.
+ */
+ public final synchronized void closeTrace() throws IOException {
+ this.raf.close();
+ }
+
public final synchronized void writeState(final TLCState initialState, final long fp) throws IOException {
// Write initial state to trace file.
this.lastPtr = this.raf.getFilePointer();
@@ -494,7 +504,7 @@ public final Object addElement(final TLCState curState, final Action action, fin
public TLCState addUnsatisfiedState(final TLCState curState, final Action action, final TLCState succState,
final SemanticNode pred, final Context c) {
if (this.allStateWriter.isConstrained()) {
- this.allStateWriter.writeState(curState, succState, IStateWriter.IsNotInModel, action, pred);
+ this.allStateWriter.writeUnsatisfied(curState, action, succState, pred, c);
}
return succState;
}
@@ -559,7 +569,9 @@ private final boolean doNextCheckInvariants(final TLCState curState, final TLCSt
{
MP.printError(EC.TLC_INVARIANT_VIOLATED_BEHAVIOR,
this.tool.getInvNames()[k]);
- this.tlc.trace.printTrace(curState, succState);
+ if (TLCGlobals.continuationTraceAllowed(this.tool.getInvNames()[k])) {
+ this.tlc.trace.printTrace(curState, succState);
+ }
return false;
}
} else {
@@ -591,7 +603,9 @@ private final boolean doNextCheckImplied(final TLCState curState, final TLCState
{
MP.printError(EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR, this.tool
.getImpliedActNames()[k]);
- this.tlc.trace.printTrace(curState, succState);
+ if (TLCGlobals.continuationTraceAllowed(this.tool.getImpliedActNames()[k])) {
+ this.tlc.trace.printTrace(curState, succState);
+ }
return false;
}
} else {
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java
new file mode 100644
index 0000000000..e5920293b7
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java
@@ -0,0 +1,254 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.tool.coverage;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import tla2sany.semantic.OpDeclNode;
+import tla2sany.semantic.SemanticNode;
+import tlc2.TLCGlobals;
+import tlc2.tool.Action;
+import tlc2.tool.ITool;
+import tlc2.tool.coverage.ActionWrapper.Relation;
+import tlc2.tool.coverage.OpApplNodeWrapper.Calculate;
+import tlc2.util.Vect;
+
+/**
+ * The coverage tree as data.
+ *
+ *
+ * {@link CostModelCreator#report} walks the same {@link ActionWrapper} and
+ * {@link OpApplNodeWrapper} tree and prints it, but the printer is lossy
+ * exactly where a reader wants detail: {@link OpApplNodeWrapper#print}
+ * skips subtrees whose counts are zero and collapses subtrees whose counts
+ * agree with their parent. This walk keeps every node with its location,
+ * primary and secondary counts and whether it is primed, and lists the
+ * unevaluated subexpressions of every action and invariant separately, so
+ * a conjunct that never ran is visible rather than elided. It lives in
+ * this package because the counters are package-private.
+ */
+public final class CoverageWalk {
+
+ private CoverageWalk() {
+ }
+
+ /** Whether the counters were armed for this run. */
+ public static boolean enabled() {
+ return TLCGlobals.isCoverageEnabled() || TLCGlobals.Coverage.isEnabled();
+ }
+
+ /**
+ * Per action: name, declaration, relation, how many successor states it
+ * produced ({@code found}) and how many were new ({@code distinct}); per
+ * invariant: its evaluations; per variable: the approximate number of
+ * distinct values seen. Every action's and invariant's subexpression tree
+ * goes under {@code tree}, and the subexpressions never evaluated under
+ * {@code unevaluated}.
+ */
+ public static JsonObject walk(final ITool tool) {
+ final JsonObject out = new JsonObject();
+ out.addProperty("enabled", enabled());
+ if (!enabled()) {
+ return out;
+ }
+ final JsonArray actions = new JsonArray();
+ final Set seen = new HashSet<>();
+ for (final Action a : tool.getActions()) {
+ if (!(a.cm instanceof ActionWrapper)) {
+ continue;
+ }
+ final ActionWrapper w = (ActionWrapper) a.cm;
+ final JsonObject o = new JsonObject();
+ o.addProperty("name", a.getNameOfDefault());
+ o.addProperty("id", a.getId());
+ o.addProperty("location", a.getLocation());
+ o.addProperty("relation", "next");
+ // ActionWrapper.report: for NEXT, secondary is the distinct states
+ // and the eval count the states found.
+ o.addProperty("found", w.getEvalCount());
+ o.addProperty("distinct", w.getSecondary());
+ // Actions sharing a predicate share a cost model; the tree is
+ // listed once and referenced by the others.
+ if (seen.add(w)) {
+ o.add("tree", subtree(w));
+ o.add("unevaluated", unevaluated(w));
+ } else {
+ o.addProperty("tree_shared", true);
+ }
+ actions.add(o);
+ }
+ out.add("actions", actions);
+
+ final JsonArray inits = new JsonArray();
+ final Vect init = tool.getInitStateSpec();
+ for (int i = 0; i < init.size(); i++) {
+ final Action a = init.elementAt(i);
+ if (!(a.cm instanceof ActionWrapper)) {
+ continue;
+ }
+ final ActionWrapper w = (ActionWrapper) a.cm;
+ final JsonObject o = new JsonObject();
+ o.addProperty("location", a.getLocation());
+ o.addProperty("found", w.getEvalCount());
+ o.addProperty("distinct", w.getEvalCount() + w.getSecondary());
+ o.add("tree", subtree(w));
+ o.add("unevaluated", unevaluated(w));
+ inits.add(o);
+ }
+ out.add("init", inits);
+
+ final JsonArray invariants = new JsonArray();
+ final String[] names = tool.getInvNames();
+ final Action[] invs = tool.getInvariants();
+ for (int i = 0; i < invs.length; i++) {
+ final Action a = invs[i];
+ if (!(a.cm instanceof ActionWrapper)) {
+ continue;
+ }
+ final ActionWrapper w = (ActionWrapper) a.cm;
+ final JsonObject o = new JsonObject();
+ o.addProperty("name", i < names.length ? names[i] : a.getNameOfDefault());
+ o.addProperty("location", a.getLocation());
+ o.add("tree", subtree(w));
+ o.add("unevaluated", unevaluated(w));
+ invariants.add(o);
+ }
+ out.add("invariants", invariants);
+
+ final JsonArray variables = new JsonArray();
+ for (final OpDeclNode odn : tool.getSpecProcessor().getVariablesNodes()) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("name", odn.getName().toString());
+ o.addProperty("location", odn.getLocation().toString());
+ final long count = odn.getCountDistinct() == null ? -1 : odn.getCountDistinct().count();
+ // -1 means nothing counted (Noop, or before exploration).
+ if (count >= 0) {
+ o.addProperty("distinct_values_approx", count);
+ } else {
+ o.add("distinct_values_approx", null);
+ }
+ variables.add(o);
+ }
+ out.add("variables", variables);
+ return out;
+ }
+
+ private static JsonArray subtree(final CostModelNode parent) {
+ final JsonArray out = new JsonArray();
+ for (final CostModelNode child : parent.children.values()) {
+ out.add(node(child));
+ }
+ return out;
+ }
+
+ private static JsonObject node(final CostModelNode n) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("location", n.getLocation().toString());
+ if (n instanceof OpApplNodeWrapper) {
+ final OpApplNodeWrapper w = (OpApplNodeWrapper) n;
+ o.addProperty("primary", w.getEvalCount(Calculate.FRESH));
+ o.addProperty("secondary", w.getSecondCount(Calculate.FRESH));
+ o.addProperty("primed", w.isPrimed());
+ final SemanticNode sn = w.getNode();
+ if (sn != null) {
+ o.addProperty("text", tlc2.basis.GraphStore.text(sn));
+ }
+ } else {
+ o.addProperty("primary", n.getEvalCount());
+ o.addProperty("secondary", n.getSecondary());
+ }
+ final JsonArray children = subtree(n);
+ if (children.size() > 0) {
+ o.add("children", children);
+ }
+ return o;
+ }
+
+ /**
+ * The subexpressions under {@code root} whose own evaluation count is
+ * zero and below which nothing ran, assignments included, listed flat
+ * with their locations. The inside of an assignment is not listed: its
+ * primed side is a target, never evaluated.
+ */
+ private static JsonArray unevaluated(final CostModelNode root) {
+ final JsonArray out = new JsonArray();
+ collectUnevaluated(root, out);
+ return out;
+ }
+
+ private static void collectUnevaluated(final CostModelNode parent, final JsonArray out) {
+ if (parent instanceof OpApplNodeWrapper && ((OpApplNodeWrapper) parent).isPrimed()) {
+ // Under a primed node (x' = e) the left side is an assignment
+ // target and never evaluated; nothing there is a dead conjunct.
+ return;
+ }
+ for (final CostModelNode child : parent.children.values()) {
+ if (child instanceof OpApplNodeWrapper) {
+ final OpApplNodeWrapper w = (OpApplNodeWrapper) child;
+ // A conjunction's own counter can stay at zero while its
+ // conjuncts run (the printer collapses such nodes into their
+ // children), so a node is unevaluated only when nothing below
+ // it ran either.
+ // An assignment (x' = e, marked primed) counts when it runs, so
+ // one whose count is zero with nothing below it run is dead too.
+ if (w.getEvalCount(Calculate.FRESH) == 0L && !anyEvaluated(w)) {
+ final JsonObject o = new JsonObject();
+ o.addProperty("location", w.getLocation().toString());
+ if (w.getNode() != null) {
+ o.addProperty("text", tlc2.basis.GraphStore.text(w.getNode()));
+ }
+ out.add(o);
+ // Its children are unevaluated too; one entry is enough.
+ continue;
+ }
+ }
+ collectUnevaluated(child, out);
+ }
+ }
+
+ private static boolean anyEvaluated(final CostModelNode node) {
+ for (final CostModelNode child : node.children.values()) {
+ if (child instanceof OpApplNodeWrapper) {
+ final OpApplNodeWrapper w = (OpApplNodeWrapper) child;
+ if (w.getEvalCount(Calculate.FRESH) > 0L) {
+ return true;
+ }
+ } else if (child.getEvalCount() > 0L) {
+ return true;
+ }
+ if (anyEvaluated(child)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Which relation an action wrapper records, for callers outside the package. */
+ public static boolean is(final CostModel cm, final Relation r) {
+ return cm instanceof ActionWrapper && ((ActionWrapper) cm).is(r);
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java
index 704a648b90..e86634902b 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java
@@ -898,6 +898,18 @@ public final StateVec getNextStates(Action action, TLCState state) {
return getNextStates(action, action.con, state);
}
+ /**
+ * Basis: {@link #getNextStates(Action, TLCState)} without counting the
+ * evaluation in the action's coverage, for queries made outside a run
+ * (the resident's successor queries) that must not change its report.
+ */
+ public final StateVec getNextStatesUnrecorded(final Action action, final TLCState state) {
+ final StateVec nss = new StateVec(0);
+ this.getNextStates(action, action.pred, ActionItemList.Empty, action.con, state,
+ TLCState.Empty.createEmpty().setPredecessor(state).setAction(action), nss, CostModel.DO_NOT_RECORD);
+ return nss;
+ }
+
public final StateVec getNextStates(final Action action, final Context ctx, final TLCState state) {
ActionItemList acts = ActionItemList.Empty;
TLCState s1 = TLCState.Empty.createEmpty();
@@ -1092,7 +1104,20 @@ protected TLCState processUnsatisfied(final TLCState s0, final Action action, fi
final SemanticNode pred, final Context c, final INextStateFunctor nss, final CostModel cm) {
return nss.addUnsatisfiedState(s0, action, s1, pred, c);
}
-
+
+ /**
+ * Basis: a guard conjunct evaluated false on a path where upstream TLC
+ * reported nothing. The functor hears of it (a constrained state writer
+ * tallies it), but unlike {@link #processUnsatisfied} this is not a
+ * debugger frame, and {@code s1} may still have unassigned variables.
+ * Returns {@code s1}, which is what these paths returned before.
+ */
+ private TLCState falseGuard(final TLCState s0, final Action action, final TLCState s1,
+ final SemanticNode pred, final Context c, final INextStateFunctor nss) {
+ nss.addUnsatisfiedState(s0, action, s1, pred, c);
+ return s1;
+ }
+
/* getNextStatesAppl */
@ExpectInlined
@@ -1179,7 +1204,8 @@ private final TLCState getNextStatesApplUsrDefOp(final Action action, final OpAp
return this.getNextStates0(action, acts, s0, s1, nss, cm);
}
}
- return s1;
+ // Basis: a user-defined guard evaluated false; its bindings are not at hand here.
+ return this.falseGuard(s0, action, s1, pred, Context.Empty, nss);
}
private final TLCState getNextStatesApplSwitch(final Action action, final OpApplNode pred, final ActionItemList acts, final Context c, final TLCState s0,
@@ -1357,7 +1383,7 @@ private final TLCState getNextStatesApplSwitch(final Action action, final OpAppl
if (var == null) {
Value bval = this.eval(pred, c, s0, s1, EvalControl.Clear, cm);
if (!((BoolValue)bval).val) {
- return resState;
+ return this.falseGuard(s0, action, resState, pred, c, nss);
}
}
else {
@@ -1371,7 +1397,7 @@ private final TLCState getNextStatesApplSwitch(final Action action, final OpAppl
return resState;
}
else if (!lval.equals(rval)) {
- return resState;
+ return this.falseGuard(s0, action, resState, pred, c, nss);
}
}
return this.getNextStates(action, acts, s0, s1, nss, cm);
@@ -1382,7 +1408,7 @@ else if (!lval.equals(rval)) {
if (var == null) {
Value bval = this.eval(pred, c, s0, s1, EvalControl.Clear, cm);
if (!((BoolValue)bval).val) {
- return resState;
+ return this.falseGuard(s0, action, resState, pred, c, nss);
}
}
else {
@@ -1414,7 +1440,7 @@ else if (!lval.equals(rval)) {
return resState;
}
else if (!rval.member(lval)) {
- return resState;
+ return this.falseGuard(s0, action, resState, pred, c, nss);
}
}
return this.getNextStates(action, acts, s0, s1, nss, cm);
@@ -1426,7 +1452,7 @@ else if (!rval.member(lval)) {
if (var == null) {
Value bval = this.eval(pred, c, s0, s1, EvalControl.Clear, cm);
if (!((BoolValue)bval).val) {
- return resState;
+ return this.falseGuard(s0, action, resState, pred, c, nss);
}
}
else {
@@ -1462,7 +1488,7 @@ else if (!rval.member(lval)) {
return resState;
}
else if (!rval.member(lval)) {
- return resState;
+ return this.falseGuard(s0, action, resState, pred, c, nss);
}
}
return this.getNextStates(action, acts, s0, s1, nss, cm);
@@ -1545,6 +1571,10 @@ public Object addElement(final TLCState t, final Action a, final TLCState u) {
}
if (((BoolValue)bval).val) {
resState = this.getNextStates(action, acts, s0, s1, nss, cm);
+ } else {
+ // Basis: a guard conjunct evaluated false on the general path too,
+ // not only when every primed variable was already assigned.
+ return this.falseGuard(s0, action, s1, pred, c, nss);
}
return resState;
}
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java
index 62d446e943..8f944b0524 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java
@@ -65,6 +65,30 @@ default boolean isSet(int v, int control) {
void writeState(TLCState state, TLCState successor, short stateFlags, Action action, SemanticNode pred);
+ /**
+ * An initial state that a state constraint excludes from the model. TLC
+ * checks invariants and state-level properties on it but never explores
+ * it. The default ignores it, as upstream writers always have.
+ */
+ default void writeExcludedInitial(TLCState state) {
+ }
+
+ /**
+ * A guard conjunct {@code pred} of {@code action} evaluated false at
+ * {@code state} under the bindings in {@code c}, so the transition to
+ * {@code successor} was not taken. Delivered only to a constrained writer.
+ * {@code successor} may have unassigned variables (a guard can fail before
+ * any primed variable is assigned). The default keeps the older behaviour:
+ * it drops the context and forwards only a fully assigned successor, the
+ * only kind upstream writers ever received here.
+ */
+ default void writeUnsatisfied(TLCState state, Action action, TLCState successor, SemanticNode pred,
+ tlc2.util.Context c) {
+ if (successor != null && successor.allAssigned()) {
+ writeState(state, successor, IsNotInModel, action, pred);
+ }
+ }
+
void writeState(TLCState state, TLCState successor, short stateFlags, Visualization visualization);
void writeState(TLCState state, TLCState successor, BitVector actionChecks, int from, int length, short stateFlags);
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java
index ffaa36e9d5..afeccb75b5 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java
@@ -15,7 +15,7 @@
public final class ValueOutputStream implements IValueOutputStream {
private final BufferedDataOutputStream dos;
- private final HandleTable handles;
+ private HandleTable handles;
public ValueOutputStream(File file) throws IOException {
this(file, TLCGlobals.useGZIP);
@@ -70,6 +70,16 @@ public final void writeLong(long x) throws IOException {
public final void close() throws IOException {
this.dos.close();
}
+
+ /**
+ * Basis: flush what was written and forget its handles, so the next value
+ * is encoded on its own and one stream can serialise many states that are
+ * later decoded separately.
+ */
+ public final void reset() throws IOException {
+ this.dos.flush();
+ this.handles = new HandleTable();
+ }
/* Precondition: x is a non-negative short. */
@Override
diff --git a/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java b/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java
index 9077295e58..f3231e0f2d 100644
--- a/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java
+++ b/tlatools/org.lamport.tlatools/src/tlc2/value/impl/ModelValue.java
@@ -118,6 +118,56 @@ public static Value add(String str) {
return mv;
}
+ /**
+ * Basis: the process-global model-value table, as {@link #snapshot} took
+ * it. Every parse resets the table ({@link #init}), and serialised values
+ * name a model value by its index here, so a caller that parses a second
+ * spec in one process and keeps the first puts the first one's table back.
+ */
+ public static final class Table {
+ private final int count;
+ private final Hashtable table;
+ private final ModelValue[] values;
+
+ private Table(final int count, final Hashtable table, final ModelValue[] values) {
+ this.count = count;
+ this.table = table;
+ this.values = values;
+ }
+
+ /**
+ * Whether every model value of this table has the same index in
+ * {@code later}: a value serialised under this table then decodes to the
+ * same model value under {@code later}, which may have added more.
+ */
+ public boolean isPrefixOf(final Table later) {
+ final ModelValue[] mine = this.values == null ? new ModelValue[0] : this.values;
+ final ModelValue[] theirs = later.values == null ? new ModelValue[0] : later.values;
+ if (mine.length > theirs.length) {
+ return false;
+ }
+ for (int i = 0; i < mine.length; i++) {
+ if (mine[i] == null || theirs[i] == null || !mine[i].val.equals(theirs[i].val)
+ || mine[i].type != theirs[i].type) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ /** Basis: the current model-value table, to {@link #restore} later. */
+ public static synchronized Table snapshot() {
+ return new Table(count, mvTable, mvs);
+ }
+
+ /** Basis: put back a table {@link #snapshot} took; {@link #init} replaced it, it was not mutated. */
+ public static synchronized void restore(final Table t) {
+ count = t.count;
+ mvTable = t.table;
+ mvs = t.values;
+ }
+
/* Collect all the model values defined thus far. */
public static void setValues() {
mvs = new ModelValue[mvTable.size()];
diff --git a/tlatools/org.lamport.tlatools/test-model/basis/Guards.cfg b/tlatools/org.lamport.tlatools/test-model/basis/Guards.cfg
new file mode 100644
index 0000000000..e3e8c3d250
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test-model/basis/Guards.cfg
@@ -0,0 +1,2 @@
+INIT Init
+NEXT Next
diff --git a/tlatools/org.lamport.tlatools/test-model/basis/Guards.tla b/tlatools/org.lamport.tlatools/test-model/basis/Guards.tla
new file mode 100644
index 0000000000..8545285291
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test-model/basis/Guards.tla
@@ -0,0 +1,13 @@
+---- MODULE Guards ----
+EXTENDS Naturals
+VARIABLES x, pc
+
+Init == x = 0 /\ pc = "a"
+
+\* Each guard is false somewhere before any primed variable is assigned.
+A == pc = "a" /\ x' = x + 1 /\ pc' = "b"
+B == pc = "b" /\ x \in {1} /\ x' = x + 1 /\ pc' = "c"
+C == x < 1 /\ x' = x /\ pc' = "c"
+
+Next == A \/ B \/ C
+====
diff --git a/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.cfg b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.cfg
new file mode 100644
index 0000000000..070a9d7a22
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.cfg
@@ -0,0 +1,3 @@
+INIT Init
+NEXT Next
+CONSTRAINT Small
diff --git a/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.tla b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.tla
new file mode 100644
index 0000000000..cd1331dca7
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.tla
@@ -0,0 +1,13 @@
+---- MODULE GuardsConstraint ----
+EXTENDS Naturals
+VARIABLES x
+
+Init == x = 0
+
+A == x < 5 /\ x' = x + 1
+B == x = 10 /\ x' = 0
+
+Next == A \/ B
+
+Small == x < 3
+====
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/TLCGlobalsContinuationTraceTest.java b/tlatools/org.lamport.tlatools/test/tlc2/TLCGlobalsContinuationTraceTest.java
new file mode 100644
index 0000000000..013f0878ff
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/TLCGlobalsContinuationTraceTest.java
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.After;
+import org.junit.Test;
+
+public class TLCGlobalsContinuationTraceTest {
+
+ @After
+ public void restore() {
+ TLCGlobals.continuationTraceLimit = -1;
+ TLCGlobals.resetContinuationTraces();
+ }
+
+ @Test
+ public void unlimitedByDefault() {
+ TLCGlobals.continuationTraceLimit = -1;
+ for (int i = 0; i < 10; i++) {
+ assertTrue(TLCGlobals.continuationTraceAllowed("Inv"));
+ }
+ }
+
+ @Test
+ public void capIsPerProperty() {
+ TLCGlobals.continuationTraceLimit = 2;
+ assertTrue(TLCGlobals.continuationTraceAllowed("Inv"));
+ assertTrue(TLCGlobals.continuationTraceAllowed("Inv"));
+ assertFalse(TLCGlobals.continuationTraceAllowed("Inv"));
+ assertTrue(TLCGlobals.continuationTraceAllowed("Other"));
+ assertTrue(TLCGlobals.continuationTraceAllowed(null));
+ TLCGlobals.resetContinuationTraces();
+ assertTrue(TLCGlobals.continuationTraceAllowed("Inv"));
+ }
+
+ @Test
+ public void zeroPrintsNone() {
+ TLCGlobals.continuationTraceLimit = 0;
+ assertFalse(TLCGlobals.continuationTraceAllowed("Inv"));
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreConstraintTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreConstraintTest.java
new file mode 100644
index 0000000000..f9db08303b
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreConstraintTest.java
@@ -0,0 +1,108 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+
+import tlc2.output.EC;
+import tlc2.output.EC.ExitStatus;
+import tlc2.tool.liveness.ModelCheckerTestCase;
+import tlc2.util.IStateWriter;
+
+/**
+ * A successor a state constraint excludes is tallied as a constraint row, not
+ * as a guard of the action that generated it, and is counted apart from the
+ * false guards.
+ */
+public class GraphStoreConstraintTest extends ModelCheckerTestCase {
+
+ public GraphStoreConstraintTest() {
+ super("GuardsConstraint", "basis", new String[] { "-deadlock" }, ExitStatus.SUCCESS);
+ }
+
+ private GraphStore store;
+
+ @Override
+ protected boolean doDump() {
+ return false;
+ }
+
+ @Override
+ protected boolean doCoverage() {
+ return false;
+ }
+
+ @Override
+ protected int getNumberOfThreads() {
+ return 1;
+ }
+
+ @Override
+ protected IStateWriter getStateWriter(final IStateWriter sw) {
+ try {
+ store = new GraphStore(Files.createTempDirectory("graphstore").toString());
+ return store;
+ } catch (IOException e) {
+ fail(e.getMessage());
+ return null;
+ }
+ }
+
+ @Test
+ public void testSpec() {
+ assertTrue(recorder.recorded(EC.TLC_FINISHED));
+ assertFalse(recorder.recorded(EC.GENERAL));
+
+ // 0 -A-> 1 -A-> 2; A's step from 2 to 3 is excluded by Small.
+ assertEquals(3, store.states());
+ assertEquals(2, store.edges());
+
+ final Map rows = new HashMap<>();
+ for (final GraphStore.Blocked b : store.blocked()) {
+ rows.put(b.kind + " " + b.action, b);
+ }
+ assertEquals(rows.toString(), 2, rows.size());
+ // B's guard fails at 0, 1 and 2.
+ final GraphStore.Blocked guard = rows.get("guard B");
+ assertNotNull(rows.keySet().toString(), guard);
+ assertEquals("x=10", guard.text);
+ assertEquals(3, guard.count);
+ // The constraint dropped one successor A generated.
+ final GraphStore.Blocked constraint = rows.get("constraint A");
+ assertNotNull(rows.keySet().toString(), constraint);
+ assertEquals(1, constraint.count);
+ assertEquals(3, store.unsatisfied());
+ assertEquals(1, store.excluded());
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java
new file mode 100644
index 0000000000..348ea27d73
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java
@@ -0,0 +1,122 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+
+import tlc2.output.EC;
+import tlc2.output.EC.ExitStatus;
+import tlc2.tool.liveness.ModelCheckerTestCase;
+import tlc2.util.IStateWriter;
+
+/**
+ * The store on a real run: every reached state, every edge, and a tally for
+ * every guard that evaluated false, including equality and membership guards
+ * on unprimed variables, which TLC evaluates before any primed variable is
+ * assigned.
+ */
+public class GraphStoreGuardTest extends ModelCheckerTestCase {
+
+ public GraphStoreGuardTest() {
+ super("Guards", "basis", new String[] { "-deadlock" }, ExitStatus.SUCCESS);
+ }
+
+ private GraphStore store;
+
+ @Override
+ protected boolean doDump() {
+ return false;
+ }
+
+ @Override
+ protected boolean doCoverage() {
+ return false;
+ }
+
+ @Override
+ protected int getNumberOfThreads() {
+ return 1;
+ }
+
+ @Override
+ protected IStateWriter getStateWriter(final IStateWriter sw) {
+ try {
+ store = new GraphStore(Files.createTempDirectory("graphstore").toString());
+ return store;
+ } catch (IOException e) {
+ fail(e.getMessage());
+ return null;
+ }
+ }
+
+ @Test
+ public void testSpec() {
+ assertTrue(recorder.recorded(EC.TLC_FINISHED));
+ assertFalse(recorder.recorded(EC.GENERAL));
+ assertTrue(recorder.recordedWithStringValues(EC.TLC_STATS, "5", "4", "0"));
+
+ // (0,a) -A-> (1,b) -B-> (2,c); (0,a) -C-> (0,c) -C-> (0,c).
+ assertEquals(4, store.states());
+ assertEquals(1, store.initialStates());
+ assertEquals(4, store.edges());
+
+ final Map counts = new HashMap<>();
+ for (final GraphStore.Blocked b : store.blocked()) {
+ assertEquals("guard", b.kind);
+ counts.put(b.action + ": " + b.text, b.count);
+ }
+ // A's guard fails at (1,b), (0,c), (2,c); B's at (0,a), (0,c), (2,c);
+ // C's at (1,b), (2,c).
+ assertEquals(Long.valueOf(3), counts.get("A: pc=\"a\""));
+ assertEquals(Long.valueOf(3), counts.get("B: pc=\"b\""));
+ assertEquals(Long.valueOf(2), counts.get("C: x<1"));
+ assertEquals(3, counts.size());
+ assertEquals(8, store.unsatisfied());
+ assertEquals(0, store.excluded());
+
+ // Every stored state reads back, and the deepest one's path is the
+ // three-state behaviour through A and B.
+ long deepest = 0;
+ for (final long fp : store.fingerprints()) {
+ assertNotNull(store.read(fp));
+ if (store.level(fp) == 3) {
+ deepest = fp;
+ }
+ }
+ final long[][] path = store.pathTo(deepest);
+ assertEquals(3, path.length);
+ assertEquals("A", store.action((int) path[1][1]).getNameOfDefault());
+ assertEquals("B", store.action((int) path[2][1]).getNameOfDefault());
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentConcurrentSuspendTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentConcurrentSuspendTest.java
new file mode 100644
index 0000000000..1dd7f5df57
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentConcurrentSuspendTest.java
@@ -0,0 +1,103 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+import tlc2.tool.ModelChecker;
+
+/**
+ * The resident's pause and TLC's periodic work (a liveness check or a
+ * checkpoint) can both wait for the workers to park at once. The queue
+ * wakes a single waiter when the last worker parks, so both must not wait
+ * there together: with every state's expansion slow, the two start waiting
+ * while workers are mid-expansion, and both must return.
+ */
+public class ResidentConcurrentSuspendTest {
+
+ @Test(timeout = 120_000)
+ public void testPauseAndPeriodicWorkSuspendTogether() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("S.cfg", "INIT Init\nNEXT Next\n");
+ h.write("S.tla", "---- MODULE S ----\n" //
+ + "EXTENDS Naturals, FiniteSets\n" //
+ + "VARIABLES x, y\n" //
+ + "Heavy == Cardinality({s \\in SUBSET (1..16) : 1 \\in s}) > x\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "A == Heavy /\\ x < 4 /\\ x' = x + 1 /\\ UNCHANGED y\n" //
+ + "B == Heavy /\\ y < 4 /\\ y' = y + 1 /\\ UNCHANGED x\n" //
+ + "Next == A \\/ B\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S")
+ + "\",\"workers\":2,\"deadlock\":false,\"coverage\":false,\"store\":false}");
+ final JsonObject paused = h.ok("{\"command\":\"check\",\"budget_states\":3}");
+ assertFalse(paused.get("finished").getAsBoolean());
+
+ final Field f = Resident.class.getDeclaredField("checker");
+ f.setAccessible(true);
+ final ModelChecker checker = (ModelChecker) f.get(h.resident);
+ final Method periodicSuspend = ModelChecker.class.getDeclaredMethod("periodicSuspend");
+ final Method periodicResume = ModelChecker.class.getDeclaredMethod("periodicResume");
+ periodicSuspend.setAccessible(true);
+ periodicResume.setAccessible(true);
+
+ final ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ for (int round = 0; round < 3; round++) {
+ checker.resume();
+ // Let the workers get into their (slow) expansions.
+ Thread.sleep(30);
+ final Future> periodic = pool.submit(() -> periodicSuspend.invoke(checker));
+ final Future> pause = pool.submit(() -> {
+ checker.suspend();
+ return null;
+ });
+ periodic.get(30, TimeUnit.SECONDS);
+ pause.get(30, TimeUnit.SECONDS);
+ // The periodic work ends; the resident's pause still holds.
+ periodicResume.invoke(checker);
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+
+ final JsonObject done = h.ok("{\"command\":\"check\"}");
+ assertTrue(done.get("finished").getAsBoolean());
+ assertEquals("ok", done.get("verdict").getAsString());
+ assertEquals(25, done.getAsJsonObject("stats").get("distinct").getAsLong());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationBudgetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationBudgetTest.java
new file mode 100644
index 0000000000..bf7897aa7a
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationBudgetTest.java
@@ -0,0 +1,79 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A budgeted check that continues past violations, on several workers, over
+ * a spec whose invariant fails on most states. Pausing must not deadlock
+ * with workers reporting violations, and reports past the per-property
+ * trace cap must be counted rather than kept as messages.
+ */
+public class ResidentContinuationBudgetTest {
+
+ @Test(timeout = 120_000)
+ public void testBudgetedContinuation() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("C.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("C.tla", "---- MODULE C ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES a, b, c\n" //
+ + "N == 20\n" //
+ + "Init == a = 0 /\\ b = 0 /\\ c = 0\n" //
+ + "A == a < N-1 /\\ a' = a + 1 /\\ UNCHANGED <>\n" //
+ + "B == b < N-1 /\\ b' = b + 1 /\\ UNCHANGED <>\n" //
+ + "C == c < N-1 /\\ c' = c + 1 /\\ UNCHANGED <>\n" //
+ + "Next == A \\/ B \\/ C\n" //
+ + "Inv == a < 3\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("C") + "\",\"workers\":4,\"deadlock\":false,\"store\":false}");
+
+ JsonObject check = null;
+ long messages = 0;
+ int pauses = 0;
+ for (int i = 0; i < 10_000; i++) {
+ check = h.ok("{\"command\":\"check\",\"continue\":true,\"budget_ms\":5}");
+ messages += check.getAsJsonArray("messages").size();
+ if (check.get("finished").getAsBoolean()) {
+ break;
+ }
+ pauses++;
+ }
+ assertTrue(check.toString(), check.get("finished").getAsBoolean());
+ assertTrue("the budget never paused the run", pauses > 0);
+ assertEquals("invariant_violated", check.get("verdict").getAsString());
+ assertEquals(8000, check.getAsJsonObject("stats").get("distinct").getAsLong());
+ // Every state with a >= 3 violates Inv: 17 * 20 * 20 reports, one traced.
+ final JsonObject inv = check.getAsJsonArray("invariants").get(0).getAsJsonObject();
+ assertEquals(6800, inv.get("reports").getAsLong());
+ assertEquals(6799, check.getAsJsonObject("untraced_reports").get("Inv").getAsLong());
+ assertTrue("reports past the cap were kept as messages: " + messages, messages < 200);
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationExcludedTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationExcludedTest.java
new file mode 100644
index 0000000000..be646f2f24
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationExcludedTest.java
@@ -0,0 +1,62 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static tlc2.basis.ResidentContinuationInvariantsTest.verdict;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Under continuation TLC stops checking invariants on a state at the first
+ * one it violates, excluded successors included. The sweep that decides the
+ * skipped invariants must reach the excluded successors as TLC does.
+ */
+public class ResidentContinuationExcludedTest {
+
+ @Test
+ public void testSkippedInvariantOnExcludedState() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("CX.cfg", "INIT Init\nNEXT Next\nINVARIANT InvA\nINVARIANT InvB\nCONSTRAINT Cons\n");
+ h.write("CX.tla", "---- MODULE CX ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x' = x + 1\n" //
+ + "InvA == x < 2\n" //
+ + "InvB == x # 3\n" //
+ + "Cons == x < 3\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CX") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}");
+ assertEquals(check.toString(), "violated", verdict(check, "InvA").get("verdict").getAsString());
+ // x = 3 is excluded and fails InvA first, so TLC never checks InvB there.
+ final JsonObject b = verdict(check, "InvB");
+ assertEquals(check.toString(), "violated", b.get("verdict").getAsString());
+ assertEquals(check.toString(), "store", b.get("source").getAsString());
+ assertEquals(check.toString(), 4, b.get("level").getAsInt());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsNoStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsNoStoreTest.java
new file mode 100644
index 0000000000..a2b148b95f
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsNoStoreTest.java
@@ -0,0 +1,53 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Without a store the states TLC skipped under continuation cannot be
+ * revisited: an invariant TLC never reported gets no verdict either way.
+ */
+public class ResidentContinuationInvariantsNoStoreTest {
+
+ @Test
+ public void testSkippedInvariantIsNotEvaluated() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("CI.cfg", ResidentContinuationInvariantsTest.CFG);
+ h.write("CI.tla", ResidentContinuationInvariantsTest.SPEC);
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CI")
+ + "\",\"workers\":1,\"deadlock\":false,\"store\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}");
+ assertEquals(check.toString(), "violated",
+ ResidentContinuationInvariantsTest.verdict(check, "InvA").get("verdict").getAsString());
+ assertEquals(check.toString(), "not_evaluated",
+ ResidentContinuationInvariantsTest.verdict(check, "InvB").get("verdict").getAsString());
+ assertEquals(check.toString(), "not_evaluated",
+ ResidentContinuationInvariantsTest.verdict(check, "InvC").get("verdict").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsTest.java
new file mode 100644
index 0000000000..dc31f4ed2a
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsTest.java
@@ -0,0 +1,81 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+/**
+ * Under continuation TLC checks no further invariant on a state once one
+ * fails there. InvB fails exactly where InvA does, so TLC never reports it;
+ * the resident must still find it violated, over the store, and the
+ * registers must say the exhausted run found violations.
+ */
+public class ResidentContinuationInvariantsTest {
+
+ static final String SPEC = "---- MODULE CI ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x < 5 /\\ x' = x + 1\n" //
+ + "InvA == x # 3\n" //
+ + "InvB == x # 3\n" //
+ + "InvC == x < 10\n" //
+ + "====\n";
+ static final String CFG = "INIT Init\nNEXT Next\nINVARIANT InvA\nINVARIANT InvB\nINVARIANT InvC\n";
+
+ static JsonObject verdict(final JsonObject check, final String name) {
+ final JsonArray invs = check.getAsJsonArray("invariants");
+ for (int i = 0; i < invs.size(); i++) {
+ final JsonObject v = invs.get(i).getAsJsonObject();
+ if (name.equals(v.get("name").getAsString())) {
+ return v;
+ }
+ }
+ throw new AssertionError("no verdict for " + name + " in " + check);
+ }
+
+ @Test
+ public void testSkippedInvariantIsSwept() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("CI.cfg", CFG);
+ h.write("CI.tla", SPEC);
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CI") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}");
+ assertEquals(check.toString(), "violated", verdict(check, "InvA").get("verdict").getAsString());
+ final JsonObject b = verdict(check, "InvB");
+ assertEquals(check.toString(), "violated", b.get("verdict").getAsString());
+ assertEquals(1, b.get("reports").getAsLong());
+ assertEquals(4, b.get("level").getAsInt());
+ assertEquals("store", b.get("source").getAsString());
+ assertEquals(check.toString(), "no_violation_found", verdict(check, "InvC").get("verdict").getAsString());
+
+ final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers");
+ assertEquals(registers.toString(), "exhausted_with_violations", registers.get("stopped_by").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationTest.java
new file mode 100644
index 0000000000..81ff93ea35
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationTest.java
@@ -0,0 +1,76 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A run that stopped at its first violation did not explore the whole
+ * graph, and asking for continuation after it ended must not make it look
+ * as if it had: the next refresh must restart rather than replay a partial
+ * graph.
+ */
+public class ResidentContinuationTest {
+
+ private static String spec(final String inv) {
+ return "---- MODULE K ----\n" //
+ + "EXTENDS Integers\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "A == x \\in 0..4 /\\ x' = x + 1\n" //
+ + "Next == A\n" //
+ + "Inv == " + inv + "\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testContinuationAfterTheRunEnded() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("K.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("K.tla", spec("x < 3"));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("K") + "\",\"workers\":1,\"deadlock\":false}");
+ JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals("invariant_violated", check.get("verdict").getAsString());
+ assertFalse(check.get("continuation").getAsBoolean());
+
+ // The run has ended; this explores nothing and changes nothing.
+ check = h.ok("{\"command\":\"check\",\"continue\":true}");
+ assertFalse(check.toString(), check.get("continuation").getAsBoolean());
+ final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers");
+ assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean());
+ assertEquals("violation", registers.get("stopped_by").getAsString());
+
+ // x = 5 is reachable but was never generated: no replay can know.
+ h.write("K.tla", spec("x /= 5"));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "full", r.get("mode").getAsString());
+ assertTrue(r.get("restart_required").getAsBoolean());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java
new file mode 100644
index 0000000000..fdaba127a6
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java
@@ -0,0 +1,73 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static tlc2.basis.ResidentContinuationInvariantsTest.verdict;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A run paused under continuation after InvA was reported, then resumed
+ * without it. TLC skipped InvB on the state where InvA failed while it
+ * continued, so InvB's silence is still no verdict: the resident must sweep
+ * it over the store, not call it clean because the last call did not continue.
+ */
+public class ResidentContinuationToggleTest {
+
+ static final String SPEC = "---- MODULE CT ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ // Enough states that the six-state budget below takes effect before
+ // the run ends: the budget is polled, not enforced per state.
+ + "Next == x < 200000 /\\ x' = x + 1\n" //
+ + "InvA == x # 3\n" //
+ + "InvB == x # 3\n" //
+ + "====\n";
+ static final String CFG = "INIT Init\nNEXT Next\nINVARIANT InvA\nINVARIANT InvB\n";
+
+ @Test
+ public void testResumeWithoutContinuationStillSweeps() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("CT.cfg", CFG);
+ h.write("CT.tla", SPEC);
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("CT") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject paused = h.ok("{\"command\":\"check\",\"continue\":true,\"budget_states\":6}");
+ assertFalse(paused.toString(), paused.get("finished").getAsBoolean());
+ assertEquals(paused.toString(), "violated", verdict(paused, "InvA").get("verdict").getAsString());
+
+ final JsonObject done = h.ok("{\"command\":\"check\",\"continue\":false}");
+ assertTrue(done.toString(), done.get("finished").getAsBoolean());
+ assertEquals(done.toString(), "violated", verdict(done, "InvA").get("verdict").getAsString());
+ final JsonObject b = verdict(done, "InvB");
+ assertEquals(done.toString(), "violated", b.get("verdict").getAsString());
+ assertEquals("store", b.get("source").getAsString());
+ assertEquals(4, b.get("level").getAsInt());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java
new file mode 100644
index 0000000000..100ac735f2
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java
@@ -0,0 +1,99 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * Coverage as data: a conjunct no evaluation reached is listed under
+ * {@code unevaluated}, a primed conjunct (an assignment) is not, and an
+ * action whose guards all ran lists nothing. After an incremental refresh
+ * the coverage is still the run's, marked stale, not an empty tree.
+ */
+public class ResidentCoverageTest {
+
+ private static String spec(final int dLimit) {
+ return "---- MODULE V ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x\n" //
+ + "Init == x = 0\n" //
+ + "A == x < 3 /\\ x' = x + 1\n" //
+ + "D == x > " + dLimit + " /\\ x * 2 > 25 /\\ x' = 0\n" //
+ + "Next == A \\/ D\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testUnevaluated() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("V.cfg", "INIT Init\nNEXT Next\n");
+ h.write("V.tla", spec(10));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("V") + "\",\"workers\":1,\"deadlock\":false}");
+ h.ok("{\"command\":\"check\"}");
+ final JsonObject coverage = h.ok("{\"command\":\"coverage\"}").getAsJsonObject("coverage");
+ assertTrue(coverage.get("enabled").getAsBoolean());
+ final Map actions = new HashMap<>();
+ for (final JsonElement e : coverage.getAsJsonArray("actions")) {
+ actions.put(e.getAsJsonObject().get("name").getAsString(), e.getAsJsonObject());
+ }
+ assertEquals(3, actions.get("A").get("found").getAsLong());
+ assertEquals(0, actions.get("A").getAsJsonArray("unevaluated").size());
+ assertEquals(0, actions.get("D").get("found").getAsLong());
+ // Past D's first guard nothing ran: the second guard, and the
+ // assignment, whose only evaluated part would be its primed side.
+ final JsonArray dead = actions.get("D").getAsJsonArray("unevaluated");
+ assertEquals(dead.toString(), 2, dead.size());
+ assertEquals("x*2>25", dead.get(0).getAsJsonObject().get("text").getAsString());
+ assertEquals("x'=0", dead.get(1).getAsJsonObject().get("text").getAsString());
+ assertTrue(h.ok("{\"command\":\"coverage\"}").get("stale") == null);
+
+ // Queries over the store evaluate outside the run and leave its
+ // coverage as it was.
+ final String fp = h.ok("{\"command\":\"screen\",\"candidates\":[\"x # 1\"]}").getAsJsonArray("results")
+ .get(0).getAsJsonObject().get("first_violation_fp").getAsString();
+ for (int i = 0; i < 3; i++) {
+ h.ok("{\"command\":\"neighbours\",\"fp\":\"" + fp + "\"}");
+ h.ok("{\"command\":\"eval\",\"fp\":\"" + fp + "\",\"expr\":\"x + 1\"}");
+ }
+ assertEquals(coverage.toString(), h.ok("{\"command\":\"coverage\"}").getAsJsonObject("coverage").toString());
+
+ // A refresh replaces the tool with one no checker ran; coverage stays
+ // the run's, and says it is stale.
+ h.write("V.tla", spec(11));
+ assertEquals("incremental", h.ok("{\"command\":\"refresh\"}").get("mode").getAsString());
+ final JsonObject after = h.ok("{\"command\":\"coverage\"}");
+ assertTrue(after.toString(), after.get("stale").getAsBoolean());
+ assertEquals(after.toString(), 2, after.getAsJsonObject("coverage").getAsJsonArray("actions").size());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedContinueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedContinueTest.java
new file mode 100644
index 0000000000..1e8f88dcc1
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedContinueTest.java
@@ -0,0 +1,81 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+/**
+ * An invariant that fails to evaluate is not violated: its verdict says it
+ * could not be evaluated, and the run that stopped on the error did not
+ * explore the whole graph. Under continuation TLC prints the failing
+ * behaviour a second time when it rebuilds the call stack; the trace holds
+ * it once.
+ */
+public class ResidentEvaluationFailedContinueTest {
+
+ @Test
+ public void testEvaluationFailure() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("E.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("E.tla", "---- MODULE E ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x < 5 /\\ x' = x + 1\n" //
+ + "Inv == IF x = 3 THEN (1 \\div 0) = 1 ELSE TRUE\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("E") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}");
+ assertTrue(check.toString(), check.get("finished").getAsBoolean());
+ assertEquals(check.toString(), "evaluation_failed", check.get("verdict").getAsString());
+
+ final JsonArray invariants = check.getAsJsonArray("invariants");
+ assertEquals(1, invariants.size());
+ final JsonObject inv = invariants.get(0).getAsJsonObject();
+ assertEquals(inv.toString(), "not_evaluable", inv.get("verdict").getAsString());
+ assertFalse(inv.toString(), inv.has("reports"));
+ assertTrue(inv.toString(), inv.has("error"));
+ // x = 3 is the fourth state of the behaviour.
+ assertEquals(inv.toString(), 4, inv.get("level").getAsInt());
+
+ final JsonObject trace = check.getAsJsonObject("trace");
+ assertEquals(trace.toString(), 4, trace.get("length").getAsInt());
+ final JsonArray states = trace.getAsJsonArray("states");
+ for (int i = 0; i < states.size(); i++) {
+ assertEquals(states.toString(), i + 1, states.get(i).getAsJsonObject().get("ordinal").getAsInt());
+ }
+
+ // x = 4 and x = 5 were never explored.
+ final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers");
+ assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean());
+ assertEquals(registers.toString(), "error", registers.get("stopped_by").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedTest.java
new file mode 100644
index 0000000000..2c9f8ca5bb
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedTest.java
@@ -0,0 +1,79 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+/**
+ * An invariant that fails to evaluate is not violated: its verdict says it
+ * could not be evaluated, and the run that stopped on the error did not
+ * explore the whole graph.
+ */
+public class ResidentEvaluationFailedTest {
+
+ @Test
+ public void testEvaluationFailure() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("E.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("E.tla", "---- MODULE E ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x < 5 /\\ x' = x + 1\n" //
+ + "Inv == IF x = 3 THEN (1 \\div 0) = 1 ELSE TRUE\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("E") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":false}");
+ assertTrue(check.toString(), check.get("finished").getAsBoolean());
+ assertEquals(check.toString(), "evaluation_failed", check.get("verdict").getAsString());
+
+ final JsonArray invariants = check.getAsJsonArray("invariants");
+ assertEquals(1, invariants.size());
+ final JsonObject inv = invariants.get(0).getAsJsonObject();
+ assertEquals(inv.toString(), "not_evaluable", inv.get("verdict").getAsString());
+ assertFalse(inv.toString(), inv.has("reports"));
+ assertTrue(inv.toString(), inv.has("error"));
+ // x = 3 is the fourth state of the behaviour.
+ assertEquals(inv.toString(), 4, inv.get("level").getAsInt());
+
+ final JsonObject trace = check.getAsJsonObject("trace");
+ assertEquals(trace.toString(), 4, trace.get("length").getAsInt());
+ final JsonArray states = trace.getAsJsonArray("states");
+ for (int i = 0; i < states.size(); i++) {
+ assertEquals(states.toString(), i + 1, states.get(i).getAsJsonObject().get("ordinal").getAsInt());
+ }
+
+ // x = 4 and x = 5 were never explored.
+ final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers");
+ assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean());
+ assertEquals(registers.toString(), "error", registers.get("stopped_by").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java
new file mode 100644
index 0000000000..09bd63e203
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java
@@ -0,0 +1,79 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+/**
+ * Drives a {@link Resident} in-process, one request object at a time, over a
+ * spec written into a fresh directory. TLC's statics allow one resident per
+ * JVM; the build forks one per test class.
+ */
+final class ResidentHarness {
+
+ final Path dir;
+ final Resident resident = Resident.install();
+
+ ResidentHarness() throws IOException {
+ dir = Files.createTempDirectory("resident");
+ }
+
+ void write(final String name, final String text) throws IOException {
+ Files.write(dir.resolve(name), text.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * The module's path, escaped for a JSON string literal: tests splice it
+ * into request text, and a Windows path's backslashes are escapes there.
+ */
+ String spec(final String module) {
+ return dir.resolve(module + ".tla").toString().replace("\\", "\\\\");
+ }
+
+ /** Serve {@code json}; the reply must be ok. */
+ JsonObject ok(final String json) throws Exception {
+ final JsonObject reply = call(json);
+ assertTrue(reply.toString(), reply.get("ok").getAsBoolean());
+ return reply;
+ }
+
+ JsonObject call(final String json) throws Exception {
+ return resident.serve(JsonParser.parseString(json).getAsJsonObject());
+ }
+
+ static long storeStates(final JsonObject reply) {
+ return reply.getAsJsonObject("store").get("states").getAsLong();
+ }
+
+ static long storeEdges(final JsonObject reply) {
+ return reply.getAsJsonObject("store").get("edges").getAsLong();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java
new file mode 100644
index 0000000000..5e571dec9e
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java
@@ -0,0 +1,61 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A budget that runs out while the initial states are generated, in a run
+ * that then ends there (an initial state violates the invariant): no worker
+ * ever starts, and the budget's suspend must not wait for one.
+ */
+public class ResidentInitBudgetTest {
+
+ @Test(timeout = 120_000)
+ public void testBudgetDuringInit() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("I.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("I.tla", "---- MODULE I ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x\n" //
+ // Under 65536 initial states: the build caps direct memory so
+ // that OffHeapDiskFPSet evicts there, and an eviction during
+ // init trips DiskFPSet's checkFile assertion upstream too.
+ + "Init == x \\in 1..50000\n" //
+ + "Next == x' = x\n" //
+ + "Inv == x < 49000\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("I") + "\",\"workers\":2}");
+ // Returns rather than hanging, whether or not the run has ended yet.
+ h.ok("{\"command\":\"check\",\"budget_ms\":1}");
+ final JsonObject done = h.ok("{\"command\":\"check\"}");
+ assertTrue(done.toString(), done.get("finished").getAsBoolean());
+ assertEquals(done.toString(), "invariant_violated", done.get("verdict").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationContinueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationContinueTest.java
new file mode 100644
index 0000000000..fe4f707a1d
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationContinueTest.java
@@ -0,0 +1,78 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.Test;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * Under continuation every initial state that violates an invariant is
+ * listed with its state, those a state constraint excludes included, next to
+ * the traces of violations reached later.
+ */
+public class ResidentInitialViolationContinueTest {
+
+ @Test
+ public void testInitialViolationsAreListed() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("IC.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\nCONSTRAINT Bound\n");
+ h.write("IC.tla", "---- MODULE IC ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x \\in {0, 9, 20}\n" //
+ + "Next == x < 9 /\\ x' = x + 1\n" //
+ + "Small == x < 8\n" //
+ + "Bound == x < 15\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("IC") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}");
+ assertTrue(check.toString(), check.get("finished").getAsBoolean());
+ final Set initial = new HashSet<>();
+ int behaviours = 0;
+ for (final JsonElement e : check.getAsJsonArray("traces")) {
+ final JsonObject t = e.getAsJsonObject();
+ if (t.get("length").getAsInt() == 1) {
+ final JsonObject s = t.getAsJsonArray("states").get(0).getAsJsonObject();
+ assertEquals(check.toString(), "", s.get("action").getAsString());
+ initial.add(s.getAsJsonObject("vars").get("x").getAsInt());
+ } else {
+ behaviours++;
+ }
+ }
+ // 9 is in the model, 20 excluded by the constraint; 8 is reached from 0.
+ assertEquals(check.toString(), Set.of(9, 20), initial);
+ assertEquals(check.toString(), 1, behaviours);
+ final JsonObject small = check.getAsJsonArray("invariants").get(0).getAsJsonObject();
+ assertEquals(check.toString(), "violated", small.get("verdict").getAsString());
+ assertEquals(check.toString(), 1, small.get("level").getAsInt());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationNoStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationNoStoreTest.java
new file mode 100644
index 0000000000..a347016bdc
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationNoStoreTest.java
@@ -0,0 +1,58 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Without a store an initial state's violation still has its one-state
+ * trace, the state kept as TLC printed it.
+ */
+public class ResidentInitialViolationNoStoreTest {
+
+ @Test
+ public void testInitialViolationKeepsPrintedState() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("IN.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n");
+ h.write("IN.tla", "---- MODULE IN ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x \\in {0, 9}\n" //
+ + "Next == x < 9 /\\ x' = x + 1\n" //
+ + "Small == x < 8\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("IN") + "\",\"workers\":1,\"deadlock\":false,\"store\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ final JsonObject trace = check.getAsJsonObject("trace");
+ assertEquals(check.toString(), 1, trace.get("length").getAsInt());
+ final JsonObject state = trace.getAsJsonArray("states").get(0).getAsJsonObject();
+ assertEquals(check.toString(), "x = 9", state.get("tla").getAsString().replace("/\\", "").trim());
+ final JsonObject small = check.getAsJsonArray("invariants").get(0).getAsJsonObject();
+ assertEquals(check.toString(), 1, small.get("level").getAsInt());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationTest.java
new file mode 100644
index 0000000000..ebb99e7224
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationTest.java
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * An invariant false in an initial state has a one-state counterexample: TLC
+ * prints that state only into its report, and the resident hands it on as a
+ * typed state from the store, at level 1.
+ */
+public class ResidentInitialViolationTest {
+
+ @Test
+ public void testInitialInvariantViolationHasItsState() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("IV.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n");
+ h.write("IV.tla", "---- MODULE IV ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x \\in {0, 9}\n" //
+ + "Next == x < 9 /\\ x' = x + 1\n" //
+ + "Small == x < 8\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("IV") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals(check.toString(), "invariant_violated", check.get("verdict").getAsString());
+ final JsonObject trace = check.getAsJsonObject("trace");
+ assertEquals(check.toString(), 1, trace.get("length").getAsInt());
+ final JsonObject state = trace.getAsJsonArray("states").get(0).getAsJsonObject();
+ assertEquals(check.toString(), 9, state.getAsJsonObject("vars").get("x").getAsInt());
+ assertEquals(check.toString(), "", state.get("action").getAsString());
+ assertEquals(check.toString(), 1, check.getAsJsonArray("traces").size());
+ final JsonObject small = check.getAsJsonArray("invariants").get(0).getAsJsonObject();
+ assertEquals(check.toString(), "violated", small.get("verdict").getAsString());
+ assertEquals(check.toString(), 1, small.get("level").getAsInt());
+ assertEquals(check.toString(), "", small.get("action").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinue.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinue.java
new file mode 100644
index 0000000000..22cff14bc0
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinue.java
@@ -0,0 +1,101 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * Under continuation TLC keeps going after the next-state relation fails to
+ * evaluate, and with deadlock checking on it then reports the state whose
+ * expansion the error aborted as a deadlock, which overwrites the error's
+ * code. The run is still an error: B's successor of x = 2, which violates
+ * Inv, was never generated, so Inv has no verdict and the store is not the
+ * whole graph. The earlier violation of Inv2 keeps its own two-state trace
+ * though the error's behaviour is printed after it. Shared by
+ * {@link ResidentNextStateErrorContinueTest} (deadlock checked) and
+ * {@link ResidentNextStateErrorContinueNoDeadlockTest}.
+ */
+final class ResidentNextStateErrorContinue {
+
+ private static final String SPEC = "---- MODULE F ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "A == x < 5 /\\ x' = IF x = 2 THEN 1 \\div 0 ELSE x + 1\n" //
+ + "B == x = 2 /\\ x' = 10\n" //
+ + "Next == A \\/ B\n" //
+ + "Inv == x < 10\n" //
+ + "Inv2 == x # 1\n" //
+ + "====\n";
+
+ private ResidentNextStateErrorContinue() {
+ }
+
+ /** One resident per JVM: each deadlock setting runs in its own test class. */
+ static void run(final boolean deadlock) throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("F.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nINVARIANT Inv2\n");
+ h.write("F.tla", SPEC);
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("F") + "\",\"workers\":1,\"deadlock\":" + deadlock + "}");
+ final JsonObject check = h.ok("{\"command\":\"check\",\"continue\":true}");
+ assertTrue(check.toString(), check.get("finished").getAsBoolean());
+ assertEquals(check.toString(), "error", check.get("verdict").getAsString());
+ assertTrue(check.toString(), check.has("error_code"));
+
+ final JsonArray invariants = check.getAsJsonArray("invariants");
+ final JsonObject inv = invariants.get(0).getAsJsonObject();
+ assertEquals(inv.toString(), "Inv", inv.get("name").getAsString());
+ assertEquals(inv.toString(), "not_evaluated", inv.get("verdict").getAsString());
+ final JsonObject inv2 = invariants.get(1).getAsJsonObject();
+ assertEquals(inv2.toString(), "violated", inv2.get("verdict").getAsString());
+ // x = 1 is the second state of the behaviour.
+ assertEquals(inv2.toString(), 2, inv2.get("level").getAsInt());
+
+ JsonObject violation = null;
+ for (final JsonElement t : check.getAsJsonArray("traces")) {
+ final JsonElement property = t.getAsJsonObject().get("property");
+ if (property != null && !property.isJsonNull() && "Inv2".equals(property.getAsString())) {
+ violation = t.getAsJsonObject();
+ }
+ }
+ assertTrue(check.toString(), violation != null);
+ assertEquals(violation.toString(), 2, violation.get("length").getAsInt());
+
+ final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers");
+ assertFalse(registers.toString(), registers.get("exhausted").getAsBoolean());
+ assertEquals(registers.toString(), "error", registers.get("stopped_by").getAsString());
+
+ // Nothing to replay from: the store is not the whole graph.
+ h.write("F.tla", SPEC.replace("Inv2 == x # 1", "Inv2 == x # 2"));
+ final JsonObject refresh = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(refresh.toString(), "full", refresh.get("mode").getAsString());
+ assertTrue(refresh.toString(), refresh.get("restart_required").getAsBoolean());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueNoDeadlockTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueNoDeadlockTest.java
new file mode 100644
index 0000000000..ad58cdfb77
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueNoDeadlockTest.java
@@ -0,0 +1,34 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import org.junit.Test;
+
+/** {@link ResidentNextStateErrorContinue} with deadlock checking off: the run ends on the error's code. */
+public class ResidentNextStateErrorContinueNoDeadlockTest {
+
+ @Test
+ public void testNextStateError() throws Exception {
+ ResidentNextStateErrorContinue.run(false);
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueTest.java
new file mode 100644
index 0000000000..1751ae741f
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueTest.java
@@ -0,0 +1,34 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import org.junit.Test;
+
+/** {@link ResidentNextStateErrorContinue} with deadlock checking on: the error is overwritten by a deadlock. */
+public class ResidentNextStateErrorContinueTest {
+
+ @Test
+ public void testNextStateError() throws Exception {
+ ResidentNextStateErrorContinue.run(true);
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPauseTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPauseTest.java
new file mode 100644
index 0000000000..d65217861e
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPauseTest.java
@@ -0,0 +1,78 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+import tlc2.TLCGlobals;
+
+/**
+ * A budget pause must hold while TLC does its periodic work. With a
+ * checkpoint due on every wake-up, each suspend wakes the checker's main
+ * thread into a checkpoint, which suspends and resumes the queue on its own;
+ * that resume must not restart workers the resident parked.
+ */
+public class ResidentPauseTest {
+
+ @Test
+ public void testPauseHoldsThroughCheckpoints() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("P.cfg", "INIT Init\nNEXT Next\n");
+ h.write("P.tla", "---- MODULE P ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES a, b, c\n" //
+ + "N == 30\n" //
+ + "Init == a = 0 /\\ b = 0 /\\ c = 0\n" //
+ + "A == a < N-1 /\\ a' = a + 1 /\\ UNCHANGED <>\n" //
+ + "B == b < N-1 /\\ b' = b + 1 /\\ UNCHANGED <>\n" //
+ + "C == c < N-1 /\\ c' = c + 1 /\\ UNCHANGED <>\n" //
+ + "Next == A \\/ B \\/ C\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("P") + "\",\"workers\":2,\"deadlock\":false,\"coverage\":false}");
+ TLCGlobals.chkptDuration = 1;
+
+ final JsonObject paused = h.ok("{\"command\":\"check\",\"budget_states\":50}");
+ assertFalse(paused.get("finished").getAsBoolean());
+ final long before = h.ok("{\"command\":\"stats\"}").getAsJsonObject("stats").get("distinct").getAsLong();
+ for (int i = 0; i < 5; i++) {
+ // Each suspend wakes the main thread into a checkpoint.
+ h.ok("{\"command\":\"screen\",\"candidates\":[\"a < 100\"]}");
+ Thread.sleep(300);
+ }
+ final JsonObject stats = h.ok("{\"command\":\"stats\"}").getAsJsonObject("stats");
+ assertEquals("workers ran while paused", before, stats.get("distinct").getAsLong());
+ assertTrue(stats.get("running").getAsBoolean());
+
+ final JsonObject done = h.ok("{\"command\":\"check\"}");
+ assertTrue(done.get("finished").getAsBoolean());
+ assertEquals("ok", done.get("verdict").getAsString());
+ assertEquals(27000, done.getAsJsonObject("stats").get("distinct").getAsLong());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java
new file mode 100644
index 0000000000..81d4bd7f97
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A PROPERTY that is false in an initial state is a violation, named as
+ * such, not an error.
+ */
+public class ResidentPropertyInitialTest {
+
+ @Test
+ public void testInitialPropertyViolation() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("PI.cfg", "INIT Init\nNEXT Next\nPROPERTY Prop\n");
+ h.write("PI.tla", "---- MODULE PI ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x < 2 /\\ x' = x + 1\n" //
+ + "Prop == x = 1\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("PI") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals(check.toString(), "property_violated", check.get("verdict").getAsString());
+ assertEquals(check.toString(), "Prop", check.get("violated").getAsString());
+ // TLC prints the state only into its report; the trace carries it.
+ final JsonObject trace = check.getAsJsonObject("trace");
+ assertEquals(check.toString(), 1, trace.get("length").getAsInt());
+ assertEquals(check.toString(), 0, trace.getAsJsonArray("states").get(0).getAsJsonObject()
+ .getAsJsonObject("vars").get("x").getAsInt());
+ final JsonObject registers = h.ok("{\"command\":\"registers\"}").getAsJsonObject("registers");
+ assertFalse(registers.get("exhausted").getAsBoolean());
+ assertEquals(registers.toString(), "violation", registers.get("stopped_by").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshAssumptionTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshAssumptionTest.java
new file mode 100644
index 0000000000..d16fb75e93
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshAssumptionTest.java
@@ -0,0 +1,109 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static tlc2.basis.ResidentHarness.storeStates;
+
+import org.junit.Test;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+/**
+ * A refresh checks what TLC checks before and at the initial states: an
+ * edited ASSUME that is false leaves the edit unadopted (TLC would explore
+ * nothing), and a state-level PROPERTY, which TLC checks on the initial
+ * states only, gets a verdict over the stored initial states.
+ */
+public class ResidentRefreshAssumptionTest {
+
+ private static String spec(final String assume, final String pos, final int lim) {
+ return "---- MODULE A ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "CONSTANT N\n" //
+ + "VARIABLE x\n" //
+ + "ASSUME " + assume + "\n" //
+ + "Init == x = 0\n" //
+ + "Inc == x < " + lim + " /\\ x' = x + 1\n" //
+ + "Dec == x > 0 /\\ x' = x - 1\n" //
+ + "Next == Inc \\/ Dec\n" //
+ + "TypeOK == x \\in 0..N\n" //
+ + "Pos == " + pos + "\n" //
+ + "====\n";
+ }
+
+ private static JsonObject named(final JsonArray verdicts, final String name) {
+ for (int i = 0; i < verdicts.size(); i++) {
+ final JsonObject v = verdicts.get(i).getAsJsonObject();
+ if (name.equals(v.get("name").getAsString())) {
+ return v;
+ }
+ }
+ throw new AssertionError("no verdict for " + name + " in " + verdicts);
+ }
+
+ @Test
+ public void testAssumptionsAndImpliedInits() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("A.cfg", "CONSTANT N = 3\nINIT Init\nNEXT Next\nINVARIANT TypeOK\nPROPERTY Pos\n");
+ h.write("A.tla", spec("N > 0", "x >= 0", 3));
+ final JsonObject open = h
+ .ok("{\"command\":\"open\",\"spec\":\"" + h.spec("A") + "\",\"workers\":1,\"deadlock\":false}");
+ assertTrue(open.toString(), open.getAsJsonObject("catalogue").getAsJsonArray("implied_inits").toString()
+ .contains("Pos"));
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+
+ // A false assumption: not adopted, the store stays, and it still serves.
+ h.write("A.tla", spec("N > 100", "x >= 0", 2));
+ JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertFalse(r.toString(), r.get("adopted").getAsBoolean());
+ assertEquals(r.toString(), "assumption_false", r.get("verdict").getAsString());
+ assertEquals(4, storeStates(r));
+ final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x < 3\"]}");
+ assertEquals("violated",
+ screen.getAsJsonArray("results").get(0).getAsJsonObject().get("verdict").getAsString());
+
+ // The assumption fixed and the state-level property broken: adopted,
+ // and the property is violated in the initial state, as TLC reports it.
+ h.write("A.tla", spec("N > 0", "x >= 1", 2));
+ r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("adopted").getAsBoolean());
+ assertEquals("hold", r.get("assumptions").getAsString());
+ assertEquals(3, storeStates(r));
+ assertEquals(r.toString(), "violated_initially",
+ named(r.getAsJsonArray("implied_inits"), "Pos").get("verdict").getAsString());
+ assertEquals("no_violation_found", named(r.getAsJsonArray("invariants"), "TypeOK").get("verdict").getAsString());
+
+ // And fixed again.
+ h.write("A.tla", spec("N > 0", "x >= 0", 2));
+ r = h.ok("{\"command\":\"refresh\"}");
+ assertTrue(r.toString(), r.get("adopted").getAsBoolean());
+ assertEquals("no_violation_found",
+ named(r.getAsJsonArray("implied_inits"), "Pos").get("verdict").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshDefinitionsTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshDefinitionsTest.java
new file mode 100644
index 0000000000..00823a346c
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshDefinitionsTest.java
@@ -0,0 +1,81 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Parsing the edited spec in the same JVM must resolve the config's names to
+ * the same definitions a fresh run would. Definition slots live on
+ * process-global strings, so names the edit adds, before and after the ones
+ * the config names, must not take the slots of INIT, NEXT or INVARIANT.
+ */
+public class ResidentRefreshDefinitionsTest {
+
+ private static String spec(final int before, final int after) {
+ final StringBuilder sb = new StringBuilder("---- MODULE D ----\nVARIABLES x, y\n");
+ for (int i = 0; i < before; i++) {
+ sb.append("Pb").append(i).append(" == ").append(i).append('\n');
+ }
+ sb.append("Init == x = \"a\" /\\ y = FALSE\n") //
+ .append("A == x = \"a\" /\\ x' = \"b\" /\\ y' = TRUE\n") //
+ .append("B == x = \"b\" /\\ x' = \"c\" /\\ y' = FALSE\n") //
+ .append("Next == A \\/ B\n") //
+ .append("Inv == y \\in BOOLEAN\n");
+ for (int i = 0; i < after; i++) {
+ sb.append("Pa").append(i).append(" == y = FALSE\n");
+ }
+ return sb.append("====\n").toString();
+ }
+
+ @Test
+ public void testAddedDefinitions() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("D.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("D.tla", spec(0, 0));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("D") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+
+ // Before the fix, (8, 1) resolved INVARIANT Inv to Pa0 and (6, 3)
+ // resolved NEXT to Pa1; (4, 1) and (0, 5) took Init's slot.
+ final int[][] edits = { { 8, 1 }, { 6, 3 }, { 4, 1 }, { 0, 5 }, { 12, 12 } };
+ for (final int[] e : edits) {
+ h.write("D.tla", spec(e[0], e[1]));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("complete").getAsBoolean());
+ final JsonObject diff = r.getAsJsonObject("diff");
+ assertEquals(r.toString(), "[\"A\",\"B\"]", diff.getAsJsonArray("unchanged").toString());
+ assertEquals(r.toString(), 0, diff.getAsJsonArray("changed_invariants").size());
+ assertEquals(r.toString(), "no_violation_found",
+ r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString());
+ assertEquals(r.toString(), 3, ResidentHarness.storeStates(r));
+ }
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedInitialTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedInitialTest.java
new file mode 100644
index 0000000000..7f02617ba0
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedInitialTest.java
@@ -0,0 +1,102 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+/**
+ * An initial state a state constraint excludes is never explored, but TLC
+ * checks every invariant and state-level PROPERTY on it. The store keeps it,
+ * so a refresh that edits an invariant or property violated only there
+ * reports the violation, and later refreshes still carry it.
+ */
+public class ResidentRefreshExcludedInitialTest {
+
+ private static String spec(final String inv, final String pos) {
+ return "---- MODULE I ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x \\in {0, 10}\n" //
+ + "Next == x < 5 /\\ x' = x + 1\n" //
+ + "Bound == x < 5\n" //
+ + "Inv == " + inv + "\n" //
+ + "Pos == " + pos + "\n" //
+ + "====\n";
+ }
+
+ private static JsonObject first(final JsonArray verdicts) {
+ assertEquals(verdicts.toString(), 1, verdicts.size());
+ return verdicts.get(0).getAsJsonObject();
+ }
+
+ private static JsonObject refresh(final ResidentHarness h) throws Exception {
+ // Past violations, so the replay covers the whole graph and the next
+ // refresh replays from it.
+ final JsonObject r = h.ok("{\"command\":\"refresh\",\"continue\":true}");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("complete").getAsBoolean());
+ return r;
+ }
+
+ @Test
+ public void testExcludedInitialStates() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("I.cfg", "INIT Init\nNEXT Next\nCONSTRAINT Bound\nINVARIANT Inv\nPROPERTY Pos\n");
+ h.write("I.tla", spec("x >= 0", "x >= 0"));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("I") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals(check.toString(), "ok", check.get("verdict").getAsString());
+
+ // Violated only by x = 10, which the constraint excludes.
+ h.write("I.tla", spec("x < 10", "x < 10"));
+ JsonObject r = refresh(h);
+ JsonObject inv = first(r.getAsJsonArray("invariants"));
+ assertEquals(r.toString(), "violated", inv.get("verdict").getAsString());
+ assertEquals(r.toString(), 1, inv.get("level").getAsInt());
+ final long fp = inv.get("fp").getAsLong();
+ assertEquals(r.toString(), "violated_initially",
+ first(r.getAsJsonArray("implied_inits")).get("verdict").getAsString());
+ final JsonObject trace = h.ok("{\"command\":\"trace\",\"fp\":\"" + fp + "\"}");
+ assertEquals(trace.toString(), 1, trace.get("length").getAsInt());
+
+ // Carried through a refresh from the refreshed store.
+ h.write("I.tla", spec("x < 11", "x < 11"));
+ r = refresh(h);
+ assertEquals(r.toString(), "no_violation_found",
+ first(r.getAsJsonArray("invariants")).get("verdict").getAsString());
+ h.write("I.tla", spec("x /= 10", "x /= 10"));
+ r = refresh(h);
+ inv = first(r.getAsJsonArray("invariants"));
+ assertEquals(r.toString(), "violated", inv.get("verdict").getAsString());
+ assertEquals(r.toString(), fp, inv.get("fp").getAsLong());
+ assertEquals(r.toString(), "violated_initially",
+ first(r.getAsJsonArray("implied_inits")).get("verdict").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedTest.java
new file mode 100644
index 0000000000..99b5950b8e
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedTest.java
@@ -0,0 +1,87 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static tlc2.basis.ResidentContinuationInvariantsTest.verdict;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * TLC checks invariants on successors a state constraint excludes. A refresh
+ * must too: an invariant that fails only on the excluded state is violated
+ * in the refreshed graph, as a fresh run reports.
+ */
+public class ResidentRefreshExcludedTest {
+
+ private static String spec(final int bad) {
+ return "---- MODULE RX ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x' = x + 1\n" //
+ + "Inv == x # " + bad + "\n" //
+ + "Cons == x < 3\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testExcludedSuccessorIsChecked() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("RX.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nCONSTRAINT Cons\n");
+ h.write("RX.tla", spec(7));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("RX") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals(check.toString(), "no_violation_found", verdict(check, "Inv").get("verdict").getAsString());
+ // 0, 1 and 2 are in the model; 3 is excluded and kept.
+ assertEquals(check.toString(), 3, check.getAsJsonObject("stats").getAsJsonObject("store").get("states").getAsLong());
+ assertEquals(check.toString(), 1,
+ check.getAsJsonObject("stats").getAsJsonObject("store").get("excluded_states").getAsLong());
+
+ // Only the excluded state violates the edited invariant.
+ h.write("RX.tla", spec(3));
+ final JsonObject refresh = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(refresh.toString(), "incremental", refresh.get("mode").getAsString());
+ final JsonObject inv = verdict(refresh, "Inv");
+ assertEquals(refresh.toString(), "violated", inv.get("verdict").getAsString());
+ assertEquals(refresh.toString(), 4, inv.get("level").getAsInt());
+ assertEquals(refresh.toString(), 1, refresh.getAsJsonArray("violations").size());
+ // The path to the excluded state is served like any other.
+ final JsonObject trace = h.ok("{\"command\":\"trace\",\"fp\":\"" + inv.get("fp").getAsString() + "\"}");
+ assertEquals(trace.toString(), 4, trace.get("length").getAsInt());
+
+ // The excluded edge was carried: a further edit replays it again.
+ h.write("RX.tla", spec(2));
+ final JsonObject again = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(again.toString(), "violated", verdict(again, "Inv").get("verdict").getAsString());
+ assertEquals(again.toString(), 3, verdict(again, "Inv").get("level").getAsInt());
+
+ // And an added action that generates the excluded state anew.
+ h.write("RX.tla", spec(3).replace("Next == x' = x + 1", "Next == x' = x + 1 \\/ x' = x + 2"));
+ final JsonObject added = h.ok("{\"command\":\"refresh\",\"continue\":true}");
+ assertEquals(added.toString(), "violated", verdict(added, "Inv").get("verdict").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshInvariantErrorTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshInvariantErrorTest.java
new file mode 100644
index 0000000000..1474b42941
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshInvariantErrorTest.java
@@ -0,0 +1,100 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static tlc2.basis.ResidentHarness.storeStates;
+
+import java.io.File;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * An edited invariant that does not evaluate on a state carried over from
+ * the old graph: the refresh must answer as a replay that failed (not
+ * adopted, with the error), keep serving the old store, release the new
+ * one, and leave the next refresh working.
+ */
+public class ResidentRefreshInvariantErrorTest {
+
+ private static String spec(final String inv) {
+ return "---- MODULE E ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x\n" //
+ + "Init == x = 0\n" //
+ + "Inc == x < 5 /\\ x' = x + 1\n" //
+ + "Next == Inc\n" //
+ + "Inv == " + inv + "\n" //
+ + "====\n";
+ }
+
+ private static int storeFiles(final ResidentHarness h) {
+ int files = 0;
+ final File[] metadirs = new File(h.dir.toFile(), "states").listFiles();
+ for (final File metadir : metadirs == null ? new File[0] : metadirs) {
+ if (new File(metadir, "basis.states").exists()) {
+ files++;
+ }
+ }
+ return files;
+ }
+
+ @Test
+ public void testInvariantThatDoesNotEvaluate() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("E.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("E.tla", spec("x <= 5"));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("E") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals("ok", check.get("verdict").getAsString());
+ assertEquals(6, storeStates(check.getAsJsonObject("stats")));
+
+ // Every stored state survives (no action changed), and the changed
+ // invariant fails to evaluate on the first of them.
+ final int filesBefore = storeFiles(h);
+ h.write("E.tla", spec("x.foo <= 5"));
+ JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertFalse(r.toString(), r.get("adopted").getAsBoolean());
+ assertTrue(r.toString(), r.get("error").getAsString().contains("Inv"));
+ assertEquals(6, storeStates(r));
+ assertEquals("the unadopted store was not released", filesBefore, storeFiles(h));
+
+ // The old spec is still what the store queries evaluate against...
+ final long fp = h.ok("{\"command\":\"screen\",\"candidates\":[\"x < 1\"]}").getAsJsonArray("results").get(0)
+ .getAsJsonObject().get("first_violation_fp").getAsLong();
+ final JsonObject eval = h.ok("{\"command\":\"eval\",\"fp\":" + fp + ",\"expr\":\"x + 1\"}");
+ assertTrue(eval.toString(), eval.get("evaluated").getAsBoolean());
+
+ // ...and a fixed edit replays as usual.
+ h.write("E.tla", spec("x <= 4"));
+ r = h.ok("{\"command\":\"refresh\",\"continue\":true}");
+ assertTrue(r.toString(), r.get("adopted").getAsBoolean());
+ assertTrue(r.get("complete").getAsBoolean());
+ assertEquals("violated", r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshModelValueTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshModelValueTest.java
new file mode 100644
index 0000000000..caa3b426be
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshModelValueTest.java
@@ -0,0 +1,98 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Every parse resets TLC's model-value table, and a stored state names a
+ * model value by its index there. A refresh that is not adopted must put
+ * the old table back, or the store decodes against the edited spec's
+ * numbering; an edit that renumbers the model values cannot be replayed at
+ * all; one that only adds a model value after the others can.
+ */
+public class ResidentRefreshModelValueTest {
+
+ private static String spec(final String values, final int bound, final boolean broken) {
+ return "---- MODULE MV ----\n" //
+ + "EXTENDS Naturals, TLCExt\n" //
+ + "VARIABLE x\n" //
+ + values //
+ + "Init == x = NoVal\n" //
+ + "A == x = NoVal /\\ x' = 0\n" //
+ + "B == x \\in Nat /\\ x < " + bound + " /\\ x' = x + 1\n" //
+ + "Next == A \\/ B" + (broken ? " \\/ ((" : "") + "\n" //
+ + "Inv == x = NoVal \\/ x < 10\n" //
+ + "====\n";
+ }
+
+ private static final String NOVAL = "NoVal == TLCModelValue(\"NoVal\")\n";
+
+ /** How many stored states are not NoVal, by screening the store. */
+ private static long notNoVal(final ResidentHarness h) throws Exception {
+ final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x = NoVal\"]}");
+ final JsonObject result = screen.getAsJsonArray("results").get(0).getAsJsonObject();
+ assertEquals(result.toString(), "violated", result.get("verdict").getAsString());
+ return result.get("violations").getAsLong();
+ }
+
+ @Test
+ public void testModelValuesSurviveRefreshes() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("MV.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("MV.tla", spec(NOVAL, 3, false));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("MV") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+ assertEquals(4, notNoVal(h));
+
+ // A parse failure: the parse reset the table before it failed.
+ h.write("MV.tla", spec(NOVAL, 3, true));
+ final JsonObject failed = h.call("{\"command\":\"refresh\"}");
+ assertEquals(failed.toString(), "parse_failed", failed.get("error_code").getAsString());
+ assertEquals(4, notNoVal(h));
+
+ // A model value ahead of NoVal takes its index: nothing can be carried.
+ h.write("MV.tla", spec("Aaa == TLCModelValue(\"Aaa\")\n" + NOVAL, 3, false));
+ final JsonObject renumbered = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(renumbered.toString(), "full", renumbered.get("mode").getAsString());
+ assertTrue(renumbered.toString(), renumbered.get("restart_required").getAsBoolean());
+ assertTrue(renumbered.toString(), renumbered.get("reason").getAsString().contains("model values"));
+ assertEquals(4, notNoVal(h));
+
+ // One added after NoVal keeps every stored index: replayed as usual.
+ h.write("MV.tla", spec(NOVAL + "Zzz == TLCModelValue(\"Zzz\")\n", 4, false));
+ final JsonObject appended = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(appended.toString(), "incremental", appended.get("mode").getAsString());
+ assertTrue(appended.toString(), appended.get("adopted").getAsBoolean());
+ assertFalse(appended.toString(), appended.has("restart_required"));
+ assertEquals(6, appended.getAsJsonObject("store").get("states").getAsLong());
+ assertEquals(5, notNoVal(h));
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshPairingTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshPairingTest.java
new file mode 100644
index 0000000000..0d549fd99f
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshPairingTest.java
@@ -0,0 +1,78 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static tlc2.basis.ResidentHarness.storeEdges;
+import static tlc2.basis.ResidentHarness.storeStates;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * The disjuncts of an unnamed Next all share its name. Inserting one in front
+ * must leave the others paired with themselves (unchanged, their edges
+ * copied), not shift every pair by one.
+ */
+public class ResidentRefreshPairingTest {
+
+ private static String spec(final boolean front) {
+ return "---- MODULE P ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x, y\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "Next == " + (front ? "\\/ x = 99 /\\ x' = 0 /\\ y' = y\n " : "") //
+ + "\\/ x < 5 /\\ x' = x + 1 /\\ y' = y\n" //
+ + " \\/ y < 3 /\\ y' = y + 1 /\\ x' = x\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testInsertedDisjunct() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("P.cfg", "INIT Init\nNEXT Next\n");
+ h.write("P.tla", spec(false));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("P") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals(24, storeStates(check.getAsJsonObject("stats")));
+ assertEquals(38, storeEdges(check.getAsJsonObject("stats")));
+
+ h.write("P.tla", spec(true));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ final JsonObject diff = r.getAsJsonObject("diff");
+ assertEquals(r.toString(), 2, diff.getAsJsonArray("unchanged").size());
+ assertEquals(r.toString(), 0, diff.getAsJsonArray("changed").size());
+ assertEquals(r.toString(), 1, diff.getAsJsonArray("added").size());
+ assertEquals(r.toString(), 0, diff.getAsJsonArray("removed").size());
+ // Every old edge is copied; the new disjunct is never enabled.
+ assertEquals(r.toString(), 38, r.get("edges_copied").getAsLong());
+ assertEquals(r.toString(), 0, r.get("edges_generated").getAsLong());
+ assertTrue(r.get("complete").getAsBoolean());
+ assertEquals(24, storeStates(r));
+ assertEquals(38, storeEdges(r));
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshRestoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshRestoreTest.java
new file mode 100644
index 0000000000..a1a66a371a
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshRestoreTest.java
@@ -0,0 +1,73 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A refresh whose edited spec declares a variable named like one of the old
+ * spec's definitions is not adopted; the old spec's name slots are put back,
+ * so the session still evaluates the definition as a definition.
+ */
+public class ResidentRefreshRestoreTest {
+
+ @Test
+ public void testDefinitionBecomesVariable() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("RR.cfg", "INIT Init\nNEXT Next\n");
+ h.write("RR.tla", "---- MODULE RR ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "y == 5\n" //
+ + "Init == x = 0\n" //
+ + "Next == x < 2 /\\ x' = x + 1\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("RR") + "\",\"workers\":1,\"deadlock\":false}");
+ h.ok("{\"command\":\"check\"}");
+ final String screen = "{\"command\":\"screen\",\"candidates\":[\"y = 5\",\"x + y > 4\"]}";
+ assertHolds(h.ok(screen));
+
+ h.write("RR.tla", "---- MODULE RR ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x, y\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "Next == x < 2 /\\ x' = x + 1 /\\ y' = y\n" //
+ + "====\n");
+ final JsonObject refresh = h.ok("{\"command\":\"refresh\"}");
+ assertTrue(refresh.toString(), refresh.get("restart_required").getAsBoolean());
+ assertHolds(h.ok(screen));
+ h.resident.shutdown();
+ }
+
+ private static void assertHolds(final JsonObject screen) {
+ for (int i = 0; i < 2; i++) {
+ assertEquals(screen.toString(), "holds_on_stored",
+ screen.getAsJsonArray("results").get(i).getAsJsonObject().get("verdict").getAsString());
+ }
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSignatureTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSignatureTest.java
new file mode 100644
index 0000000000..9f07e8cb77
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSignatureTest.java
@@ -0,0 +1,94 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * An action's signature must change when what it means changes, even where
+ * no definition's body text does: swapping a definition's formal parameters,
+ * or which parameter an INSTANCE substitution replaces.
+ */
+public class ResidentRefreshSignatureTest {
+
+ private static String spec(final String params, final String with) {
+ return "---- MODULE S ----\n" //
+ + "EXTENDS Integers\n" //
+ + "VARIABLES x, y\n" //
+ + "F(" + params + ") == a - b\n" //
+ + "I == INSTANCE SM WITH " + with + "\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "A == x \\in 0..4 /\\ x' = x + F(2, 1) /\\ y' = y\n" //
+ + "B == y \\in 0..4 /\\ y' = y + I!D /\\ x' = x\n" //
+ + "Next == A \\/ B\n" //
+ + "Inv == x >= 0 /\\ y >= 0\n" //
+ + "====\n";
+ }
+
+ private static void assertChanged(final JsonObject r, final String changed, final String unchanged) {
+ final JsonObject diff = r.getAsJsonObject("diff");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ assertEquals(r.toString(), "[\"" + changed + "\"]", diff.getAsJsonArray("changed").toString());
+ assertEquals(r.toString(), "[\"" + unchanged + "\"]", diff.getAsJsonArray("unchanged").toString());
+ // A fresh run of the edit steps to -1, below Inv.
+ assertEquals(r.toString(), "violated",
+ r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString());
+ }
+
+ @Test
+ public void testSignature() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("S.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("SM.tla", "---- MODULE SM ----\nEXTENDS Integers\nCONSTANTS a, b\nD == a - b\n====\n");
+ h.write("S.tla", spec("a, b", "a <- 2, b <- 1"));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals("ok", check.get("verdict").getAsString());
+ assertEquals(36, ResidentHarness.storeStates(check.getAsJsonObject("stats")));
+
+ // The same body text, the parameters swapped.
+ h.write("S.tla", spec("b, a", "a <- 2, b <- 1"));
+ assertChanged(h.ok("{\"command\":\"refresh\"}"), "A", "B");
+
+ // The same expressions, substituted for the other parameters. The
+ // replay above stopped at its violation, so this one diffs against
+ // the complete original graph.
+ h.write("S.tla", spec("a, b", "a <- 1, b <- 2"));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertChanged(r, "B", "A");
+ assertEquals("last_complete", r.get("replayed_from").getAsString());
+
+ // Moving the text without changing it is no edit.
+ h.write("S.tla", spec("a, b", "a <- 2, b <- 1").replace("EXTENDS Integers\n", "EXTENDS Integers\n\n\n"));
+ final JsonObject moved = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(moved.toString(), 0, moved.getAsJsonObject("diff").getAsJsonArray("changed").size());
+ assertTrue(moved.toString(), moved.get("complete").getAsBoolean());
+ assertEquals(36, ResidentHarness.storeStates(moved));
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshStaleTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshStaleTest.java
new file mode 100644
index 0000000000..dafc648aef
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshStaleTest.java
@@ -0,0 +1,79 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * After an incremental refresh, stats and registers still describe the run
+ * before it: they say so, and whether the refreshed store is the whole graph.
+ */
+public class ResidentRefreshStaleTest {
+
+ private static String spec(final int lim) {
+ return "---- MODULE S ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x, y\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "Inc == x < " + lim + " /\\ x' = x + 1 /\\ y' = y\n" //
+ + "Bump == y < 3 /\\ y' = y + 1 /\\ x' = x\n" //
+ + "Next == Inc \\/ Bump\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testStaleAfterRefresh() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("S.cfg", "INIT Init\nNEXT Next\n");
+ h.write("S.tla", spec(5));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}");
+ h.ok("{\"command\":\"check\"}");
+ assertFalse(h.ok("{\"command\":\"stats\"}").has("stale"));
+ assertFalse(h.ok("{\"command\":\"registers\"}").has("stale"));
+
+ h.write("S.tla", spec(7));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertTrue(r.toString(), r.get("complete").getAsBoolean());
+ for (final String command : new String[] { "stats", "registers" }) {
+ final JsonObject o = h.ok("{\"command\":\"" + command + "\"}");
+ assertTrue(o.toString(), o.get("stale").getAsBoolean());
+ assertTrue(o.toString(), o.get("refresh_complete").getAsBoolean());
+ }
+
+ // A refresh cut by its budget leaves a store that is not the whole graph.
+ h.write("S.tla", spec(9));
+ final JsonObject cut = h.ok("{\"command\":\"refresh\",\"budget_ms\":-1}");
+ assertFalse(cut.toString(), cut.get("complete").getAsBoolean());
+ for (final String command : new String[] { "stats", "registers" }) {
+ final JsonObject o = h.ok("{\"command\":\"" + command + "\"}");
+ assertTrue(o.toString(), o.get("stale").getAsBoolean());
+ assertFalse(o.toString(), o.get("refresh_complete").getAsBoolean());
+ }
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSymmetryTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSymmetryTest.java
new file mode 100644
index 0000000000..ce38df9c7d
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSymmetryTest.java
@@ -0,0 +1,68 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Under a SYMMETRY set a fingerprint names every permutation of a state, and
+ * the store keeps the one TLC reached first, so a refresh asks for a full
+ * rerun as it does under a VIEW.
+ */
+public class ResidentRefreshSymmetryTest {
+
+ private static String spec(final boolean remove) {
+ return "---- MODULE S ----\n" //
+ + "EXTENDS TLC\n" //
+ + "CONSTANT P\n" //
+ + "VARIABLE s\n" //
+ + "Init == s = {}\n" //
+ + "Add == \\E p \\in P : p \\notin s /\\ s' = s \\cup {p}\n" //
+ + (remove ? "Remove == \\E p \\in s : s' = s \\ {p}\n" : "") //
+ + "Next == Add" + (remove ? " \\/ Remove" : "") + "\n" //
+ + "Inv == s \\subseteq P\n" //
+ + "Perms == Permutations(P)\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testSymmetryRefreshIsFullRerun() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("S.cfg", "CONSTANT P = {p1, p2}\nSYMMETRY Perms\nINIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("S.tla", spec(false));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+
+ h.write("S.tla", spec(true));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "full", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("restart_required").getAsBoolean());
+ assertTrue(r.toString(), r.get("reason").getAsString().contains("SYMMETRY"));
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetSubstitutionTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetSubstitutionTest.java
new file mode 100644
index 0000000000..c2daffd928
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetSubstitutionTest.java
@@ -0,0 +1,71 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A constraint that reads {@code TLCGet("level")} only through a definition
+ * the config substitutes in ({@code CONSTANT Depth <- D}): the substitution
+ * is bound as a tool object, so the syntactic walk from the constraint never
+ * reaches {@code TLCGet}. The refresh must still ask for a full run; replayed
+ * without a predecessor, the constraint excluded nothing and the replay ran
+ * away.
+ */
+public class ResidentRefreshTLCGetSubstitutionTest {
+
+ private static String spec(final String next) {
+ return "---- MODULE S ----\n" //
+ + "EXTENDS Naturals, TLC\n" //
+ + "VARIABLES x\n" //
+ + "Depth == 0\n" //
+ + "D == TLCGet(\"level\")\n" //
+ + "Constr == Depth < 4\n" //
+ + "Init == x = 0\n" //
+ + "Inc == x' = x + 1\n" //
+ + "Jump == x' = x + 3\n" //
+ + "Next == " + next + "\n" //
+ + "Inv == TRUE\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testSubstitutedLevelConstraint() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("S.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nCONSTRAINT Constr\nCONSTANT Depth <- D\n");
+ h.write("S.tla", spec("Inc"));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("S") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+ h.write("S.tla", spec("Inc \\/ Jump"));
+ final JsonObject r = h.ok("{\"command\":\"refresh\",\"budget_ms\":10000}");
+ assertEquals(r.toString(), "full", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("restart_required").getAsBoolean());
+ assertTrue(r.toString(), r.get("reason").getAsString().contains("Depth <- D"));
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetTest.java
new file mode 100644
index 0000000000..a4e0fa62b3
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetTest.java
@@ -0,0 +1,67 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A spec whose constraint reads {@code TLCGet("level")}: whether a successor
+ * is in the model depends on the path to it, which an edit can shorten, so a
+ * refresh asks for a full run instead of copying edges (and instead of
+ * failing to evaluate the constraint without a predecessor).
+ */
+public class ResidentRefreshTLCGetTest {
+
+ private static String spec(final int step) {
+ return "---- MODULE L ----\n" //
+ + "EXTENDS Naturals, TLC\n" //
+ + "VARIABLES x, y\n" //
+ + "Depth == TLCGet(\"level\") < 4\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "Inc == x' = x + 1 /\\ y' = y\n" //
+ + "Bump == y' = y + " + step + " /\\ x' = x\n" //
+ + "Next == Inc \\/ Bump\n" //
+ + "Inv == x + y < 100\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testLevelConstraint() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("L.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nCONSTRAINT Depth\n");
+ h.write("L.tla", spec(1));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("L") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+ h.write("L.tla", spec(2));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "full", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("restart_required").getAsBoolean());
+ assertTrue(r.toString(), r.get("reason").getAsString().contains("TLCGet"));
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTest.java
new file mode 100644
index 0000000000..f72c439859
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTest.java
@@ -0,0 +1,139 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static tlc2.basis.ResidentHarness.storeEdges;
+import static tlc2.basis.ResidentHarness.storeStates;
+
+import java.io.File;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Incremental refresh against the graph a fresh run explores: a sequence of
+ * edits (a changed guard, a changed definition, an added and a removed
+ * action), each replay matching the fresh run's states and edges; then an
+ * edit that breaks an invariant followed by its fix, which must replay from
+ * the last complete graph rather than ask for a restart; then an edit that
+ * does not evaluate, which must leave the store in place.
+ */
+public class ResidentRefreshTest {
+
+ private static String spec(final int lim, final int blim, final String inv, final boolean extra,
+ final boolean broken) {
+ return "---- MODULE R ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x, y\n" //
+ + "Lim == " + lim + "\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "Inc == x < Lim /\\ x' = x + 1 /\\ y' = y\n" //
+ + "Bump == y < " + blim + " /\\ y' = y + 1 /\\ x' = x\n" //
+ + "Reset == x = Lim /\\ x' = 0 /\\ y' = y\n" //
+ + (extra ? "Extra == x = 2 /\\ x' = 6 /\\ y' = y\n" : "") //
+ + "Next == " + (broken ? "x = " : "") + "Inc \\/ Bump \\/ Reset" + (extra ? " \\/ Extra" : "") + "\n" //
+ + "Inv == " + inv + "\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testRefresh() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("R.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("R.tla", spec(5, 3, "x + y < 100", false, false));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("R") + "\",\"workers\":1,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals("ok", check.get("verdict").getAsString());
+ assertEquals(24, storeStates(check.getAsJsonObject("stats")));
+ assertEquals(42, storeEdges(check.getAsJsonObject("stats")));
+
+ // Each edit's expected (states, edges) is what a fresh run stores.
+ final Object[][] edits = { //
+ { spec(5, 4, "x + y < 100", false, false), 30, 54, "Bump" }, // a changed guard
+ { spec(7, 4, "x + y < 100", false, false), 40, 72, "Inc" }, // a changed definition
+ { spec(7, 4, "x + y < 100", true, false), 40, 77, null }, // an added action
+ { spec(7, 2, "x + y < 100", false, false), 24, 40, "Bump" }, // removed, and changed
+ };
+ for (final Object[] e : edits) {
+ h.write("R.tla", (String) e[0]);
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ assertTrue(r.get("complete").getAsBoolean());
+ assertEquals(r.toString(), ((Integer) e[1]).longValue(), storeStates(r));
+ assertEquals(r.toString(), ((Integer) e[2]).longValue(), storeEdges(r));
+ if (e[3] != null) {
+ assertTrue(r.toString(), r.getAsJsonObject("diff").getAsJsonArray("changed").toString()
+ .contains((String) e[3]));
+ }
+ assertTrue(r.has("unchecked"));
+ }
+
+ // An edit that breaks the invariant stops at its first violation...
+ h.write("R.tla", spec(7, 2, "x + y < 6", false, false));
+ JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals("incremental", r.get("mode").getAsString());
+ assertFalse(r.get("complete").getAsBoolean());
+ assertTrue(r.get("stopped_at_first_violation").getAsBoolean());
+ assertEquals("violated", r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString());
+
+ // ...and the fix replays from the last complete graph, not a restart.
+ h.write("R.tla", spec(7, 2, "x + y < 100", false, false));
+ r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ assertEquals("last_complete", r.get("replayed_from").getAsString());
+ assertTrue(r.get("complete").getAsBoolean());
+ assertEquals(24, storeStates(r));
+ assertEquals(40, storeEdges(r));
+
+ // An edit that does not evaluate is not adopted: the store stays.
+ final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x < 1\"]}");
+ final long fp = screen.getAsJsonArray("results").get(0).getAsJsonObject().get("first_violation_fp")
+ .getAsLong();
+ h.write("R.tla", spec(7, 2, "x + y < 100", false, true));
+ r = h.ok("{\"command\":\"refresh\"}");
+ assertFalse(r.toString(), r.get("adopted").getAsBoolean());
+ assertTrue(r.has("error"));
+ assertEquals(24, storeStates(r));
+ final JsonObject eval = h.ok("{\"command\":\"eval\",\"fp\":" + fp + ",\"expr\":\"x + y\"}");
+ assertTrue(eval.toString(), eval.get("evaluated").getAsBoolean());
+
+ // Guards are not tallied during a replay; the profile says so.
+ assertTrue(h.ok("{\"command\":\"guard_profile\"}").get("stale").getAsBoolean());
+
+ // Stores nothing refers to are released: only the current store's
+ // file is left (a metadir that held nothing else goes with it).
+ final String current = h.ok("{\"command\":\"store\"}").toString();
+ int files = 0;
+ for (final File metadir : new File(h.dir.toFile(), "states").listFiles()) {
+ if (new File(metadir, "basis.states").exists()) {
+ files++;
+ }
+ }
+ assertEquals(current, 1, files);
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTraceTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTraceTest.java
new file mode 100644
index 0000000000..971a526bcd
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTraceTest.java
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * An invariant that reads the behaviour so far ({@code TLCExt!Trace}, which
+ * TLC overrides in Java to walk the predecessor chain): its value depends on
+ * the path to a state, which a replay neither preserves nor sets, so a
+ * refresh asks for a full run.
+ */
+public class ResidentRefreshTraceTest {
+
+ private static String spec(final int step) {
+ return "---- MODULE T ----\n" //
+ + "EXTENDS Naturals, Sequences, TLCExt\n" //
+ + "VARIABLES x\n" //
+ + "Init == x = 0\n" //
+ + "Inc == x < 5 /\\ x' = x + " + step + "\n" //
+ + "Next == Inc\n" //
+ + "Inv == Len(Trace) < 100\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testTraceInvariant() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("T.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("T.tla", spec(1));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("T") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+ h.write("T.tla", spec(2));
+ final JsonObject r = h.ok("{\"command\":\"refresh\",\"budget_ms\":10000}");
+ assertEquals(r.toString(), "full", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("restart_required").getAsBoolean());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshViewTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshViewTest.java
new file mode 100644
index 0000000000..8cf6ed6c06
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshViewTest.java
@@ -0,0 +1,70 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * Under a VIEW a refresh asks for a full rerun. Here x = 1 is first reached
+ * with y = 1 by A, whose store content is (1, 1); B reaches it with y = 2.
+ * Once A changes, (1, 1) is unreachable, yet a replay copying B's edge would
+ * store it and carry D from it to x = 5, a state a fresh run never reaches.
+ */
+public class ResidentRefreshViewTest {
+
+ private static String spec(final String ay) {
+ return "---- MODULE V ----\n" //
+ + "VARIABLES x, y\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "A == x = 0 /\\ x' = 1 /\\ y' = " + ay + "\n" //
+ + "B == x = 0 /\\ x' = 1 /\\ y' = 2\n" //
+ + "D == x = 1 /\\ y = 1 /\\ x' = 5 /\\ y' = 0\n" //
+ + "Next == A \\/ B \\/ D\n" //
+ + "Inv == x /= 7\n" //
+ + "View == x\n" //
+ + "====\n";
+ }
+
+ @Test
+ public void testViewRefreshIsFullRerun() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("V.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\nVIEW View\n");
+ h.write("V.tla", spec("1"));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("V") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+
+ h.write("V.tla", spec("3"));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "full", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("restart_required").getAsBoolean());
+ assertTrue(r.toString(), r.get("reason").getAsString().contains("VIEW"));
+ // Nothing was adopted: the old store still serves.
+ h.ok("{\"command\":\"screen\",\"candidates\":[\"x /= 5\"]}");
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshWorkersTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshWorkersTest.java
new file mode 100644
index 0000000000..a628c24b7d
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshWorkersTest.java
@@ -0,0 +1,76 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static tlc2.basis.ResidentHarness.storeEdges;
+import static tlc2.basis.ResidentHarness.storeStates;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * The store and its incremental refresh with several workers writing it
+ * concurrently: each replay must hold what a fresh run stores. With b the
+ * bound on y, a fresh run reaches 21(b+1) states over 42b+21 edges.
+ */
+public class ResidentRefreshWorkersTest {
+
+ private static String spec(final int b) {
+ return "---- MODULE W ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x, y\n" //
+ + "Lim == 20\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "Inc == x < Lim /\\ x' = x + 1 /\\ y' = y\n" //
+ + "Bump == y < " + b + " /\\ y' = y + 1 /\\ x' = x\n" //
+ + "Reset == x = Lim /\\ x' = 0 /\\ y' = y\n" //
+ + "Next == Inc \\/ Bump \\/ Reset\n" //
+ + "Inv == x + y < 1000\n" //
+ + "====\n";
+ }
+
+ @Test(timeout = 120_000)
+ public void testRefreshWithWorkers() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("W.cfg", "INIT Init\nNEXT Next\nINVARIANT Inv\n");
+ h.write("W.tla", spec(10));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("W") + "\",\"workers\":4,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals("ok", check.get("verdict").getAsString());
+ assertEquals(231, storeStates(check.getAsJsonObject("stats")));
+ assertEquals(441, storeEdges(check.getAsJsonObject("stats")));
+
+ for (final int b : new int[] { 15, 7, 12 }) {
+ h.write("W.tla", spec(b));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "incremental", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("complete").getAsBoolean());
+ assertEquals(r.toString(), 21 * (b + 1), storeStates(r));
+ assertEquals(r.toString(), 42 * b + 21, storeEdges(r));
+ }
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java
new file mode 100644
index 0000000000..f2b52d1870
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java
@@ -0,0 +1,97 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.file.Files;
+import java.util.stream.Stream;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A refresh keeps its store in a metadir of its own under the spec's
+ * directory (TLC removes the run's metadir itself when the run ends): a
+ * session that ends removes it.
+ */
+public class ResidentShutdownCleanupTest {
+
+ private static String spec(final int lim) {
+ return "---- MODULE C ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Inc == x < " + lim + " /\\ x' = x + 1\n" //
+ + "Next == Inc\n" //
+ + "====\n";
+ }
+
+ private static long storeFiles(final ResidentHarness h) throws Exception {
+ try (Stream files = Files.walk(h.dir)) {
+ return files.filter(p -> p.getFileName().toString().equals("basis.states")).count();
+ }
+ }
+
+ /** Everything left under the spec's directory, for a failure message. */
+ private static String left(final ResidentHarness h) throws Exception {
+ try (Stream files = Files.walk(h.dir)) {
+ return files.map(p -> h.dir.relativize(p).toString()).collect(java.util.stream.Collectors.joining(", "));
+ }
+ }
+
+ /** The metadirs left under the spec's directory. */
+ private static long metadirs(final ResidentHarness h) throws Exception {
+ final java.nio.file.Path states = h.dir.resolve("states");
+ if (!Files.isDirectory(states)) {
+ return 0;
+ }
+ try (Stream dirs = Files.list(states)) {
+ return dirs.count();
+ }
+ }
+
+ @Test
+ public void testShutdownRemovesStoreFiles() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("C.cfg", "INIT Init\nNEXT Next\n");
+ h.write("C.tla", spec(3));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("C") + "\",\"workers\":1,\"deadlock\":false}");
+ assertEquals("ok", h.ok("{\"command\":\"check\"}").get("verdict").getAsString());
+ // A refresh stopped by its budget (here, at once) keeps the complete
+ // run's store as the base and serves its own, in its own metadir.
+ h.write("C.tla", spec(6));
+ final JsonObject partial = h.ok("{\"command\":\"refresh\",\"budget_ms\":-1}");
+ assertTrue(partial.toString(), partial.get("adopted").getAsBoolean());
+ assertFalse(partial.toString(), partial.get("complete").getAsBoolean());
+ assertTrue(storeFiles(h) >= 1);
+ assertTrue(metadirs(h) >= 1);
+
+ h.resident.shutdown();
+ assertEquals(left(h), 0, storeFiles(h));
+ assertEquals(left(h), 0, metadirs(h));
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateBudgetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateBudgetTest.java
new file mode 100644
index 0000000000..bfd71f05f4
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateBudgetTest.java
@@ -0,0 +1,62 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/**
+ * A budget stop ends the simulator for good: the reply says it stopped on
+ * its budget with nothing found, and a later call reports that run again as
+ * not resumable instead of as a completed one.
+ */
+public class ResidentSimulateBudgetTest {
+
+ @Test
+ public void testBudgetStopIsFinal() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("SB.cfg", "INIT Init\nNEXT Next\nINVARIANT Ok\n");
+ h.write("SB.tla", "---- MODULE SB ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x' = 1 - x\n" //
+ + "Ok == x \\in {0, 1}\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("SB")
+ + "\",\"mode\":\"simulate\",\"workers\":1,\"depth\":10,\"seed\":1}");
+ final JsonObject first = h.ok("{\"command\":\"simulate\",\"budget_ms\":300}");
+ assertTrue(first.toString(), first.get("stopped_by_budget").getAsBoolean());
+ assertEquals(first.toString(), "no_violation_found", first.get("verdict").getAsString());
+ final JsonObject again = h.ok("{\"command\":\"simulate\",\"budget_ms\":300}");
+ assertFalse(again.toString(), again.get("resumable").getAsBoolean());
+ assertTrue(again.toString(), again.get("stopped_by_budget").getAsBoolean());
+ assertEquals(again.toString(), "no_violation_found", again.get("verdict").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java
new file mode 100644
index 0000000000..a48a67d5d2
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java
@@ -0,0 +1,65 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+import tlc2.output.EC;
+
+/**
+ * A simulation that stops because the next-state relation fails to evaluate
+ * ends on an error no violation code reports: its verdict is an error, with
+ * the behaviour that led there, never a clean run.
+ */
+public class ResidentSimulateErrorTest {
+
+ @Test
+ public void testEvaluationErrorIsAnError() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("SE.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n");
+ h.write("SE.tla", "---- MODULE SE ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x' = IF x = 3 THEN CHOOSE y \\in {} : TRUE ELSE x + 1\n" //
+ + "Small == x < 10\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("SE")
+ + "\",\"mode\":\"simulate\",\"workers\":1,\"depth\":10,\"traces\":5,\"seed\":1}");
+ final JsonObject sim = h.ok("{\"command\":\"simulate\",\"budget_ms\":30000}");
+ assertTrue(sim.toString(), sim.get("finished").getAsBoolean());
+ assertEquals(sim.toString(), "error", sim.get("verdict").getAsString());
+ assertNotEquals(sim.toString(), EC.NO_ERROR, sim.get("result_code").getAsInt());
+ assertEquals(sim.toString(), 4, sim.getAsJsonObject("trace").get("length").getAsInt());
+ // A simulate session keeps no store, and its store queries say so.
+ final JsonObject trace = h.call("{\"command\":\"trace\",\"fp\":1}");
+ assertEquals(trace.toString(), "no_store", trace.get("error_code").getAsString());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateViolationTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateViolationTest.java
new file mode 100644
index 0000000000..36759f2186
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateViolationTest.java
@@ -0,0 +1,55 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import com.google.gson.JsonObject;
+
+/** A simulation that reaches an invariant violation reports it with its behaviour. */
+public class ResidentSimulateViolationTest {
+
+ @Test
+ public void testViolationIsReported() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("SV.cfg", "INIT Init\nNEXT Next\nINVARIANT Small\n");
+ h.write("SV.tla", "---- MODULE SV ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLE x\n" //
+ + "Init == x = 0\n" //
+ + "Next == x' = x + 1\n" //
+ + "Small == x < 3\n" //
+ + "====\n");
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("SV")
+ + "\",\"mode\":\"simulate\",\"workers\":1,\"depth\":10,\"traces\":5,\"seed\":1}");
+ final JsonObject sim = h.ok("{\"command\":\"simulate\",\"budget_ms\":30000}");
+ assertTrue(sim.toString(), sim.get("finished").getAsBoolean());
+ assertEquals(sim.toString(), "invariant_violated", sim.get("verdict").getAsString());
+ assertEquals(sim.toString(), "Small", sim.get("violated").getAsString());
+ assertEquals(sim.toString(), 4, sim.getAsJsonObject("trace").get("length").getAsInt());
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java
new file mode 100644
index 0000000000..e149f43dae
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java
@@ -0,0 +1,135 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.basis;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static tlc2.basis.ResidentHarness.storeStates;
+
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.Test;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+/**
+ * Under a VIEW, several concrete states share a fingerprint and TLC explores
+ * only the one whose fingerprint-set put won. The store must keep that one:
+ * its recorded out-edges are the successors TLC generated from it, and the
+ * store queries and the invariant sweeps work from its
+ * content. Here the hidden variable decides the successors, so content from
+ * the wrong concrete state would disagree with the recorded edges. Run with
+ * several workers, whose writes race for the store's lock. A refresh under
+ * a VIEW asks for a full rerun and leaves the store as it was.
+ */
+public class ResidentViewStoreTest {
+
+ private static String spec(final boolean extra) {
+ return "---- MODULE V ----\n" //
+ + "EXTENDS Naturals\n" //
+ + "VARIABLES x, y\n" //
+ + "Init == x = 0 /\\ y = 0\n" //
+ + "A == x < 6 /\\ x' = x + 1 + y /\\ y' = 0\n" //
+ + "B == x < 6 /\\ x' = x + 1 /\\ y' = 1\n" //
+ + (extra ? "C == x = 1 /\\ x' = 4 /\\ y' = 1 - y\n" : "") //
+ + "Next == A \\/ B" + (extra ? " \\/ C" : "") + "\n" //
+ + "View == x\n" //
+ + "Inv == x <= 7\n" //
+ + "====\n";
+ }
+
+ /**
+ * Walk the store from its initial state through the successors each
+ * stored state's content generates, and check them against the edges the
+ * store recorded out of that state. Returns the number of states walked.
+ */
+ private static int consistent(final ResidentHarness h) throws Exception {
+ final JsonObject screen = h.ok("{\"command\":\"screen\",\"candidates\":[\"x # 0\"]}");
+ final long init = screen.getAsJsonArray("results").get(0).getAsJsonObject().get("first_violation_fp")
+ .getAsLong();
+ final Map> generated = new HashMap<>();
+ final Map> recorded = new HashMap<>();
+ final ArrayDeque queue = new ArrayDeque<>();
+ final Set seen = new HashSet<>();
+ queue.add(init);
+ seen.add(init);
+ while (!queue.isEmpty()) {
+ final long fp = queue.poll();
+ final JsonObject n = h.ok("{\"command\":\"neighbours\",\"fp\":\"" + fp + "\"}");
+ final Set out = generated.computeIfAbsent(fp, k -> new HashSet<>());
+ final JsonArray succs = n.getAsJsonArray("successors");
+ for (int i = 0; i < succs.size(); i++) {
+ final JsonObject a = succs.get(i).getAsJsonObject();
+ final JsonArray states = a.getAsJsonArray("states");
+ for (int j = 0; j < states.size(); j++) {
+ final JsonObject s = states.get(j).getAsJsonObject();
+ assertTrue(n.toString(), s.get("stored").getAsBoolean());
+ final long to = s.get("fp").getAsLong();
+ out.add(to + "/" + a.get("action_id").getAsLong());
+ if (seen.add(to)) {
+ queue.add(to);
+ }
+ }
+ }
+ final JsonArray preds = n.getAsJsonArray("predecessors");
+ for (int i = 0; i < preds.size(); i++) {
+ final JsonObject p = preds.get(i).getAsJsonObject();
+ recorded.computeIfAbsent(p.get("fp").getAsLong(), k -> new HashSet<>())
+ .add(fp + "/" + p.get("action_id").getAsLong());
+ }
+ }
+ for (final long fp : seen) {
+ assertEquals("state " + fp, generated.get(fp), recorded.getOrDefault(fp, new HashSet<>()));
+ }
+ return seen.size();
+ }
+
+ @Test
+ public void testViewStore() throws Exception {
+ final ResidentHarness h = new ResidentHarness();
+ h.write("V.cfg", "INIT Init\nNEXT Next\nVIEW View\nINVARIANT Inv\n");
+ h.write("V.tla", spec(false));
+ h.ok("{\"command\":\"open\",\"spec\":\"" + h.spec("V") + "\",\"workers\":4,\"deadlock\":false}");
+ final JsonObject check = h.ok("{\"command\":\"check\"}");
+ assertEquals(check.toString(), "ok", check.get("verdict").getAsString());
+ final JsonObject stats = check.getAsJsonObject("stats");
+ assertEquals(stats.get("distinct").getAsLong(), storeStates(stats));
+ assertEquals(storeStates(stats), consistent(h));
+
+ // A refresh under a VIEW is refused: a copied edge may reach a stored
+ // state other than the one it generates (ResidentRefreshViewTest).
+ // The store stays and still serves.
+ h.write("V.tla", spec(true));
+ final JsonObject r = h.ok("{\"command\":\"refresh\"}");
+ assertEquals(r.toString(), "full", r.get("mode").getAsString());
+ assertTrue(r.toString(), r.get("restart_required").getAsBoolean());
+ assertTrue(r.toString(), r.get("reason").getAsString().contains("VIEW"));
+ assertEquals(storeStates(stats), consistent(h));
+ h.resident.shutdown();
+ }
+}
diff --git a/tlatools/org.lamport.tlatools/test/tlc2/tool/DotConstrainedGuardTest.java b/tlatools/org.lamport.tlatools/test/tlc2/tool/DotConstrainedGuardTest.java
new file mode 100644
index 0000000000..916252d6c6
--- /dev/null
+++ b/tlatools/org.lamport.tlatools/test/tlc2/tool/DotConstrainedGuardTest.java
@@ -0,0 +1,107 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Basis Research Institute. All rights reserved.
+ *
+ * The MIT License (MIT)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+ * of the Software, and to permit persons to whom the Software is furnished to do
+ * so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ ******************************************************************************/
+package tlc2.tool;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.Test;
+
+import tla2sany.semantic.SemanticNode;
+import tlc2.output.EC;
+import tlc2.output.EC.ExitStatus;
+import tlc2.tool.liveness.ModelCheckerTestCase;
+import tlc2.util.DotStateWriter;
+import tlc2.util.IStateWriter;
+import util.FileUtil;
+
+/**
+ * Guards that evaluate false before any primed variable is assigned reach a
+ * constrained writer through {@link IStateWriter#writeUnsatisfied}; the
+ * default must not hand {@link DotStateWriter} a partially assigned
+ * successor (it fingerprints it).
+ */
+public class DotConstrainedGuardTest extends ModelCheckerTestCase {
+
+ public DotConstrainedGuardTest() {
+ super("Guards", "basis",
+ new String[] { "-deadlock", "-dump", "dot,constrained",
+ "${metadir}" + FileUtil.separator + DotConstrainedGuardTest.class.getCanonicalName() + ".dot" },
+ ExitStatus.SUCCESS);
+ }
+
+ @Override
+ protected boolean doDump() {
+ return false;
+ }
+
+ @Override
+ protected boolean doCoverage() {
+ return false;
+ }
+
+ private final AtomicBoolean partial = new AtomicBoolean(false);
+ private final AtomicInteger unsatisfied = new AtomicInteger();
+
+ @Override
+ protected IStateWriter getStateWriter(final IStateWriter sw) {
+ try {
+ return new DotStateWriter(sw.getDumpFileName(), "strict ", false, false, false, true, false, false) {
+ @Override
+ public void writeUnsatisfied(final TLCState state, final Action action, final TLCState successor,
+ final SemanticNode pred, final tlc2.util.Context c) {
+ unsatisfied.incrementAndGet();
+ super.writeUnsatisfied(state, action, successor, pred, c);
+ }
+
+ @Override
+ public void writeState(final TLCState state, final TLCState successor, final short stateFlags,
+ final Action action, final SemanticNode pred) {
+ if (!successor.allAssigned()) {
+ partial.set(true);
+ }
+ super.writeState(state, successor, stateFlags, action, pred);
+ }
+ };
+ } catch (IOException e) {
+ fail(e.getMessage());
+ return null;
+ }
+ }
+
+ @Test
+ public void testSpec() {
+ assertTrue(recorder.recorded(EC.TLC_FINISHED));
+ assertFalse(recorder.recorded(EC.GENERAL));
+ assertTrue(recorder.recordedWithStringValues(EC.TLC_STATS, "5", "4", "0"));
+ // The false guards were reported (A, B and C are each blocked
+ // somewhere) but never forwarded with unassigned variables.
+ assertTrue(unsatisfied.get() > 0);
+ assertFalse(partial.get());
+ }
+}