From fc3256b99cc9c47523c3759270af20b3ba650690 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 21 Sep 2026 14:52:01 -0400 Subject: [PATCH 01/33] TLC as a resident service: open, budgeted check, typed traces, stats, close over JSON lines Adds tlc2.basis.Resident, one JVM that parses a spec once and keeps the checker, fingerprint set, queue and trace alive between requests, driven by JSON objects on stdin with replies on stdout; TLC's own printing goes to stderr so the protocol owns stdout. open returns the catalogue (actions with locations, invariants, implied actions, temporal properties, variables, constraints, symmetry) and explores nothing. check explores under a wall-clock or distinct-state budget, suspending the queue when it runs out and resuming on the next call; with continue it keeps going past the first violation (TLCGlobals.continuation) and reports every invariant's verdict: violated with its first level, last action and report count, no_violation_found when the run finished, or not_evaluated. Counterexamples come from tlc2.basis.Recorder, an IMessagePrinterRecorder that keeps TLC's messages as their code and objects and renders trace states through the Json module, so nothing parses the -tool text. Coverage and the fingerprint polynomial are decided at open, since TLC reads both into class-load statics. Verified by hand on a two-action counter spec: an invariant violation answers with a typed five-state trace; with continue, one run reports six violations of one invariant, none of the other, and the terminal deadlock; stats and close behave. Co-Authored-By: Claude Fable 5.1 --- .../src/tlc2/basis/Recorder.java | 274 +++++++++ .../src/tlc2/basis/Resident.java | 518 ++++++++++++++++++ 2 files changed, 792 insertions(+) create mode 100644 tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java create mode 100644 tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java 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..f1654c49f6 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -0,0 +1,274 @@ +/******************************************************************************* + * 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; + } + + 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<>(); + private JsonObject finalStats; + private int outcome = EC.NO_ERROR; + private String outcomeProperty; + + @Override + public synchronized void record(final int code, final Object... objects) { + 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) { + params.add(stateInfo((TLCStateInfo) o)); + } 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_DEADLOCK_REACHED: + case EC.TLC_INVARIANT_EVALUATION_FAILED: + final String property = objects != null && objects.length > 0 && !(objects[0] instanceof TLCState) + ? String.valueOf(objects[0]) + : null; + if (outcome == EC.NO_ERROR) { + outcome = code; + outcomeProperty = property; + } + violationCounts.merge(property == null ? "" : property, 1, Integer::sum); + trace = new Trace(); + trace.code = code; + trace.property = property; + traces.add(trace); + break; + case EC.TLC_STATE_PRINT1: + // A single state (an initial-state violation): no ordinal. + if (trace == null) { + trace = new Trace(); + trace.code = code; + } + if (objects != null && objects.length > 0 && objects[0] instanceof TLCState) { + final JsonObject s = state((TLCState) objects[0]); + s.addProperty("ordinal", trace.states.size() + 1); + trace.states.add(s); + } + 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) { + 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; + } + } + + /** 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; + } + + /** 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); + } + + /** 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..332dab599d --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -0,0 +1,518 @@ +/******************************************************************************* + * 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.util.NoopStateWriter; +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 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; + private ModelChecker checker; + private Thread checkerThread; + private String metadir; + 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 = new Resident(); + MP.setRecorder(resident.recorder); + + 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); + } + + private JsonObject dispatch(final String command, final JsonObject request) throws Exception { + switch (command) { + case "open": + return open(request); + case "check": + return check(request); + case "stats": { + final JsonObject reply = ok(); + reply.add("stats", stats()); + 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; + if (request.has("metadir")) { + TLCGlobals.metaDir = new File(request.get("metadir").getAsString()).getAbsolutePath() + + FileUtil.separator; + } + + openedAt = System.currentTimeMillis(); + recorder.drainMessages(); + 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); + tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.MC, + new HashMap<>()); + final boolean checkDeadlock = deadlock && tool.getModelConfig().getCheckDeadlock(); + checker = new ModelChecker(tool, metadir, new NoopStateWriter(), checkDeadlock, null, + FPSetFactory.getFPSetInitialized(new FPSetConfiguration(), metadir, specFile.getName()), + openedAt); + TLCGlobals.mainChecker = checker; + } catch (final Throwable t) { + tool = 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.add("catalogue", catalogue()); + 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())); + 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"); + } + 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(); + if (request.has("continue")) { + // Keep exploring past a violation, so one run reports every + // invariant's verdict. Process-global, as TLC's -continue is. + TLCGlobals.continuation = request.get("continue").getAsBoolean(); + } + + if (resultCode == null && checkerFailure == null) { + if (checkerThread == null) { + checkerThread = new Thread(() -> { + try { + resultCode = checker.modelCheck(); + } catch (final Throwable t) { + checkerFailure = t; + } + }, "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. + 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); + reply.addProperty("verdict", verdict(resultCode, recorder.outcome())); + } else { + reply.addProperty("verdict", "unfinished"); + } + final String property = recorder.outcomeProperty(); + if (property != null) { + reply.addProperty("violated", property); + } + 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); + reply.add("invariants", invariantVerdicts(finished)); + reply.addProperty("continuation", TLCGlobals.continuation); + reply.add("stats", stats()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + + 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 List traces = recorder.traces(); + final boolean exhausted = finished && checkerFailure == null && resultCode != null + && (resultCode == EC.NO_ERROR || TLCGlobals.continuation); + for (final String name : tool.getInvNames()) { + final JsonObject v = new JsonObject(); + v.addProperty("name", name); + final Integer n = counts.get(name); + if (n != null) { + v.addProperty("verdict", "violated"); + v.addProperty("reports", n); + for (final Recorder.Trace t : traces) { + if (name.equals(t.property)) { + v.addProperty("level", t.states.size()); + if (!t.states.isEmpty()) { + v.add("action", t.states.get(t.states.size() - 1).get("action")); + } + break; + } + } + } 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_DEADLOCK_REACHED: + return "deadlock"; + case EC.TLC_INVARIANT_EVALUATION_FAILED: + return "evaluation_failed"; + default: + return code == EC.NO_ERROR ? "ok" : "error"; + } + } + + // ─── 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); + return s; + } + + private void shutdown() { + if (checker != null && checkerThread != null && checkerThread.isAlive()) { + checker.stop(); + try { + checkerThread.join(5000); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + // ─── 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; + } +} From 933ec8a097b812aef5747a16a8a69ea6074abd3b Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 21 Sep 2026 15:26:26 -0400 Subject: [PATCH 02/33] GraphStore: keep every reached state, its first path, its predecessors and the guards that blocked transitions; serve trace, neighbours, eval, screen and guard_profile tlc2.basis.GraphStore implements IStateWriter and sits on the hooks the checker already calls on every edge and, because it is constrained, on every guard that evaluated false. It appends each newly reached state's variable values to basis.states under the metadir (only the values: the TLCState header is unset on the states the hook receives), keeps the fingerprint index with level and first predecessor and the predecessor lists in memory, and tallies blocked guards per (action, conjunct) with one example state and its quantifier bindings. The checker's end-of-run close only syncs the file, so the store outlives the run. IStateWriter gains writeUnsatisfied(state, action, successor, pred, context), which Worker.addUnsatisfiedState now calls, so the bindings that made a guard false reach the writer; the default keeps the old behaviour. Tool reports a false guard on the general next-state path and on user-defined-operator guards too, not only when every primed variable was already assigned, so a spec whose actions start with their guards (the usual shape) is profiled. The resident serves the store: trace (the path from an initial state to a fingerprint along first predecessors, shortest with one worker), neighbours (recorded predecessors, and per action the successors computed afresh, with the variables each changes), eval (an expression parsed against the root module as the debugger does, in one stored state or a stored pair for primed expressions), screen (candidate state predicates over every stored state under a budget: holds_on_stored, violated with the first violating fingerprint and level, or not_evaluable with the parse error) and guard_profile. stats carries the store's counts. Verified by hand on the counter spec: 18 states and 27 edges stored, Dbl blocked six times by y<4 and Inc three times by x<5, a screen of four candidates (one violated twelve times from level 5, one unparsable), a six-state trace, a neighbourhood with one enabled action, and single- and two-state evaluation. Co-Authored-By: Claude Fable 5.1 --- .../src/tlc2/basis/GraphStore.java | 419 ++++++++++++++++++ .../src/tlc2/basis/Resident.java | 350 ++++++++++++++- .../src/tlc2/tool/Worker.java | 2 +- .../src/tlc2/tool/impl/Tool.java | 7 +- .../src/tlc2/util/IStateWriter.java | 11 + 5 files changed, 785 insertions(+), 4 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java 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..57d345c402 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -0,0 +1,419 @@ +/******************************************************************************* + * 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 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. Blocked guards are tallied, not logged: per (action, conjunct) + * a count, one example state and one example binding. + */ +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 tally of the transitions it disabled. */ + public static final class Blocked { + public final int actionId; + public final String action; + public final String location; + public final String text; + public long count; + public long exampleFp; + public Map exampleBindings; + + Blocked(int actionId, String action, String location, String text) { + this.actionId = actionId; + this.action = action; + this.location = location; + this.text = text; + } + } + + private final File file; + private final RandomAccessFile content; + private final Map index = new HashMap<>(); + /** Fingerprint to (predecessor fp, action id, flags) triples. */ + private final Map predecessors = new HashMap<>(); + private final Map actions = new HashMap<>(); + private final Map blocked = new HashMap<>(); + private final List initial = new ArrayList<>(); + private long edges; + private long unsatisfied; + private TLCState empty; + + 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 synchronized void writeState(final TLCState state) { + // An initial state. + final long fp = state.fingerPrint(); + if (!index.containsKey(fp)) { + store(fp, state, 1, 0, -1); + initial.add(fp); + } + } + + @Override + public synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags) { + writeState(state, successor, stateFlags, (Action) null); + } + + @Override + public synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags, + final Action action) { + if (isSet(stateFlags, IsNotInModel)) { + return; + } + final long from = state.fingerPrint(); + final long to = successor.fingerPrint(); + final int actionId = action == null ? -1 : action.getId(); + if (action != null) { + actions.putIfAbsent(actionId, action); + } + edges++; + if (!index.containsKey(to)) { + final Entry pred = index.get(from); + store(to, successor, pred == null ? 2 : pred.level + 1, from, actionId); + } + predecessors.computeIfAbsent(to, k -> new LongVec(4)).addElement(from); + predecessors.get(to).addElement(actionId); + predecessors.get(to).addElement(stateFlags); + } + + @Override + public synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags, + final Action action, final SemanticNode pred) { + writeUnsatisfied(state, action, successor, pred, null); + } + + /** + * 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). + */ + public synchronized void writeUnsatisfied(final TLCState state, final Action action, final TLCState successor, + final SemanticNode pred, final Context c) { + unsatisfied++; + final int actionId = action == null ? -1 : action.getId(); + final String location = pred == null ? "?" : String.valueOf(pred.getLocation()); + final String key = actionId + "|" + location; + Blocked b = blocked.get(key); + if (b == null) { + b = new Blocked(actionId, action == null ? "?" : action.getNameOfDefault(), location, text(pred)); + try { + b.exampleFp = state.fingerPrint(); + } catch (final RuntimeException e) { + b.exampleFp = 0; + } + if (c != null) { + final Map bindings = new HashMap<>(); + c.toMap().forEach((k, v) -> bindings.put(k.toString(), v.toString())); + b.exampleBindings = bindings; + } + blocked.put(key, b); + } + b.count++; + } + + /** 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. The file goes away with the process. + */ + @Override + public void close() { + try { + content.getFD().sync(); + } catch (final IOException e) { + // Nothing to report to. + } + } + + @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 void snapshot() throws IOException { + content.getFD().sync(); + } + + // ─── storing and reading content ──────────────────────────────────── + + private void store(final long fp, final TLCState state, final int level, final long predecessor, + final int action) { + try { + // Only the variables' values: the TLCState header (worker id, uid, + // level) is unset on the states the writer hook receives, and the + // nat encodings reject negative values. + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(256); + final ValueOutputStream vos = new ValueOutputStream(bytes, false); + 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(vos); + } + vos.close(); + final byte[] data = bytes.toByteArray(); + final long offset = content.length(); + content.seek(offset); + content.write(data); + index.put(fp, new Entry(offset, data.length, level, predecessor, action)); + } catch (final IOException e) { + throw new RuntimeException("basis.states: " + e.getMessage(), e); + } + } + + /** The stored state with this fingerprint, or null. */ + public synchronized TLCState read(final long fp) { + final Entry e = index.get(fp); + if (e == null) { + return null; + } + try { + 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 = index.get(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 (!index.containsKey(fp)) { + return null; + } + final List reversed = new ArrayList<>(); + long cur = fp; + while (true) { + final Entry e = 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; + } + + /** (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); + } + + public synchronized List blocked() { + final List out = new ArrayList<>(blocked.values()); + out.sort((a, b) -> Long.compare(b.count, a.count)); + return out; + } + + public synchronized long states() { + return index.size(); + } + + public synchronized long edges() { + return edges; + } + + public synchronized long unsatisfied() { + return unsatisfied; + } + + public synchronized long initialStates() { + return initial.size(); + } + + public synchronized long bytes() { + try { + return content.length(); + } catch (final IOException e) { + return -1; + } + } +} diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 332dab599d..d2e9a48e7d 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -51,7 +51,14 @@ import tlc2.util.FP64; import tlc2.tool.impl.FastTool; import tlc2.tool.impl.Tool; -import tlc2.util.NoopStateWriter; +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; @@ -90,6 +97,7 @@ public final class Resident { private final Recorder recorder = new Recorder(); private Tool tool; + private GraphStore store; private ModelChecker checker; private Thread checkerThread; private String metadir; @@ -165,6 +173,21 @@ private JsonObject dispatch(final String command, final JsonObject request) thro reply.add("stats", stats()); 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 "store": { + final JsonObject reply = ok(); + reply.add("store", storeInfo()); + return reply; + } case "close": return ok(); default: @@ -223,7 +246,8 @@ private JsonObject open(final JsonObject request) { tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.MC, new HashMap<>()); final boolean checkDeadlock = deadlock && tool.getModelConfig().getCheckDeadlock(); - checker = new ModelChecker(tool, metadir, new NoopStateWriter(), checkDeadlock, null, + store = new GraphStore(metadir); + checker = new ModelChecker(tool, metadir, store, checkDeadlock, null, FPSetFactory.getFPSetInitialized(new FPSetConfiguration(), metadir, specFile.getName()), openedAt); TLCGlobals.mainChecker = checker; @@ -479,9 +503,331 @@ private JsonObject stats() { 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; } + 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("bytes", store.bytes()); + return o; + } + + // ─── the store's queries ──────────────────────────────────────────── + + private JsonObject notOpen() { + 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(); + } + + /** 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 { + final StateVec next = tool.getNextStates(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("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.add("blocked", rows); + reply.addProperty("note", + "attribution is to the first guard conjunct that evaluated false in TLC's evaluation order, on the all-assigned path only"); + return reply; + } + private void shutdown() { if (checker != null && checkerThread != null && checkerThread.isAlive()) { checker.stop(); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java index c58bcd0661..86c7eabaf5 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java @@ -494,7 +494,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; } 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..bf8a92761e 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java @@ -1179,7 +1179,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.processUnsatisfied(s0, action, s1, pred, Context.Empty, nss, cm); } private final TLCState getNextStatesApplSwitch(final Action action, final OpApplNode pred, final ActionItemList acts, final Context c, final TLCState s0, @@ -1545,6 +1546,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.processUnsatisfied(s0, action, s1, pred, c, nss, cm); } 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..faa3278d94 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java @@ -65,6 +65,17 @@ default boolean isSet(int v, int control) { void writeState(TLCState state, TLCState successor, short stateFlags, Action action, SemanticNode pred); + /** + * 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. + * The default keeps the older signature's behaviour and drops the context. + */ + default void writeUnsatisfied(TLCState state, Action action, TLCState successor, SemanticNode pred, + tlc2.util.Context c) { + 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); From 056305c415b4de4e4f7d2aec2aa2f671ecca6bb6 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 21 Sep 2026 15:36:31 -0400 Subject: [PATCH 03/33] Coverage as data and the run's registers tlc2.tool.coverage.CoverageWalk walks the ActionWrapper and OpApplNodeWrapper tree TLC's printer walks, but keeps every node with its location, source text, primary and secondary counts and whether it is primed, and lists per action and invariant the subexpressions no evaluation reached (a node counts as unevaluated only when nothing below it ran either, since a conjunction's own counter stays at zero while its conjuncts run, and assignment targets under a primed node are never evaluated). The printer skips zero-count subtrees and collapses consistent ones, which hides exactly the dead conjuncts a reader wants. Per variable it reports the HyperLogLog distinct-value estimate, null before exploration. It lives in the coverage package because the counters are package-private. The resident serves coverage, and registers: whether the reachable graph was exhausted and what stopped the run, the workers' out-degree buckets aggregated (ModelChecker gains a getWorkers accessor), the fingerprint set's statistics where its implementation keeps them, the optimistic fingerprint-collision probability, and whether liveness and coverage are on. Co-Authored-By: Claude Fable 5.1 --- .../src/tlc2/basis/Resident.java | 105 ++++++++ .../src/tlc2/tool/ModelChecker.java | 5 + .../src/tlc2/tool/coverage/CoverageWalk.java | 251 ++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index d2e9a48e7d..c017172adb 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -183,6 +183,23 @@ private JsonObject dispatch(final String command, final JsonObject request) thro return screen(request); case "guard_profile": return guardProfile(); + case "coverage": { + if (tool == null) { + return notOpen(); + } + final JsonObject reply = ok(); + reply.add("coverage", tlc2.tool.coverage.CoverageWalk.walk(tool)); + return reply; + } + case "registers": { + if (checker == null) { + return notOpen(); + } + final JsonObject reply = ok(); + reply.add("stats", stats()); + reply.add("registers", registers()); + return reply; + } case "store": { final JsonObject reply = ok(); reply.add("store", storeInfo()); @@ -507,6 +524,94 @@ private JsonObject stats() { 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; + final boolean queueEmpty = checker.getStateQueueSize() == 0; + r.addProperty("finished", finished); + r.addProperty("exhausted", finished && queueEmpty && checkerFailure == null + && (resultCode == EC.NO_ERROR || TLCGlobals.continuation)); + r.addProperty("stopped_by", checkerFailure != null ? "error" + : !finished ? (checkerThread == null ? "not_started" : "budget") + : resultCode == EC.NO_ERROR ? "exhausted" + : TLCGlobals.continuation ? "exhausted_with_violations" : "violation"); + if (finished) { + r.addProperty("result_code", resultCode); + } + r.addProperty("workers", TLCGlobals.getNumWorkers()); + r.addProperty("continuation", TLCGlobals.continuation); + 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) { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index 5ac60aafcf..ec8820c513 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java @@ -1088,6 +1088,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(); 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..4ef43fe845 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java @@ -0,0 +1,251 @@ +/******************************************************************************* + * 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 that are not primed (a primed node is assigned, not + * evaluated), listed flat with their locations. + */ + 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. + if (w.getEvalCount(Calculate.FRESH) == 0L && !w.isPrimed() && !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 || w.isPrimed()) { + 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); + } +} From 16a2de3cf179a7031203fec400584bf32ae0b238 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 21 Sep 2026 15:52:07 -0400 Subject: [PATCH 04/33] Incremental refresh: replay the store under an edited spec tlc2.basis.Incremental parses the edited spec into a new Tool, pairs actions and invariants with the old ones by name and source text (an action or invariant also counts as changed when a changed root-module definition is named in its text, a conservative dependency test), and replays the old store into a new one: states reachable from the initial states along unchanged actions survive with those edges copied, survivors are re-expanded under the changed and added actions only, states reached for the first time under every action; successor generation, constraints and invariant evaluation all go through the new Tool, so the store stays a cache of TLC's answers. A sweep then evaluates every invariant on every stored state for exact verdicts, the first violation being the lowest-level one. Changed variables, initial predicate or config cannot be carried: the resident answers restart_required, since a second ModelChecker in one JVM trips over TLC's per-process worker and trace bookkeeping. The resident serves refresh (diff, survivors, dropped, re-expanded, new states, edges copied and generated, per-invariant verdicts, the store's sizes) and refuses check after an incremental refresh, whose store the store queries serve. GraphStore exposes its initial fingerprints and variable names; the Recorder can reset. Not carried: liveness, deadlock reporting, and the blocked-guard tallies for the re-expanded states. Co-Authored-By: Claude Fable 5.1 --- .../src/tlc2/basis/GraphStore.java | 20 + .../src/tlc2/basis/Incremental.java | 509 ++++++++++++++++++ .../src/tlc2/basis/Recorder.java | 12 + .../src/tlc2/basis/Resident.java | 147 +++++ 4 files changed, 688 insertions(+) create mode 100644 tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index 57d345c402..0c2024da02 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -393,6 +393,26 @@ public synchronized List blocked() { 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 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(); } 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..e5481e15e1 --- /dev/null +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -0,0 +1,509 @@ +/******************************************************************************* + * 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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import tla2sany.semantic.OpDefNode; +import tlc2.tool.Action; +import tlc2.tool.StateVec; +import tlc2.tool.TLCState; +import tlc2.tool.impl.Tool; +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}. Actions and invariants + * are paired with the old ones by name and compared by source text, and an + * action or invariant also counts as changed when any changed operator + * definition of the root module is named in its text (a conservative + * approximation of "depends on"). If the variables, the initial predicate or + * the model config changed, nothing can be reused and the caller runs a + * fresh exploration instead. + * + *

+ * 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. + * 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. + * + *

+ * 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; + } + + 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 GraphStore oldStore) { + final ActionDiff d = new ActionDiff(); + // Variables. + final String[] oldVars = TLCState.Empty == null ? new String[0] : oldStore.variableNames(); + final List newVars = new ArrayList<>(); + for (final tla2sany.semantic.OpDeclNode v : newTool.getSpecProcessor().getVariablesNodes()) { + newVars.add(v.getName().toString()); + } + if (!new ArrayList<>(List.of(oldVars)).equals(newVars)) { + d.fullRerunReason = "the variables changed"; + return d; + } + // Definitions of the root module, by name. + 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); + } + } + // Initial predicate. + if (!initText(oldTool).equals(initText(newTool)) || mentionsChanged(initText(newTool), d.changedDefinitions)) { + d.fullRerunReason = "the initial predicate changed"; + return d; + } + // Actions. + final Map oldByName = new LinkedHashMap<>(); + for (final Action a : oldTool.getActions()) { + oldByName.put(a.getNameOfDefault() + "|" + a.getId(), a); + } + final Map> oldByKey = new HashMap<>(); + for (final Action a : oldTool.getActions()) { + oldByKey.computeIfAbsent(a.getNameOfDefault(), k -> new ArrayList<>()).add(a); + } + final Set matchedOld = new HashSet<>(); + for (final Action n : newTool.getActions()) { + final String name = n.getNameOfDefault(); + final List candidates = oldByKey.getOrDefault(name, List.of()); + Action match = null; + for (final Action o : candidates) { + if (!matchedOld.contains(o.getId()) && text(o).equals(text(n))) { + match = o; + break; + } + } + if (match != null && !mentionsChanged(text(n), d.changedDefinitions)) { + matchedOld.add(match.getId()); + d.carried.put(match.getId(), n); + d.unchanged.add(name); + } else if (!candidates.isEmpty()) { + // Same name, different text (or a dependency changed). + for (final Action o : candidates) { + if (!matchedOld.contains(o.getId())) { + matchedOld.add(o.getId()); + break; + } + } + 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(), text(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(); + final String t = text(newInvs[i]); + if (!t.equals(oldInv.get(name)) || mentionsChanged(t, d.changedDefinitions)) { + d.changedInvariants.add(name); + } + } + return d; + } + + 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 initText(final Tool tool) { + final StringBuilder sb = new StringBuilder(); + final Vect init = tool.getInitStateSpec(); + for (int i = 0; i < init.size(); i++) { + sb.append(text(init.elementAt(i))).append('\n'); + } + return sb.toString(); + } + + /** Whether {@code text} names any of the changed definitions (a conservative dependency test). */ + private static boolean mentionsChanged(final String text, final List changed) { + for (final String name : changed) { + int at = text.indexOf(name); + while (at >= 0) { + final boolean before = at == 0 || !Character.isLetterOrDigit(text.charAt(at - 1)) && text.charAt(at - 1) != '_'; + final int end = at + name.length(); + final boolean after = end >= text.length() + || !Character.isLetterOrDigit(text.charAt(end)) && text.charAt(end) != '_'; + if (before && after) { + return true; + } + at = text.indexOf(name, at + 1); + } + } + return false; + } + + /** + * 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 = oldStore.read(fp); + if (s == null) { + continue; + } + newStore.writeState(rebind(newTool, s)); + seen.add(fp); + queue.add(fp); + } + final Set oldStates = new HashSet<>(); + for (final long fp : oldStore.fingerprints()) { + oldStates.add(fp); + } + boolean stop = false; + 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)); + final boolean survivor = oldStates.contains(fp); + if (survivor) { + r.survivors++; + // Carried edges: copy successors and their content. + 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) : rebind(newTool, oldStore.read(to)); + 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); + } + } + // Changed invariants on a survivor. + for (int k = 0; k < invariants.length; k++) { + final String name = k < invNames.length ? invNames[k] : invariants[k].getNameOfDefault(); + if (changedInv.contains(name) && !newTool.isValid(invariants[k], state)) { + r.violations.add(new Violation(name, fp, newStore.level(fp))); + if (!continueOnViolation) { + stop = true; + 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); + } + + /** 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. */ + 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.getNextStates(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 = tool.isInModel(succ) && tool.isInActions(state, succ); + } catch (final Throwable t) { + r.error = "constraint: " + t; + return true; + } + if (!inModel) { + continue; + } + final long to = succ.fingerPrint(); + final boolean unseen = !store.contains(to); + store.writeState(state, succ, unseen ? tlc2.util.IStateWriter.IsUnseen : tlc2.util.IStateWriter.IsSeen, a); + r.edgesGenerated++; + if (unseen) { + for (int k = 0; k < invariants.length; k++) { + boolean holds; + try { + holds = tool.isValid(invariants[k], succ); + } catch (final Throwable t) { + r.error = (k < invNames.length ? invNames[k] : "invariant") + ": " + t; + return true; + } + if (!holds) { + r.violations.add(new Violation(k < invNames.length ? invNames[k] : invariants[k].getNameOfDefault(), + to, store.level(to))); + if (!continueOnViolation) { + return true; + } + } + } + } + if (seen.add(to)) { + queue.add(to); + } + } + } + return false; + } + + /** 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: 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) { + final Action[] invariants = tool.getInvariants(); + final String[] names = tool.getInvNames(); + final List out = new ArrayList<>(); + for (int k = 0; k < invariants.length; k++) { + out.add(new Sweep(k < names.length ? names[k] : invariants[k].getNameOfDefault())); + } + for (final long fp : store.fingerprints()) { + 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 (sw.error != null) { + continue; + } + try { + if (!tool.isValid(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; + } + + 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 index f1654c49f6..1decea988f 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -186,6 +186,18 @@ private static Long parseLong(final Object o) { } } + /** 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(); + 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(); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index c017172adb..2eee3ff1f3 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -99,6 +99,13 @@ public final class Resident { private Tool tool; private GraphStore store; private ModelChecker checker; + /** True after an incremental refresh: the store is current, the checker is not. */ + private boolean refreshed; + private String specDir; + private String mainFile; + private String configName; + private int workers; + private boolean checkDeadlock; private Thread checkerThread; private String metadir; private volatile Integer resultCode; @@ -183,6 +190,8 @@ private JsonObject dispatch(final String command, final JsonObject request) thro return screen(request); case "guard_profile": return guardProfile(); + case "refresh": + return refresh(request); case "coverage": { if (tool == null) { return notOpen(); @@ -252,6 +261,10 @@ private JsonObject open(final JsonObject request) { 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 @@ -263,6 +276,7 @@ private JsonObject open(final JsonObject request) { tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.MC, new HashMap<>()); final boolean checkDeadlock = deadlock && tool.getModelConfig().getCheckDeadlock(); + this.checkDeadlock = checkDeadlock; store = new GraphStore(metadir); checker = new ModelChecker(tool, metadir, store, checkDeadlock, null, FPSetFactory.getFPSetInitialized(new FPSetConfiguration(), metadir, specFile.getName()), @@ -342,6 +356,10 @@ 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; @@ -648,6 +666,135 @@ private String actionName(final long 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 or the config discards the store and starts over. + */ + private JsonObject refresh(final JsonObject request) throws Exception { + 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(); + if (checker != null && checkerThread != null && checkerThread.isAlive()) { + checker.stop(); + checkerThread.join(10_000); + } + recorder.drainMessages(); + final Tool oldTool = tool; + final GraphStore oldStore = store; + 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 old tool and store stay in place. + return reply; + } + final Incremental.ActionDiff diff = Incremental.diff(oldTool, newTool, oldStore); + final JsonObject reply = ok(); + reply.add("diff", Incremental.diffJson(diff)); + reply.addProperty("front_end_ms", System.currentTimeMillis() - started); + if (diff.fullRerunReason != null || oldStore == null) { + // 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. + reply.addProperty("mode", "full"); + reply.addProperty("restart_required", true); + reply.addProperty("reason", diff.fullRerunReason != null ? diff.fullRerunReason : "nothing to carry"); + return reply; + } + reply.addProperty("mode", "incremental"); + final String newMetadir = FileUtil.makeMetaDir(new Date(System.currentTimeMillis()), specDir, null); + final GraphStore newStore = new GraphStore(newMetadir); + final Incremental.Result r = Incremental.replay(newTool, oldStore, newStore, diff, budgetMs, cont); + tool = newTool; + store = newStore; + metadir = newMetadir; + refreshed = true; + 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); + reply.addProperty("finished", !r.budgetExhausted && r.error == null); + if (r.error != null) { + reply.addProperty("error", r.error); + } + 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 finished (one evaluation per state and invariant), else + // not_evaluated. + final JsonArray invs = new JsonArray(); + final boolean finished = !r.budgetExhausted && r.error == null; + if (finished && (cont || r.violations.isEmpty())) { + 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); + reply.addProperty("duration_ms", System.currentTimeMillis() - started); + reply.add("store", storeInfo()); + reply.add("messages", recorder.drainMessages()); + return reply; + } + /** The path from an initial state to a stored fingerprint. */ private JsonObject trace(final JsonObject request) { if (store == null) { From a434ffd22da25aac7dffcf5828461910bbb33f26 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 21 Sep 2026 15:59:30 -0400 Subject: [PATCH 05/33] Simulate mode: random behaviours with the action-pair follow matrix The resident opens with mode simulate: a Tool in simulation mode and a Simulator with depth, traces and seed (random and reported by default). A non-null traceActions value sizes the per-worker action-pair counters without TLC writing its dot files at the end. simulate runs until the traces, a violation or the budget (Simulator.stop), then answers the verdict, every violating behaviour as a typed trace, the simulator's statistics record and the follow matrix from Simulator.actionFlowAsJson, a public accessor over the private snapshot that reduces contexts to one vertex per definition and says whether the extended statistics were armed at class load. Co-Authored-By: Claude Fable 5.1 --- .../src/tlc2/basis/Resident.java | 145 ++++++++++++++++++ .../src/tlc2/tool/Simulator.java | 32 ++++ 2 files changed, 177 insertions(+) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 2eee3ff1f3..a49bfbe644 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -106,6 +106,12 @@ public final class Resident { 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; + private long simulationMs; private Thread checkerThread; private String metadir; private volatile Integer resultCode; @@ -192,6 +198,8 @@ private JsonObject dispatch(final String command, final JsonObject request) thro return guardProfile(); case "refresh": return refresh(request); + case "simulate": + return simulate(request); case "coverage": { if (tool == null) { return notOpen(); @@ -259,6 +267,10 @@ private JsonObject open(final JsonObject request) { + 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; @@ -303,6 +315,131 @@ private JsonObject open(final JsonObject request) { 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); + tool = new FastTool(mainFile, config, new SimpleFilenameToStream(specDir), Tool.Mode.Simulation, + new HashMap<>()); + // 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; + 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 (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; + simulatorThread.join(10_000); + break; + } + simulatorThread.join(20); + } + simulationMs += System.currentTimeMillis() - started; + final JsonObject reply = ok(); + final boolean finished = !simulatorThread.isAlive(); + reply.addProperty("finished", finished); + reply.addProperty("stopped_by_budget", stopped); + final int outcome = recorder.outcome(); + 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 { + reply.addProperty("verdict", "no_violation_found"); + } + final String property = recorder.outcomeProperty(); + if (property != null) { + reply.addProperty("violated", property); + } + 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(); @@ -1081,6 +1218,14 @@ private JsonObject guardProfile() { } private 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 { 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. From 69c5b4f3230163dbeab719a0758884159249f0fc Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 21 Sep 2026 18:28:24 -0400 Subject: [PATCH 06/33] Cap counterexample traces per property under continuation TLCGlobals.continuationTraceLimit bounds how many traces Worker and ModelChecker regenerate per invariant or implied action under -continue; further violations are still reported (and so counted) but not traced. A trace is rebuilt by re-running the next-state relation from an initial state, which dominated a Raft run whose invariant failed on most states. The resident sets the cap from a check's traces_per_property (default 1, -1 unlimited) and lists only traces that carry states. Co-Authored-By: Claude Fable 5.1 --- .../src/tlc2/TLCGlobals.java | 25 +++++++++++++++++++ .../src/tlc2/basis/Resident.java | 15 ++++++++++- .../src/tlc2/tool/ModelChecker.java | 4 ++- .../src/tlc2/tool/Worker.java | 8 ++++-- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java b/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java index 3c022d1e72..e1efdb29b8 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java @@ -210,6 +210,31 @@ public static final boolean isCoverageEnabled() { // Continue running even when invariant is violated public static boolean continuation = false; + /** + * Basis: under continuation, regenerate and print at most this many + * counterexample traces per invariant or implied action (a trace is + * rebuilt by re-running the next-state relation from an initial state, + * which dominates a run whose invariant fails on most states). Further + * violations are still reported by message, so they are counted, but + * without a trace. Negative means no limit, as upstream. + */ + public static int continuationTraceLimit = -1; + private static final java.util.concurrent.ConcurrentHashMap 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; + } + + 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/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index a49bfbe644..b96df40edb 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -507,6 +507,14 @@ private JsonObject check(final JsonObject request) throws InterruptedException { // invariant's verdict. Process-global, as TLC's -continue is. TLCGlobals.continuation = request.get("continue").getAsBoolean(); } + // 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; + TLCGlobals.resetContinuationTraces(); if (resultCode == null && checkerFailure == null) { if (checkerThread == null) { @@ -564,9 +572,14 @@ private JsonObject check(final JsonObject request) throws InterruptedException { 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()) { - all.add(traceJson(t)); + if (!t.states.isEmpty()) { + all.add(traceJson(t)); + } } reply.add("traces", all); reply.add("invariants", invariantVerdicts(finished)); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index ec8820c513..59946e73ce 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 { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java index 86c7eabaf5..0e6ef1b888 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java @@ -559,7 +559,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 +593,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 { From 818ec9d24b14790d927375738b173685d18d8b02 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Tue, 22 Sep 2026 13:41:30 -0400 Subject: [PATCH 07/33] Refresh: replay only a complete store under an unchanged frame Review fixes for the incremental refresh and simulate paths. - The variables check compared the new spec with itself: the store's names came from TLC's static table, which the new parse had already replaced. Both lists now come from each tool's own declarations. - A config edit, a changed state or action constraint, view or symmetry set, or an old exploration that did not finish (budget, first violation, error, or anything left queued) now forces a full rerun. Copying an edge is sound only for a fully explored graph under the same constraints. - "Changed" is decided by a signature: the node's text, every user definition it reaches transitively across modules, and its context bindings, so an edit two definitions down or to a quantifier bound is seen. Split actions left unpaired are reported as added. - Refresh no longer stops a paused checker (stopping made the next check report a cut-short run as finished). When the new tool is not adopted, TLC's static variable tables are rebound to the old spec; if a definition slot was lost the paused run refuses to resume. - Refresh in simulate mode is refused instead of throwing. - A simulate call after a budget stop reports that stop again, marked not resumable, rather than a completed run. - The invariant verdicts of check use the same completeness test. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Incremental.java | 198 +++++++++++++----- .../src/tlc2/basis/Resident.java | 158 ++++++++++++-- 2 files changed, 286 insertions(+), 70 deletions(-) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index e5481e15e1..c3f176e671 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -26,32 +26,43 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; 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.OpApplNode; +import tla2sany.semantic.OpArgNode; +import tla2sany.semantic.OpDeclNode; import tla2sany.semantic.OpDefNode; +import tla2sany.semantic.SemanticNode; +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}. Actions and invariants - * are paired with the old ones by name and compared by source text, and an - * action or invariant also counts as changed when any changed operator - * definition of the root module is named in its text (a conservative - * approximation of "depends on"). If the variables, the initial predicate or - * the model config changed, nothing can be reused and the caller runs a - * fresh exploration instead. + * The edited spec is parsed into a new {@link Tool}. Every action, invariant, + * initial predicate, constraint, view and symmetry set 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, a state or action constraint, the view or + * the symmetry set changed, 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 @@ -62,7 +73,9 @@ * invariant, and every surviving state against the changed invariants. * 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. + * 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 @@ -118,19 +131,16 @@ private static String text(final Action a) { } /** Pair the old and new specs' actions, invariants and definitions. */ - public static ActionDiff diff(final Tool oldTool, final Tool newTool, final GraphStore oldStore) { + public static ActionDiff diff(final Tool oldTool, final Tool newTool) { final ActionDiff d = new ActionDiff(); - // Variables. - final String[] oldVars = TLCState.Empty == null ? new String[0] : oldStore.variableNames(); - final List newVars = new ArrayList<>(); - for (final tla2sany.semantic.OpDeclNode v : newTool.getSpecProcessor().getVariablesNodes()) { - newVars.add(v.getName().toString()); - } - if (!new ArrayList<>(List.of(oldVars)).equals(newVars)) { + // 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. + // 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()) { @@ -144,43 +154,44 @@ public static ActionDiff diff(final Tool oldTool, final Tool newTool, final Grap d.changedDefinitions.add(name); } } - // Initial predicate. - if (!initText(oldTool).equals(initText(newTool)) || mentionsChanged(initText(newTool), d.changedDefinitions)) { + // 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; } - // Actions. - final Map oldByName = new LinkedHashMap<>(); - for (final Action a : oldTool.getActions()) { - oldByName.put(a.getNameOfDefault() + "|" + a.getId(), a); + if (!constraintSignature(oldTool).equals(constraintSignature(newTool))) { + d.fullRerunReason = "a state or action constraint changed"; + return d; + } + if (!fingerprintSignature(oldTool).equals(fingerprintSignature(newTool))) { + d.fullRerunReason = "the view or the symmetry set changed"; + 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)); } final Set matchedOld = new HashSet<>(); for (final Action n : newTool.getActions()) { final String name = n.getNameOfDefault(); + final String sig = signature(n); final List candidates = oldByKey.getOrDefault(name, List.of()); Action match = null; for (final Action o : candidates) { - if (!matchedOld.contains(o.getId()) && text(o).equals(text(n))) { + if (!matchedOld.contains(o.getId()) && oldSig.get(o.getId()).equals(sig)) { match = o; break; } } - if (match != null && !mentionsChanged(text(n), d.changedDefinitions)) { + if (match != null) { matchedOld.add(match.getId()); d.carried.put(match.getId(), n); d.unchanged.add(name); - } else if (!candidates.isEmpty()) { - // Same name, different text (or a dependency changed). - for (final Action o : candidates) { - if (!matchedOld.contains(o.getId())) { - matchedOld.add(o.getId()); - break; - } - } + } else if (pairUnmatched(candidates, matchedOld)) { + // Same name, different signature. d.changed.add(name); d.reexpand.add(n); } else { @@ -198,20 +209,37 @@ public static ActionDiff diff(final Tool oldTool, final Tool newTool, final Grap 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(), text(oldInvs[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(); - final String t = text(newInvs[i]); - if (!t.equals(oldInv.get(name)) || mentionsChanged(t, d.changedDefinitions)) { + if (!signature(newInvs[i]).equals(oldInv.get(name))) { d.changedInvariants.add(name); } } return d; } + /** 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(); @@ -227,31 +255,97 @@ private static Map definitions(final Tool tool) { return out; } - private static String initText(final Tool tool) { + private static String signature(final Action a) { + return signature(a.pred, a.con); + } + + /** + * The text of {@code node}, the name and text of every user definition it + * reaches (transitively, in name order) 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); + } + 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() + "!"; + reached.put(module + def.getName(), GraphStore.text(def.getBody())); + 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(text(init.elementAt(i))).append('\n'); + sb.append(signature(init.elementAt(i))).append('\n'); } return sb.toString(); } - /** Whether {@code text} names any of the changed definitions (a conservative dependency test). */ - private static boolean mentionsChanged(final String text, final List changed) { - for (final String name : changed) { - int at = text.indexOf(name); - while (at >= 0) { - final boolean before = at == 0 || !Character.isLetterOrDigit(text.charAt(at - 1)) && text.charAt(at - 1) != '_'; - final int end = at + name.length(); - final boolean after = end >= text.length() - || !Character.isLetterOrDigit(text.charAt(end)) && text.charAt(end) != '_'; - if (before && after) { - return true; + 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 view and symmetry set: they decide what a fingerprint identifies. */ + private static String fingerprintSignature(final Tool tool) { + final StringBuilder sb = new StringBuilder(); + sb.append("view ").append(signature(tool.getViewSpec(), null)).append('\n'); + final String symmetry = tool.getModelConfig().getSymmetry(); + if (symmetry != null && !symmetry.isEmpty()) { + sb.append("symmetry ").append(symmetry); + final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs(); + for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) { + if (def.getName().toString().equals(symmetry)) { + sb.append(' ').append(signature(def.getBody(), null)); } - at = text.indexOf(name, at + 1); } } - return false; + return sb.toString(); } /** diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index b96df40edb..11876e80c4 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -101,6 +101,15 @@ public final class Resident { private ModelChecker checker; /** True after an incremental refresh: the store is current, the checker is not. */ private boolean refreshed; + /** After an incremental refresh: whether the replay explored the whole graph. */ + private boolean refreshComplete; + /** + * Set when a refresh that did not replace the tool left TLC's static + * tables unfit for the parked checker to resume: why a restart is needed. + */ + 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; @@ -111,6 +120,8 @@ public final class Resident { 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; private String metadir; @@ -289,6 +300,7 @@ private JsonObject open(final JsonObject request) { new HashMap<>()); final boolean checkDeadlock = deadlock && tool.getModelConfig().getCheckDeadlock(); this.checkDeadlock = checkDeadlock; + configText = readConfig(); store = new GraphStore(metadir); checker = new ModelChecker(tool, metadir, store, checkDeadlock, null, FPSetFactory.getFPSetInitialized(new FPSetConfiguration(), metadir, specFile.getName()), @@ -381,6 +393,13 @@ private JsonObject simulate(final JsonObject request) throws InterruptedExceptio } 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 { @@ -397,12 +416,17 @@ private JsonObject simulate(final JsonObject request) throws InterruptedExceptio 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); @@ -497,6 +521,10 @@ private JsonObject check(final JsonObject request) throws InterruptedException { 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"); } + if (restartRequired != null && resultCode == null && checkerFailure == null) { + return error(null, "restart_required", + "the paused run cannot resume in this process: " + restartRequired + "; open a new session"); + } 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; @@ -618,8 +646,7 @@ private JsonArray invariantVerdicts(final boolean finished) { final JsonArray out = new JsonArray(); final Map counts = recorder.violationCounts(); final List traces = recorder.traces(); - final boolean exhausted = finished && checkerFailure == null && resultCode != null - && (resultCode == EC.NO_ERROR || TLCGlobals.continuation); + final boolean exhausted = finished && explorationComplete(); for (final String name : tool.getInvNames()) { final JsonObject v = new JsonObject(); v.addProperty("name", name); @@ -663,6 +690,33 @@ private static String verdict(final int code, final int outcome) { } } + /** + * 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) { + 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 TLCGlobals.continuation; + default: + return false; + } + } + // ─── stats ────────────────────────────────────────────────────────── private JsonObject stats() { @@ -818,21 +872,40 @@ private String actionName(final long id) { /** * 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 or the config discards the store and starts over. + * reaches (see {@link Incremental}). A change to the variables, the + * initial predicate, a constraint, the view, the symmetry set or the + * config, or an old exploration that did not finish, leaves nothing to + * carry: the reply asks for a restart and a full run. + * + *

+ * 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(); - if (checker != null && checkerThread != null && checkerThread.isAlive()) { - checker.stop(); - checkerThread.join(10_000); - } recorder.drainMessages(); + // Decided before parsing, so these paths leave TLC's statics alone. + final String currentConfig = readConfig(); + String before = null; + if (currentConfig == null || !currentConfig.equals(configText)) { + before = "the model config changed"; + } else if (refreshed ? !refreshComplete : !explorationComplete()) { + before = "the previous exploration did not finish, so the store is not the whole graph"; + } + if (before != null) { + return fullRerun(ok(), before, started); + } final Tool oldTool = tool; final GraphStore oldStore = store; final Tool newTool; @@ -843,21 +916,16 @@ private JsonObject refresh(final JsonObject request) throws Exception { final JsonObject reply = error(null, "parse_failed", t.toString()); reply.add("messages", recorder.drainMessages()); // The old tool and store stay in place. + rebindStatics(oldTool); return reply; } - final Incremental.ActionDiff diff = Incremental.diff(oldTool, newTool, oldStore); + final Incremental.ActionDiff diff = Incremental.diff(oldTool, newTool); final JsonObject reply = ok(); reply.add("diff", Incremental.diffJson(diff)); reply.addProperty("front_end_ms", System.currentTimeMillis() - started); - if (diff.fullRerunReason != null || oldStore == null) { - // 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. - reply.addProperty("mode", "full"); - reply.addProperty("restart_required", true); - reply.addProperty("reason", diff.fullRerunReason != null ? diff.fullRerunReason : "nothing to carry"); - return reply; + if (diff.fullRerunReason != null) { + rebindStatics(oldTool); + return fullRerun(reply, diff.fullRerunReason, started); } reply.addProperty("mode", "incremental"); final String newMetadir = FileUtil.makeMetaDir(new Date(System.currentTimeMillis()), specDir, null); @@ -867,6 +935,10 @@ private JsonObject refresh(final JsonObject request) throws Exception { store = newStore; metadir = newMetadir; refreshed = true; + // Only a replay that ran to the end holds the whole graph: one cut + // by its budget, an error or a first violation leaves states whose + // successors were never generated. + refreshComplete = !r.budgetExhausted && r.error == null && (cont || r.violations.isEmpty()); reply.addProperty("survivors", r.survivors); reply.addProperty("dropped", r.dropped); reply.addProperty("reexpanded", r.reexpanded); @@ -945,6 +1017,56 @@ private JsonObject refresh(final JsonObject request) throws Exception { return reply; } + /** + * 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; this + * puts the old spec's back. A name that was a definition of the old spec + * and a variable of the new one has lost its definition slot, which + * cannot be put back: the parked run then must not resume. + */ + private void rebindStatics(final Tool old) { + 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; + } + } + } + /** The path from an initial state to a stored fingerprint. */ private JsonObject trace(final JsonObject request) { if (store == null) { From 7e6baef57b371aa53c2e4b6ae8b2af488978e5aa Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Tue, 22 Sep 2026 17:07:54 -0400 Subject: [PATCH 08/33] Resident review fixes: stuttering traces, held pauses, <- substitutions, store content - Recorder: a stuttering tail (TLC_STATE_PRINT3) arrives as a TLCStateInfo with a null state; record it as null instead of throwing, which broke every liveness counterexample ending in stuttering. TLC_STATE_PRINT1 now accepts the TLCStateInfo MP.printState always passes, so standalone and runtime-error states are kept. - ModelChecker: suspend() now holds the workers. Periodic work (checkpoints, liveness checks) suspends and resumes the queue itself and used to undo a resident's budget pause; it now resumes only when nobody holds them, and resume() defers to periodic work in progress. - Incremental: config substitutions (CONSTANT N <- Def, Op <- Def, [M] Op <- Def) are bound as tool objects, invisible to the signature walk. An edit to a substituted definition now forces a full rerun. - GraphStore: keep a state's content from the IsUnseen write, the one TLC enqueued, so a VIEW or SYMMETRY run with several workers cannot store a different concrete state than the one whose successors are recorded. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 7 ++- .../src/tlc2/basis/Incremental.java | 39 ++++++++++++++++ .../src/tlc2/basis/Recorder.java | 21 ++++++--- .../src/tlc2/tool/ModelChecker.java | 44 +++++++++++++++++-- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index 0c2024da02..eff248908c 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -160,7 +160,12 @@ public synchronized void writeState(final TLCState state, final TLCState success actions.putIfAbsent(actionId, action); } edges++; - if (!index.containsKey(to)) { + // 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. + if (isSet(stateFlags, IsUnseen) && !index.containsKey(to)) { final Entry pred = index.get(from); store(to, successor, pred == null ? 2 : pred.level + 1, from, actionId); } diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index c3f176e671..adaaae9730 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -167,6 +167,10 @@ public static ActionDiff diff(final Tool oldTool, final Tool newTool) { d.fullRerunReason = "the view or the symmetry set changed"; return d; } + if (!substitutionSignature(oldTool).equals(substitutionSignature(newTool))) { + d.fullRerunReason = "a definition the config substitutes with <- changed"; + return d; + } // Actions. final Map> oldByKey = new HashMap<>(); final Map oldSig = new HashMap<>(); @@ -331,6 +335,41 @@ private static String constraintSignature(final Tool tool) { 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); + } + /** The view and symmetry set: they decide what a fingerprint identifies. */ private static String fingerprintSignature(final Tool tool) { final StringBuilder sb = new StringBuilder(); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java index 1decea988f..2ce10bb883 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -84,7 +84,9 @@ public synchronized void record(final int code, final Object... objects) { if (objects != null) { for (final Object o : objects) { if (o instanceof TLCStateInfo) { - params.add(stateInfo((TLCStateInfo) o)); + // 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[]) { @@ -110,6 +112,7 @@ public synchronized void record(final int code, final Object... objects) { 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) { @@ -128,10 +131,15 @@ public synchronized void record(final int code, final Object... objects) { trace = new Trace(); trace.code = code; } - if (objects != null && objects.length > 0 && objects[0] instanceof TLCState) { - final JsonObject s = state((TLCState) objects[0]); - s.addProperty("ordinal", trace.states.size() + 1); - trace.states.add(s); + // 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; @@ -140,7 +148,8 @@ public synchronized void record(final int code, final Object... objects) { trace = new Trace(); trace.code = code; } - if (objects != null && objects.length >= 2 && objects[0] instanceof TLCStateInfo) { + 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); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index 59946e73ce..5e0e7ab8ce 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java @@ -698,7 +698,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 @@ -726,7 +726,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; @@ -745,7 +745,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(); @@ -1059,8 +1059,40 @@ 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; + + private boolean periodicSuspend() { + synchronized (this) { + this.periodicParked = true; + } + final boolean suspended = this.theStateQueue.suspendAll(); + 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.held = true; this.theStateQueue.suspendAll(); this.notifyAll(); } @@ -1068,7 +1100,11 @@ public void suspend() { 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(); } } From a992b941a61cedd6b08a260a924da9bffb258c8e Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Tue, 22 Sep 2026 17:53:59 -0400 Subject: [PATCH 09/33] Resident review fixes: guard hooks, refresh base, store cost, tests - Tool: the false-guard sites added for the store (user-defined guards, the general path, and now = / \in / \subseteq guards on unprimed variables) report through a private falseGuard, not processUnsatisfied, so the debugger's frames are unchanged. IStateWriter's default writeUnsatisfied forwards only a fully assigned successor, so -dump dot,constrained no longer throws a NullPointerException fingerprinting a partial state. - guard_profile counts equality and membership guards (pc = "a"), and its note says what a count means, disjunctions included; after a refresh it says its tallies are stale. - refresh replays from the last fully explored graph. A replay cut short by its budget or a first violation is served but not replayed from, so the fix after a violation is incremental rather than a restart; a replay that fails is not adopted and the store stays. The reply adds complete, stopped_at_first_violation, adopted, replayed_from, and unchecked (temporal properties, implied actions, deadlock, guard tallies). Stores nothing refers to are closed and deleted. - GraphStore: fingerprinting and serialising run outside the lock, a per-thread serialiser reuses its buffer (ValueOutputStream.reset), and file writes are batched. open takes store: false to run without it; the heap cost is documented. - The continuation trace cap is per run, not per check call. - Tests: DotConstrainedGuardTest, GraphStoreGuardTest, TLCGlobalsContinuationTraceTest, and resident tests for refresh against fresh-run graphs, a pause held through checkpoints, and coverage. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 167 +++++-- .../src/tlc2/basis/Resident.java | 171 +++++-- .../src/tlc2/tool/impl/Tool.java | 31 +- .../src/tlc2/util/IStateWriter.java | 9 +- .../src/tlc2/value/ValueOutputStream.java | 446 +++++++++--------- .../test-model/basis/Guards.cfg | 2 + .../test-model/basis/Guards.tla | 13 + .../tlc2/TLCGlobalsContinuationTraceTest.java | 64 +++ .../test/tlc2/basis/GraphStoreGuardTest.java | 120 +++++ .../test/tlc2/basis/ResidentCoverageTest.java | 71 +++ .../test/tlc2/basis/ResidentHarness.java | 75 +++ .../test/tlc2/basis/ResidentPauseTest.java | 78 +++ .../test/tlc2/basis/ResidentRefreshTest.java | 139 ++++++ .../tlc2/tool/DotConstrainedGuardTest.java | 107 +++++ 14 files changed, 1193 insertions(+), 300 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test-model/basis/Guards.cfg create mode 100644 tlatools/org.lamport.tlatools/test-model/basis/Guards.tla create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/TLCGlobalsContinuationTraceTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPauseTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/tool/DotConstrainedGuardTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index eff248908c..8bfd965a57 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -64,6 +64,12 @@ * to offset, level and first predecessor) and the predecessor lists stay in * memory. Blocked guards are tallied, not logged: per (action, conjunct) * a count, one example state and one example binding. + * + *

+ * 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 { @@ -115,6 +121,11 @@ public static final class Blocked { private long edges; private long unsatisfied; 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"); @@ -133,12 +144,15 @@ private TLCState empty() { // ─── what the checker writes ──────────────────────────────────────── @Override - public synchronized void writeState(final TLCState state) { + public void writeState(final TLCState state) { // An initial state. final long fp = state.fingerPrint(); - if (!index.containsKey(fp)) { - store(fp, state, 1, 0, -1); - initial.add(fp); + final byte[] data = serialise(state); + synchronized (this) { + if (!index.containsKey(fp)) { + store(fp, data, 1, 0, -1); + initial.add(fp); + } } } @@ -148,30 +162,36 @@ public synchronized void writeState(final TLCState state, final TLCState success } @Override - public synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags, + 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(); - if (action != null) { - actions.putIfAbsent(actionId, action); - } - edges++; // 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. - if (isSet(stateFlags, IsUnseen) && !index.containsKey(to)) { - final Entry pred = index.get(from); - store(to, successor, pred == null ? 2 : pred.level + 1, from, actionId); + 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); } - predecessors.computeIfAbsent(to, k -> new LongVec(4)).addElement(from); - predecessors.get(to).addElement(actionId); - predecessors.get(to).addElement(stateFlags); } @Override @@ -240,17 +260,40 @@ public void writeState(final TLCState state, final TLCState successor, final Bit /** * The checker closes its state writer when a run ends; the store outlives - * the run, so this only flushes. The file goes away with the process. + * the run, so this only flushes. {@link #dispose()} releases it. */ @Override - public void close() { + 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(); + file.delete(); + final File dir = file.getParentFile(); + if (dir != null) { + final String[] left = dir.list(); + if (left != null && left.length == 0) { + dir.delete(); + } + } + } + @Override public String getDumpFileName() { return file.getPath(); @@ -273,38 +316,91 @@ public boolean isConstrained() { } @Override - public void snapshot() throws IOException { + public synchronized void snapshot() throws IOException { + flush(); content.getFD().sync(); } // ─── storing and reading content ──────────────────────────────────── - private void store(final long fp, final TLCState state, final int level, final long predecessor, - final int action) { + /** + * 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 { - // Only the variables' values: the TLCState header (worker id, uid, - // level) is unset on the states the writer hook receives, and the - // nat encodings reject negative values. - final ByteArrayOutputStream bytes = new ByteArrayOutputStream(256); - final ValueOutputStream vos = new ValueOutputStream(bytes, false); + ser.bytes.reset(); + ser.vos.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(vos); + value.write(ser.vos); } - vos.close(); - final byte[] data = bytes.toByteArray(); - final long offset = content.length(); - content.seek(offset); - content.write(data); - index.put(fp, new Entry(offset, data.length, level, predecessor, action)); + 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) { + final long offset = length; + pending.write(data, 0, data.length); + length += data.length; + index.put(fp, new Entry(offset, data.length, level, predecessor, action)); + if (pending.size() >= FLUSH_AT) { + try { + flush(); + } catch (final IOException e) { + throw new RuntimeException("basis.states: " + e.getMessage(), e); + } + } + } + + 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 with this fingerprint, or null. */ public synchronized TLCState read(final long fp) { final Entry e = index.get(fp); @@ -312,6 +408,7 @@ public synchronized TLCState read(final long fp) { return null; } try { + flush(); final byte[] data = new byte[e.length]; content.seek(e.offset); content.readFully(data); @@ -435,10 +532,6 @@ public synchronized long initialStates() { } public synchronized long bytes() { - try { - return content.length(); - } catch (final IOException e) { - return -1; - } + return length; } } diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 11876e80c4..fa065e2110 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -78,7 +78,9 @@ * 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. + * 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). *

  • {@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 @@ -101,8 +103,17 @@ public final class Resident { private ModelChecker checker; /** True after an incremental refresh: the store is current, the checker is not. */ private boolean refreshed; - /** After an incremental refresh: whether the replay explored the whole graph. */ - 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 parked checker to resume: why a restart is needed. @@ -141,8 +152,7 @@ public static void main(final String[] args) throws IOException { ToolIO.err = sink; ToolIO.setMode(ToolIO.TOOL); - final Resident resident = new Resident(); - MP.setRecorder(resident.recorder); + final Resident resident = install(); final JsonObject ready = new JsonObject(); ready.addProperty("event", "ready"); @@ -186,6 +196,18 @@ public static void main(final String[] args) throws IOException { 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 "open": @@ -273,6 +295,7 @@ private JsonObject open(final JsonObject request) { 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; @@ -301,8 +324,9 @@ private JsonObject open(final JsonObject request) { final boolean checkDeadlock = deadlock && tool.getModelConfig().getCheckDeadlock(); this.checkDeadlock = checkDeadlock; configText = readConfig(); - store = new GraphStore(metadir); - checker = new ModelChecker(tool, metadir, store, checkDeadlock, null, + 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; @@ -322,6 +346,7 @@ private JsonObject open(final JsonObject request) { 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; @@ -542,7 +567,11 @@ private JsonObject check(final JsonObject request) throws InterruptedException { TLCGlobals.continuationTraceLimit = request.has("traces_per_property") ? request.get("traces_per_property").getAsInt() : 1; - TLCGlobals.resetContinuationTraces(); + 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 (checkerThread == null) { @@ -850,6 +879,9 @@ private JsonObject storeInfo() { // ─── the store's queries ──────────────────────────────────────────── private JsonObject notOpen() { + 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"); } @@ -874,8 +906,15 @@ private String actionName(final long id) { * 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, the view, the symmetry set or the - * config, or an old exploration that did not finish, leaves nothing to - * carry: the reply asks for a restart and a full run. + * config, 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. * *

    * A paused checker is left parked, not stopped: stopping ends its run as @@ -898,16 +937,21 @@ private JsonObject refresh(final JsonObject request) throws Exception { // Decided before parsing, so these paths leave TLC's statics alone. final String currentConfig = readConfig(); String before = null; - if (currentConfig == null || !currentConfig.equals(configText)) { + 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 ? !refreshComplete : !explorationComplete()) { + } 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); } - final Tool oldTool = tool; - final GraphStore oldStore = store; + if (baseStore == null) { + // The first refresh, from a run that explored the whole graph. + baseTool = tool; + baseStore = store; + } final Tool newTool; try { newTool = new FastTool(mainFile, configName, new SimpleFilenameToStream(specDir), Tool.Mode.MC, @@ -915,30 +959,23 @@ private JsonObject refresh(final JsonObject request) throws Exception { } catch (final Throwable t) { final JsonObject reply = error(null, "parse_failed", t.toString()); reply.add("messages", recorder.drainMessages()); - // The old tool and store stay in place. - rebindStatics(oldTool); + // The current tool and store stay in place. + rebindStatics(tool); return reply; } - final Incremental.ActionDiff diff = Incremental.diff(oldTool, newTool); + 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) { - rebindStatics(oldTool); + rebindStatics(tool); return fullRerun(reply, diff.fullRerunReason, started); } reply.addProperty("mode", "incremental"); + 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); - final Incremental.Result r = Incremental.replay(newTool, oldStore, newStore, diff, budgetMs, cont); - 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, an error or a first violation leaves states whose - // successors were never generated. - refreshComplete = !r.budgetExhausted && r.error == null && (cont || r.violations.isEmpty()); + final Incremental.Result r = Incremental.replay(newTool, baseStore, newStore, diff, budgetMs, cont); reply.addProperty("survivors", r.survivors); reply.addProperty("dropped", r.dropped); reply.addProperty("reexpanded", r.reexpanded); @@ -946,10 +983,40 @@ private JsonObject refresh(final JsonObject request) throws Exception { reply.addProperty("edges_copied", r.edgesCopied); reply.addProperty("edges_generated", r.edgesGenerated); reply.addProperty("budget_exhausted", r.budgetExhausted); - reply.addProperty("finished", !r.budgetExhausted && r.error == null); if (r.error != null) { + // The edited spec does not evaluate; keep serving what was there. + newStore.dispose(); + rebindStatics(tool); + 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; + 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(); @@ -959,11 +1026,10 @@ private JsonObject refresh(final JsonObject request) throws Exception { violations.add(o); } // Per-invariant verdicts: exact over the refreshed store when the - // replay finished (one evaluation per state and invariant), else - // not_evaluated. + // replay explored the whole graph (one evaluation per state and + // invariant), else not_evaluated. final JsonArray invs = new JsonArray(); - final boolean finished = !r.budgetExhausted && r.error == null; - if (finished && (cont || r.violations.isEmpty())) { + if (complete) { final JsonArray exact = new JsonArray(); for (final Incremental.Sweep sw : Incremental.sweep(tool, store)) { final JsonObject v = new JsonObject(); @@ -1011,12 +1077,42 @@ private JsonObject refresh(final JsonObject request) throws Exception { reply.add("violations", violations); } reply.add("invariants", invs); + 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: 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; + } + + /** 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 @@ -1348,11 +1444,18 @@ private JsonObject guardProfile() { reply.addProperty("unsatisfied", store.unsatisfied()); reply.add("blocked", rows); reply.addProperty("note", - "attribution is to the first guard conjunct that evaluated false in TLC's evaluation order, on the all-assigned path only"); + "count is 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"); + 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; } - private void shutdown() { + void shutdown() { if (simulator != null && simulatorThread != null && simulatorThread.isAlive()) { simulator.stop(); try { 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 bf8a92761e..199992857f 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/impl/Tool.java @@ -1092,7 +1092,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 @@ -1180,7 +1193,7 @@ private final TLCState getNextStatesApplUsrDefOp(final Action action, final OpAp } } // Basis: a user-defined guard evaluated false; its bindings are not at hand here. - return this.processUnsatisfied(s0, action, s1, pred, Context.Empty, nss, cm); + 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, @@ -1358,7 +1371,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 { @@ -1372,7 +1385,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); @@ -1383,7 +1396,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 { @@ -1415,7 +1428,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); @@ -1427,7 +1440,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 { @@ -1463,7 +1476,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); @@ -1549,7 +1562,7 @@ public Object addElement(final TLCState t, final Action a, final TLCState u) { } else { // Basis: a guard conjunct evaluated false on the general path too, // not only when every primed variable was already assigned. - return this.processUnsatisfied(s0, action, s1, pred, c, nss, cm); + 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 faa3278d94..155d81dcb9 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java @@ -69,11 +69,16 @@ default boolean isSet(int v, int control) { * 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. - * The default keeps the older signature's behaviour and drops the context. + * {@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) { - writeState(state, successor, IsNotInModel, action, pred); + if (successor != null && successor.allAssigned()) { + writeState(state, successor, IsNotInModel, action, pred); + } } void writeState(TLCState state, TLCState successor, short stateFlags, Visualization visualization); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java index ffaa36e9d5..e88e01a801 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java @@ -1,218 +1,228 @@ -// Copyright (c) 2003 Microsoft Corporation. All rights reserved. - -package tlc2.value; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.util.Arrays; -import java.util.zip.GZIPOutputStream; - -import tlc2.TLCGlobals; -import util.BufferedDataOutputStream; - -public final class ValueOutputStream implements IValueOutputStream { - - private final BufferedDataOutputStream dos; - private final HandleTable handles; - - public ValueOutputStream(File file) throws IOException { - this(file, TLCGlobals.useGZIP); - } - - public ValueOutputStream(File file, final boolean compress) throws IOException { - this(new FileOutputStream(file), compress); - } - - public ValueOutputStream(final OutputStream out, final boolean compress) throws IOException { - if (compress) { - OutputStream os = new GZIPOutputStream(out); - this.dos = new BufferedDataOutputStream(os); - } - else { - this.dos = new BufferedDataOutputStream(out); - } - this.handles = new HandleTable(); - } - - public ValueOutputStream(String fname) throws IOException { - this(fname, TLCGlobals.useGZIP); - } - - public ValueOutputStream(String fname, boolean zip) throws IOException { - if (zip) { - OutputStream os = new GZIPOutputStream(new FileOutputStream(fname)); - this.dos = new BufferedDataOutputStream(os); - } - else { - this.dos = new BufferedDataOutputStream(fname); - } - this.handles = new HandleTable(); - } - - @Override - public final void writeShort(short x) throws IOException { - this.dos.writeShort(x); - } - - @Override - public final void writeInt(int x) throws IOException { - this.dos.writeInt(x); - } - - @Override - public final void writeLong(long x) throws IOException { - this.dos.writeLong(x); - } - - @Override - public final void close() throws IOException { - this.dos.close(); - } - - /* Precondition: x is a non-negative short. */ - @Override - public final void writeShortNat(short x) throws IOException { - if (x > 0x7f) { - this.dos.writeShort((short) -x); - } - else { - this.dos.writeByte((byte)x); - } - } - - /* Precondition: x is a non-negative int. */ - @Override - public final void writeNat(int x) throws IOException { - if (x > 0x7fff) { - this.dos.writeInt(-x); - } - else { - this.dos.writeShort((short)x); - } - } - - /* Precondition: x is a non-negative long. */ - @Override - public final void writeLongNat(long x) throws IOException { - if (x <= 0x7fffffff) { - this.dos.writeInt((int)x); - } - else { - this.dos.writeLong(-x); - } - } - - @Override - public final void writeByte(final byte b) throws IOException { - this.dos.writeByte(b); - } - - @Override - public final void writeBoolean(final boolean b) throws IOException { - this.dos.writeBoolean(b); - } - - @Override - public final BufferedDataOutputStream getOutputStream() { - return dos; - } - - /** - * Check if another TLCState - which is currently also being serialized to the - * same storage (i.e. disk file) - has/contains an identical Value. If yes, do - * not serialize the Value instance again but make this TLCState point to the - * Value instance previously serialized for the other TLCState. In other words, - * this is a custom-tailored compression/de-duplication mechanism for Value - * instances. - *

    - * This approach only works because both TLCStates are serialized to the same - * storage and thus de-serialized as part of the same operation (same - * Value*Stream instance). - *

    - * The purpose of this approach appears to be: - *

      - *
    • Reduce serialization efforts and storage size
    • - *
    • Reduce the number of Value instances created during de-serialization
    • - *
    • Allow identity comparison on Value instances (AFAICT not used by Value - * explicitly, just UniqueString) to speed up check. Value#equals internally - * likely uses identity comparison as first check.
    • - *
    - *

    - * A disadvantage is the cost of maintaining the internal HandleTable which can - * grow to thousands of elements during serialization/de-serialization (in - * ValueInputStream). Since serialization suspends the DiskStateQueue and thus - * blocks tlc2.tool.Workers from exploring the state space, this might has - * adverse effects. - */ - @Override - public final int put(final Object obj) { - return this.handles.put(obj); - } - - private static class HandleTable { - private int[] spine; - private int[] next; - private Object[] values; - private int size; - private int threshold; - - HandleTable() { - this.spine = new int[17]; - Arrays.fill(spine, -1); - this.next = new int[16]; - this.values = new Object[16]; - this.size = 0; - this.threshold = (int)(this.spine.length * 0.75); - } - -// SZ Jul 13, 2009: not used -// final int size() { return this.size; } - - final int put(Object val) { - int index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; - // lookup: - for (int i = spine[index]; i >= 0; i = next[i]) { - if (values[i] == val) { return i; } - } - // grow if needed: - if (this.size >= this.next.length) { - this.growEntries(); - } - if (this.size >= this.threshold) { - this.growSpine(); - index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; - } - // add val to the table: - this.values[this.size] = val; - this.next[this.size] = this.spine[index]; - this.spine[index] = this.size; - this.size++; - return -1; - } - - private final void growEntries() { - int newLength = this.next.length * 2; - int[] newNext = new int[newLength]; - System.arraycopy(this.next, 0, newNext, 0, this.size); - this.next = newNext; - - Object[] newValues = new Object[newLength]; - System.arraycopy(this.values, 0, newValues, 0, this.size); - this.values = newValues; - } - - private final void growSpine() { - int len = (this.spine.length * 2) + 1; - this.spine = new int[len]; - this.threshold = (int)(len * 0.75); - Arrays.fill(this.spine, -1); - for (int i = 0; i < this.size; i++) { - int index = (System.identityHashCode(this.values[i]) & 0x7FFFFFFF) % len; - this.next[i] = this.spine[index]; - this.spine[index] = i; - } - } - } -} +// Copyright (c) 2003 Microsoft Corporation. All rights reserved. + +package tlc2.value; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.zip.GZIPOutputStream; + +import tlc2.TLCGlobals; +import util.BufferedDataOutputStream; + +public final class ValueOutputStream implements IValueOutputStream { + + private final BufferedDataOutputStream dos; + private HandleTable handles; + + public ValueOutputStream(File file) throws IOException { + this(file, TLCGlobals.useGZIP); + } + + public ValueOutputStream(File file, final boolean compress) throws IOException { + this(new FileOutputStream(file), compress); + } + + public ValueOutputStream(final OutputStream out, final boolean compress) throws IOException { + if (compress) { + OutputStream os = new GZIPOutputStream(out); + this.dos = new BufferedDataOutputStream(os); + } + else { + this.dos = new BufferedDataOutputStream(out); + } + this.handles = new HandleTable(); + } + + public ValueOutputStream(String fname) throws IOException { + this(fname, TLCGlobals.useGZIP); + } + + public ValueOutputStream(String fname, boolean zip) throws IOException { + if (zip) { + OutputStream os = new GZIPOutputStream(new FileOutputStream(fname)); + this.dos = new BufferedDataOutputStream(os); + } + else { + this.dos = new BufferedDataOutputStream(fname); + } + this.handles = new HandleTable(); + } + + @Override + public final void writeShort(short x) throws IOException { + this.dos.writeShort(x); + } + + @Override + public final void writeInt(int x) throws IOException { + this.dos.writeInt(x); + } + + @Override + public final void writeLong(long x) throws IOException { + this.dos.writeLong(x); + } + + @Override + 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 + public final void writeShortNat(short x) throws IOException { + if (x > 0x7f) { + this.dos.writeShort((short) -x); + } + else { + this.dos.writeByte((byte)x); + } + } + + /* Precondition: x is a non-negative int. */ + @Override + public final void writeNat(int x) throws IOException { + if (x > 0x7fff) { + this.dos.writeInt(-x); + } + else { + this.dos.writeShort((short)x); + } + } + + /* Precondition: x is a non-negative long. */ + @Override + public final void writeLongNat(long x) throws IOException { + if (x <= 0x7fffffff) { + this.dos.writeInt((int)x); + } + else { + this.dos.writeLong(-x); + } + } + + @Override + public final void writeByte(final byte b) throws IOException { + this.dos.writeByte(b); + } + + @Override + public final void writeBoolean(final boolean b) throws IOException { + this.dos.writeBoolean(b); + } + + @Override + public final BufferedDataOutputStream getOutputStream() { + return dos; + } + + /** + * Check if another TLCState - which is currently also being serialized to the + * same storage (i.e. disk file) - has/contains an identical Value. If yes, do + * not serialize the Value instance again but make this TLCState point to the + * Value instance previously serialized for the other TLCState. In other words, + * this is a custom-tailored compression/de-duplication mechanism for Value + * instances. + *

    + * This approach only works because both TLCStates are serialized to the same + * storage and thus de-serialized as part of the same operation (same + * Value*Stream instance). + *

    + * The purpose of this approach appears to be: + *

      + *
    • Reduce serialization efforts and storage size
    • + *
    • Reduce the number of Value instances created during de-serialization
    • + *
    • Allow identity comparison on Value instances (AFAICT not used by Value + * explicitly, just UniqueString) to speed up check. Value#equals internally + * likely uses identity comparison as first check.
    • + *
    + *

    + * A disadvantage is the cost of maintaining the internal HandleTable which can + * grow to thousands of elements during serialization/de-serialization (in + * ValueInputStream). Since serialization suspends the DiskStateQueue and thus + * blocks tlc2.tool.Workers from exploring the state space, this might has + * adverse effects. + */ + @Override + public final int put(final Object obj) { + return this.handles.put(obj); + } + + private static class HandleTable { + private int[] spine; + private int[] next; + private Object[] values; + private int size; + private int threshold; + + HandleTable() { + this.spine = new int[17]; + Arrays.fill(spine, -1); + this.next = new int[16]; + this.values = new Object[16]; + this.size = 0; + this.threshold = (int)(this.spine.length * 0.75); + } + +// SZ Jul 13, 2009: not used +// final int size() { return this.size; } + + final int put(Object val) { + int index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; + // lookup: + for (int i = spine[index]; i >= 0; i = next[i]) { + if (values[i] == val) { return i; } + } + // grow if needed: + if (this.size >= this.next.length) { + this.growEntries(); + } + if (this.size >= this.threshold) { + this.growSpine(); + index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; + } + // add val to the table: + this.values[this.size] = val; + this.next[this.size] = this.spine[index]; + this.spine[index] = this.size; + this.size++; + return -1; + } + + private final void growEntries() { + int newLength = this.next.length * 2; + int[] newNext = new int[newLength]; + System.arraycopy(this.next, 0, newNext, 0, this.size); + this.next = newNext; + + Object[] newValues = new Object[newLength]; + System.arraycopy(this.values, 0, newValues, 0, this.size); + this.values = newValues; + } + + private final void growSpine() { + int len = (this.spine.length * 2) + 1; + this.spine = new int[len]; + this.threshold = (int)(len * 0.75); + Arrays.fill(this.spine, -1); + for (int i = 0; i < this.size; i++) { + int index = (System.identityHashCode(this.values[i]) & 0x7FFFFFFF) % len; + this.next[i] = this.spine[index]; + this.spine[index] = i; + } + } + } +} 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/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/GraphStoreGuardTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java new file mode 100644 index 0000000000..117bb7e8fc --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * 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()) { + 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()); + + // 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/ResidentCoverageTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java new file mode 100644 index 0000000000..efcf4398ae --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.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 java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +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. + */ +public class ResidentCoverageTest { + + @Test + public void testUnevaluated() throws Exception { + final ResidentHarness h = new ResidentHarness(); + h.write("V.cfg", "INIT Init\nNEXT Next\n"); + h.write("V.tla", "---- MODULE V ----\n" // + + "EXTENDS Naturals\n" // + + "VARIABLES x\n" // + + "Init == x = 0\n" // + + "A == x < 3 /\\ x' = x + 1\n" // + + "D == x > 10 /\\ x * 2 > 25 /\\ x' = 0\n" // + + "Next == A \\/ D\n" // + + "====\n"); + 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()); + assertEquals(1, actions.get("D").getAsJsonArray("unevaluated").size()); + assertEquals("x*2>25", actions.get("D").getAsJsonArray("unevaluated").get(0).getAsJsonObject() + .get("text").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..5173be7253 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java @@ -0,0 +1,75 @@ +/******************************************************************************* + * 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)); + } + + Path spec(final String module) { + return dir.resolve(module + ".tla"); + } + + /** 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/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/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/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()); + } +} From 0d022e49fc8ac53c06d63a4645748707e0f2dc55 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Tue, 22 Sep 2026 17:55:14 -0400 Subject: [PATCH 10/33] ValueOutputStream: restore CRLF line endings The previous commit's edit rewrote the file with LF endings; only the reset() method and the non-final handle table are meant to change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/value/ValueOutputStream.java | 456 +++++++++--------- 1 file changed, 228 insertions(+), 228 deletions(-) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java index e88e01a801..afeccb75b5 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/value/ValueOutputStream.java @@ -1,228 +1,228 @@ -// Copyright (c) 2003 Microsoft Corporation. All rights reserved. - -package tlc2.value; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.util.Arrays; -import java.util.zip.GZIPOutputStream; - -import tlc2.TLCGlobals; -import util.BufferedDataOutputStream; - -public final class ValueOutputStream implements IValueOutputStream { - - private final BufferedDataOutputStream dos; - private HandleTable handles; - - public ValueOutputStream(File file) throws IOException { - this(file, TLCGlobals.useGZIP); - } - - public ValueOutputStream(File file, final boolean compress) throws IOException { - this(new FileOutputStream(file), compress); - } - - public ValueOutputStream(final OutputStream out, final boolean compress) throws IOException { - if (compress) { - OutputStream os = new GZIPOutputStream(out); - this.dos = new BufferedDataOutputStream(os); - } - else { - this.dos = new BufferedDataOutputStream(out); - } - this.handles = new HandleTable(); - } - - public ValueOutputStream(String fname) throws IOException { - this(fname, TLCGlobals.useGZIP); - } - - public ValueOutputStream(String fname, boolean zip) throws IOException { - if (zip) { - OutputStream os = new GZIPOutputStream(new FileOutputStream(fname)); - this.dos = new BufferedDataOutputStream(os); - } - else { - this.dos = new BufferedDataOutputStream(fname); - } - this.handles = new HandleTable(); - } - - @Override - public final void writeShort(short x) throws IOException { - this.dos.writeShort(x); - } - - @Override - public final void writeInt(int x) throws IOException { - this.dos.writeInt(x); - } - - @Override - public final void writeLong(long x) throws IOException { - this.dos.writeLong(x); - } - - @Override - 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 - public final void writeShortNat(short x) throws IOException { - if (x > 0x7f) { - this.dos.writeShort((short) -x); - } - else { - this.dos.writeByte((byte)x); - } - } - - /* Precondition: x is a non-negative int. */ - @Override - public final void writeNat(int x) throws IOException { - if (x > 0x7fff) { - this.dos.writeInt(-x); - } - else { - this.dos.writeShort((short)x); - } - } - - /* Precondition: x is a non-negative long. */ - @Override - public final void writeLongNat(long x) throws IOException { - if (x <= 0x7fffffff) { - this.dos.writeInt((int)x); - } - else { - this.dos.writeLong(-x); - } - } - - @Override - public final void writeByte(final byte b) throws IOException { - this.dos.writeByte(b); - } - - @Override - public final void writeBoolean(final boolean b) throws IOException { - this.dos.writeBoolean(b); - } - - @Override - public final BufferedDataOutputStream getOutputStream() { - return dos; - } - - /** - * Check if another TLCState - which is currently also being serialized to the - * same storage (i.e. disk file) - has/contains an identical Value. If yes, do - * not serialize the Value instance again but make this TLCState point to the - * Value instance previously serialized for the other TLCState. In other words, - * this is a custom-tailored compression/de-duplication mechanism for Value - * instances. - *

    - * This approach only works because both TLCStates are serialized to the same - * storage and thus de-serialized as part of the same operation (same - * Value*Stream instance). - *

    - * The purpose of this approach appears to be: - *

      - *
    • Reduce serialization efforts and storage size
    • - *
    • Reduce the number of Value instances created during de-serialization
    • - *
    • Allow identity comparison on Value instances (AFAICT not used by Value - * explicitly, just UniqueString) to speed up check. Value#equals internally - * likely uses identity comparison as first check.
    • - *
    - *

    - * A disadvantage is the cost of maintaining the internal HandleTable which can - * grow to thousands of elements during serialization/de-serialization (in - * ValueInputStream). Since serialization suspends the DiskStateQueue and thus - * blocks tlc2.tool.Workers from exploring the state space, this might has - * adverse effects. - */ - @Override - public final int put(final Object obj) { - return this.handles.put(obj); - } - - private static class HandleTable { - private int[] spine; - private int[] next; - private Object[] values; - private int size; - private int threshold; - - HandleTable() { - this.spine = new int[17]; - Arrays.fill(spine, -1); - this.next = new int[16]; - this.values = new Object[16]; - this.size = 0; - this.threshold = (int)(this.spine.length * 0.75); - } - -// SZ Jul 13, 2009: not used -// final int size() { return this.size; } - - final int put(Object val) { - int index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; - // lookup: - for (int i = spine[index]; i >= 0; i = next[i]) { - if (values[i] == val) { return i; } - } - // grow if needed: - if (this.size >= this.next.length) { - this.growEntries(); - } - if (this.size >= this.threshold) { - this.growSpine(); - index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; - } - // add val to the table: - this.values[this.size] = val; - this.next[this.size] = this.spine[index]; - this.spine[index] = this.size; - this.size++; - return -1; - } - - private final void growEntries() { - int newLength = this.next.length * 2; - int[] newNext = new int[newLength]; - System.arraycopy(this.next, 0, newNext, 0, this.size); - this.next = newNext; - - Object[] newValues = new Object[newLength]; - System.arraycopy(this.values, 0, newValues, 0, this.size); - this.values = newValues; - } - - private final void growSpine() { - int len = (this.spine.length * 2) + 1; - this.spine = new int[len]; - this.threshold = (int)(len * 0.75); - Arrays.fill(this.spine, -1); - for (int i = 0; i < this.size; i++) { - int index = (System.identityHashCode(this.values[i]) & 0x7FFFFFFF) % len; - this.next[i] = this.spine[index]; - this.spine[index] = i; - } - } - } -} +// Copyright (c) 2003 Microsoft Corporation. All rights reserved. + +package tlc2.value; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.zip.GZIPOutputStream; + +import tlc2.TLCGlobals; +import util.BufferedDataOutputStream; + +public final class ValueOutputStream implements IValueOutputStream { + + private final BufferedDataOutputStream dos; + private HandleTable handles; + + public ValueOutputStream(File file) throws IOException { + this(file, TLCGlobals.useGZIP); + } + + public ValueOutputStream(File file, final boolean compress) throws IOException { + this(new FileOutputStream(file), compress); + } + + public ValueOutputStream(final OutputStream out, final boolean compress) throws IOException { + if (compress) { + OutputStream os = new GZIPOutputStream(out); + this.dos = new BufferedDataOutputStream(os); + } + else { + this.dos = new BufferedDataOutputStream(out); + } + this.handles = new HandleTable(); + } + + public ValueOutputStream(String fname) throws IOException { + this(fname, TLCGlobals.useGZIP); + } + + public ValueOutputStream(String fname, boolean zip) throws IOException { + if (zip) { + OutputStream os = new GZIPOutputStream(new FileOutputStream(fname)); + this.dos = new BufferedDataOutputStream(os); + } + else { + this.dos = new BufferedDataOutputStream(fname); + } + this.handles = new HandleTable(); + } + + @Override + public final void writeShort(short x) throws IOException { + this.dos.writeShort(x); + } + + @Override + public final void writeInt(int x) throws IOException { + this.dos.writeInt(x); + } + + @Override + public final void writeLong(long x) throws IOException { + this.dos.writeLong(x); + } + + @Override + 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 + public final void writeShortNat(short x) throws IOException { + if (x > 0x7f) { + this.dos.writeShort((short) -x); + } + else { + this.dos.writeByte((byte)x); + } + } + + /* Precondition: x is a non-negative int. */ + @Override + public final void writeNat(int x) throws IOException { + if (x > 0x7fff) { + this.dos.writeInt(-x); + } + else { + this.dos.writeShort((short)x); + } + } + + /* Precondition: x is a non-negative long. */ + @Override + public final void writeLongNat(long x) throws IOException { + if (x <= 0x7fffffff) { + this.dos.writeInt((int)x); + } + else { + this.dos.writeLong(-x); + } + } + + @Override + public final void writeByte(final byte b) throws IOException { + this.dos.writeByte(b); + } + + @Override + public final void writeBoolean(final boolean b) throws IOException { + this.dos.writeBoolean(b); + } + + @Override + public final BufferedDataOutputStream getOutputStream() { + return dos; + } + + /** + * Check if another TLCState - which is currently also being serialized to the + * same storage (i.e. disk file) - has/contains an identical Value. If yes, do + * not serialize the Value instance again but make this TLCState point to the + * Value instance previously serialized for the other TLCState. In other words, + * this is a custom-tailored compression/de-duplication mechanism for Value + * instances. + *

    + * This approach only works because both TLCStates are serialized to the same + * storage and thus de-serialized as part of the same operation (same + * Value*Stream instance). + *

    + * The purpose of this approach appears to be: + *

      + *
    • Reduce serialization efforts and storage size
    • + *
    • Reduce the number of Value instances created during de-serialization
    • + *
    • Allow identity comparison on Value instances (AFAICT not used by Value + * explicitly, just UniqueString) to speed up check. Value#equals internally + * likely uses identity comparison as first check.
    • + *
    + *

    + * A disadvantage is the cost of maintaining the internal HandleTable which can + * grow to thousands of elements during serialization/de-serialization (in + * ValueInputStream). Since serialization suspends the DiskStateQueue and thus + * blocks tlc2.tool.Workers from exploring the state space, this might has + * adverse effects. + */ + @Override + public final int put(final Object obj) { + return this.handles.put(obj); + } + + private static class HandleTable { + private int[] spine; + private int[] next; + private Object[] values; + private int size; + private int threshold; + + HandleTable() { + this.spine = new int[17]; + Arrays.fill(spine, -1); + this.next = new int[16]; + this.values = new Object[16]; + this.size = 0; + this.threshold = (int)(this.spine.length * 0.75); + } + +// SZ Jul 13, 2009: not used +// final int size() { return this.size; } + + final int put(Object val) { + int index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; + // lookup: + for (int i = spine[index]; i >= 0; i = next[i]) { + if (values[i] == val) { return i; } + } + // grow if needed: + if (this.size >= this.next.length) { + this.growEntries(); + } + if (this.size >= this.threshold) { + this.growSpine(); + index = (System.identityHashCode(val) & 0x7FFFFFFF) % this.spine.length; + } + // add val to the table: + this.values[this.size] = val; + this.next[this.size] = this.spine[index]; + this.spine[index] = this.size; + this.size++; + return -1; + } + + private final void growEntries() { + int newLength = this.next.length * 2; + int[] newNext = new int[newLength]; + System.arraycopy(this.next, 0, newNext, 0, this.size); + this.next = newNext; + + Object[] newValues = new Object[newLength]; + System.arraycopy(this.values, 0, newValues, 0, this.size); + this.values = newValues; + } + + private final void growSpine() { + int len = (this.spine.length * 2) + 1; + this.spine = new int[len]; + this.threshold = (int)(len * 0.75); + Arrays.fill(this.spine, -1); + for (int i = 0; i < this.size; i++) { + int index = (System.identityHashCode(this.values[i]) & 0x7FFFFFFF) % len; + this.next[i] = this.spine[index]; + this.spine[index] = i; + } + } + } +} From 3628ee9c763da3579b357b17d43c94b2de37bed1 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Tue, 22 Sep 2026 18:25:30 -0400 Subject: [PATCH 11/33] Refresh review fixes: definition slots, parameter signatures, continuation - Defns: a second parse in one JVM gave names the edit added the slots an earlier parse had given other names (slots live on process-global UniqueStrings), so INIT/NEXT/INVARIANT could resolve to the wrong definition after a refresh. New names now take slots above every slot already handed out in the process. - Incremental: a reached definition's signature now includes its formal parameters, and every INSTANCE ... WITH substitution passed through is recorded (by content, so moving text is no edit). Swapping F(a, b) to F(b, a), or which parameter a WITH expression replaces, was invisible. - Resident: `continue` is applied only to a run that is about to explore, and completeness/registers read the value the run explored under, not the global. A check with continue after a run stopped at its first violation made the next refresh replay the partial graph as complete. - Tests: ResidentRefreshDefinitionsTest, ResidentRefreshSignatureTest, ResidentContinuationTest (each fails without its fix). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Incremental.java | 26 ++++- .../src/tlc2/basis/Resident.java | 30 ++++-- .../src/tlc2/tool/Defns.java | 11 ++- .../tlc2/basis/ResidentContinuationTest.java | 76 +++++++++++++++ .../basis/ResidentRefreshDefinitionsTest.java | 81 ++++++++++++++++ .../basis/ResidentRefreshSignatureTest.java | 94 +++++++++++++++++++ 6 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshDefinitionsTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSignatureTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index adaaae9730..97f264b7ab 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -36,11 +36,14 @@ 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; @@ -264,8 +267,10 @@ private static String signature(final Action a) { } /** - * The text of {@code node}, the name and text of every user definition it - * reaches (transitively, in name order) and the values {@code con} binds. + * 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. */ @@ -291,6 +296,15 @@ private static void reach(final SemanticNode node, final Map rea 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) { @@ -311,7 +325,13 @@ private static void reachDefinition(final SymbolNode op, final Map { try { @@ -640,7 +650,7 @@ private JsonObject check(final JsonObject request) throws InterruptedException { } reply.add("traces", all); reply.add("invariants", invariantVerdicts(finished)); - reply.addProperty("continuation", TLCGlobals.continuation); + reply.addProperty("continuation", runContinuation); reply.add("stats", stats()); reply.add("messages", recorder.drainMessages()); return reply; @@ -740,7 +750,7 @@ private boolean explorationComplete() { case EC.TLC_INVARIANT_VIOLATED_INITIAL: case EC.TLC_INVARIANT_VIOLATED_BEHAVIOR: case EC.TLC_ACTION_PROPERTY_VIOLATED_BEHAVIOR: - return TLCGlobals.continuation; + return runContinuation; default: return false; } @@ -788,16 +798,16 @@ private JsonObject registers() { final boolean queueEmpty = checker.getStateQueueSize() == 0; r.addProperty("finished", finished); r.addProperty("exhausted", finished && queueEmpty && checkerFailure == null - && (resultCode == EC.NO_ERROR || TLCGlobals.continuation)); + && (resultCode == EC.NO_ERROR || runContinuation)); r.addProperty("stopped_by", checkerFailure != null ? "error" : !finished ? (checkerThread == null ? "not_started" : "budget") : resultCode == EC.NO_ERROR ? "exhausted" - : TLCGlobals.continuation ? "exhausted_with_violations" : "violation"); + : runContinuation ? "exhausted_with_violations" : "violation"); if (finished) { r.addProperty("result_code", resultCode); } r.addProperty("workers", TLCGlobals.getNumWorkers()); - r.addProperty("continuation", TLCGlobals.continuation); + r.addProperty("continuation", runContinuation); r.addProperty("coverage", tlc2.tool.coverage.CoverageWalk.enabled()); // Out-degree across workers. final JsonObject outDegree = new JsonObject(); 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/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/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/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(); + } +} From 1f88aabb9a05061354ed4973d55cf67fbcdc7399 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Tue, 22 Sep 2026 23:16:24 -0400 Subject: [PATCH 12/33] Resident review fixes: pause deadlock, capped reports, multi-worker tests - ModelChecker.suspend waited for the workers to park while holding the checker's monitor, which a worker takes to report a violation (under continuation, or a first violation) or the end of the run: a budgeted check could hang for good. It now waits outside the monitor. - Under continuation, reports past the per-property trace cap were kept as a message and an empty trace each (59k messages in one reply for a 64k state run). The recorder now counts them; check replies carry them as untraced_reports. - Tests: a budgeted, continuing check on four workers over a mostly violating spec (finishes, counts every report, keeps few messages), and incremental refreshes with four workers against fresh-run counts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/TLCGlobals.java | 14 ++++ .../src/tlc2/basis/Recorder.java | 28 +++++++ .../src/tlc2/basis/Resident.java | 2 + .../src/tlc2/tool/ModelChecker.java | 7 +- .../basis/ResidentContinuationBudgetTest.java | 79 +++++++++++++++++++ .../basis/ResidentRefreshWorkersTest.java | 76 ++++++++++++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationBudgetTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshWorkersTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java b/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java index e1efdb29b8..4b3b175d28 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/TLCGlobals.java @@ -231,6 +231,20 @@ public static boolean continuationTraceAllowed(final String property) { 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(); } diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java index 2ce10bb883..3242460889 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -72,12 +72,32 @@ public static final class Trace { 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<>(); 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(); @@ -202,6 +222,7 @@ public synchronized void reset() { finishedTrace = null; traces.clear(); violationCounts.clear(); + untraced.clear(); finalStats = null; outcome = EC.NO_ERROR; outcomeProperty = null; @@ -232,6 +253,13 @@ public synchronized Map violationCounts() { return new java.util.LinkedHashMap<>(violationCounts); } + /** 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; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index b1d9a827d8..7a99de62dd 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -649,6 +649,8 @@ private JsonObject check(final JsonObject request) throws InterruptedException { } } 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()); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index 5e0e7ab8ce..2fe562affc 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java @@ -1093,7 +1093,12 @@ private void periodicResume() { public void suspend() { synchronized (this) { this.held = true; - this.theStateQueue.suspendAll(); + } + // 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.theStateQueue.suspendAll(); + synchronized (this) { this.notifyAll(); } } 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/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(); + } +} From ba9f9acb0621fbbb16d078e770a28c62db029b23 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 11:06:23 -0400 Subject: [PATCH 13/33] Resident review fixes: exact action pairing, stale coverage, lock-free guard tallies, constraint rows - Incremental.diff pairs exact (name, signature) matches over every new action before pairing leftovers by name. The disjuncts of an unnamed Next share its name, so inserting one in front used to shift every pair: all actions reported changed and no edge carried. - coverage reads the tool the checker ran. After a refresh it used to walk the refreshed tool, which has no cost model, and answer an empty tree as enabled; it now serves the run's coverage marked stale. - GraphStore tallies false guards in a ConcurrentHashMap keyed by (action, conjunct node identity) with LongAdder counts, building text and location only on first insert. Four workers with the store went from 32.9s to 17.3s on a 923k-state spec (18.7M false guards); they had been slower than one worker. - Successors a state or action constraint excluded are tallied as kind "constraint" rows and counted as `excluded`, apart from the guards. Tests: ResidentRefreshPairingTest, a coverage-after-refresh case in ResidentCoverageTest, GraphStoreConstraintTest over a CONSTRAINT model. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 138 ++++++++++++++---- .../src/tlc2/basis/Incremental.java | 30 ++-- .../src/tlc2/basis/Resident.java | 30 +++- .../test-model/basis/GuardsConstraint.cfg | 3 + .../test-model/basis/GuardsConstraint.tla | 13 ++ .../tlc2/basis/GraphStoreConstraintTest.java | 108 ++++++++++++++ .../test/tlc2/basis/GraphStoreGuardTest.java | 2 + .../test/tlc2/basis/ResidentCoverageTest.java | 30 +++- .../basis/ResidentRefreshPairingTest.java | 78 ++++++++++ 9 files changed, 380 insertions(+), 52 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.cfg create mode 100644 tlatools/org.lamport.tlatools/test-model/basis/GuardsConstraint.tla create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreConstraintTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshPairingTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index 8bfd965a57..cb74a6c049 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -31,6 +31,8 @@ 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; @@ -63,7 +65,11 @@ * the way {@code DiskStateQueue} serialises states; the index (fingerprint * to offset, level and first predecessor) and the predecessor lists stay in * memory. Blocked guards are tallied, not logged: per (action, conjunct) - * a count, one example state and one example binding. + * 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 @@ -92,22 +98,66 @@ private static final class Entry { } } - /** One guard conjunct's tally of the transitions it disabled. */ + /** 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 location, String text) { + 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; @@ -116,10 +166,15 @@ public static final class Blocked { /** Fingerprint to (predecessor fp, action id, flags) triples. */ private final Map predecessors = new HashMap<>(); private final Map actions = new HashMap<>(); - private final Map blocked = 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<>(); private long edges; - private long unsatisfied; + 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); @@ -194,10 +249,16 @@ public void writeState(final TLCState state, final TLCState successor, final sho } } + /** + * 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 synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags, + public void writeState(final TLCState state, final TLCState successor, final short stateFlags, final Action action, final SemanticNode pred) { - writeUnsatisfied(state, action, successor, pred, null); + excluded.increment(); + tally(state, action, pred, null, true); } /** @@ -205,28 +266,39 @@ public synchronized void writeState(final TLCState state, final TLCState success * enabled at {@code state} because of {@code pred}, under the quantifier * bindings in {@code c}. Tallied per (action, conjunct). */ - public synchronized void writeUnsatisfied(final TLCState state, final Action action, final TLCState successor, + @Override + public void writeUnsatisfied(final TLCState state, final Action action, final TLCState successor, final SemanticNode pred, final Context c) { - unsatisfied++; + 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 String location = pred == null ? "?" : String.valueOf(pred.getLocation()); - final String key = actionId + "|" + location; + final TallyKey key = new TallyKey(actionId, pred, constraint); Blocked b = blocked.get(key); if (b == null) { - b = new Blocked(actionId, action == null ? "?" : action.getNameOfDefault(), location, text(pred)); - try { - b.exampleFp = state.fingerPrint(); - } catch (final RuntimeException e) { - b.exampleFp = 0; - } - if (c != null) { - final Map bindings = new HashMap<>(); - c.toMap().forEach((k, v) -> bindings.put(k.toString(), v.toString())); - b.exampleBindings = bindings; - } - blocked.put(key, b); + // 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.count++; + b.tally.increment(); } /** The source text of a semantic node, or its location when the parse tree is gone. */ @@ -489,8 +561,12 @@ public synchronized Action action(final int id) { return actions.get(id); } - public synchronized List blocked() { - final List out = new ArrayList<>(blocked.values()); + /** 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; } @@ -523,8 +599,14 @@ public synchronized long edges() { return edges; } - public synchronized long unsatisfied() { - return unsatisfied; + /** 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() { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index 97f264b7ab..96d372ac18 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -181,23 +181,31 @@ public static ActionDiff diff(final Tool oldTool, final Tool newTool) { 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<>(); - for (final Action n : newTool.getActions()) { - final String name = n.getNameOfDefault(); - final String sig = signature(n); - final List candidates = oldByKey.getOrDefault(name, List.of()); - Action match = null; - for (final Action o : candidates) { + 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)) { - match = o; + matchedOld.add(o.getId()); + exact[i] = o; break; } } - if (match != null) { - matchedOld.add(match.getId()); - d.carried.put(match.getId(), n); + } + // 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(candidates, matchedOld)) { + } else if (pairUnmatched(oldByKey.getOrDefault(name, List.of()), matchedOld)) { // Same name, different signature. d.changed.add(name); d.reexpand.add(n); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 7a99de62dd..1959ff5240 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -80,7 +80,8 @@ * {@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).

  • + * 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 @@ -99,6 +100,12 @@ 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. */ @@ -241,11 +248,16 @@ private JsonObject dispatch(final String command, final JsonObject request) thro case "simulate": return simulate(request); case "coverage": { - if (tool == null) { + if (runTool == null) { return notOpen(); } final JsonObject reply = ok(); - reply.add("coverage", tlc2.tool.coverage.CoverageWalk.walk(tool)); + 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": { @@ -328,6 +340,7 @@ private JsonObject open(final JsonObject request) { metadir = FileUtil.makeMetaDir(new Date(openedAt), specDir, null); 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(); @@ -339,6 +352,7 @@ private JsonObject open(final JsonObject request) { 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()); @@ -386,6 +400,7 @@ private JsonObject openSimulate(final JsonObject request, final File specFile, f metadir = FileUtil.makeMetaDir(new Date(openedAt), specDir, null); 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, @@ -393,6 +408,7 @@ private JsonObject openSimulate(final JsonObject request, final File specFile, f 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()); @@ -884,6 +900,7 @@ private JsonObject storeInfo() { o.addProperty("initial", store.initialStates()); o.addProperty("edges", store.edges()); o.addProperty("unsatisfied", store.unsatisfied()); + o.addProperty("excluded", store.excluded()); o.addProperty("bytes", store.bytes()); return o; } @@ -1442,6 +1459,7 @@ private JsonObject guardProfile() { 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); @@ -1454,11 +1472,13 @@ private JsonObject guardProfile() { rows.add(o); } reply.addProperty("unsatisfied", store.unsatisfied()); + reply.addProperty("excluded", store.excluded()); reply.add("blocked", rows); reply.addProperty("note", - "count is how often the subexpression evaluated false while TLC generated the action's successors, " + "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"); + + "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", 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/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 index 117bb7e8fc..348ea27d73 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/GraphStoreGuardTest.java @@ -93,6 +93,7 @@ public void testSpec() { 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); @@ -102,6 +103,7 @@ public void testSpec() { 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. diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java index efcf4398ae..0b10ec7a27 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java @@ -36,22 +36,27 @@ /** * 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. + * 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 { - @Test - public void testUnevaluated() throws Exception { - final ResidentHarness h = new ResidentHarness(); - h.write("V.cfg", "INIT Init\nNEXT Next\n"); - h.write("V.tla", "---- MODULE V ----\n" // + 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 > 10 /\\ x * 2 > 25 /\\ x' = 0\n" // + + "D == x > " + dLimit + " /\\ x * 2 > 25 /\\ x' = 0\n" // + "Next == A \\/ D\n" // - + "====\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"); @@ -66,6 +71,15 @@ public void testUnevaluated() throws Exception { assertEquals(1, actions.get("D").getAsJsonArray("unevaluated").size()); assertEquals("x*2>25", actions.get("D").getAsJsonArray("unevaluated").get(0).getAsJsonObject() .get("text").getAsString()); + assertTrue(h.ok("{\"command\":\"coverage\"}").get("stale") == null); + + // 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/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(); + } +} From 7e27e5f141e3cc01ac18eae20cc85100c7dcd5c2 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 11:22:30 -0400 Subject: [PATCH 14/33] Resident review fixes: unadopted replay on invariant errors, one suspender at a time Refresh: a changed invariant that throws on a surviving state (or a failure while copying carried edges) now ends the replay with r.error, like a failing action does, so the reply is adopted:false with the error and the new store is released and TLC's statics rebound to the old spec. Resident.refresh also catches anything else the replay throws and treats it the same way, instead of answering `internal` and leaking the store. ModelChecker: the resident's suspend() and the periodic work (liveness, checkpoints) both wait in StateQueue.suspendAll(), which wakes a single waiter when the last worker parks. Two waiting at once left one asleep for good: a hung protocol, or a run whose workers never resume. Both now go through one lock (not the checker's monitor, which workers take on their way to the barrier). Tests: ResidentRefreshInvariantErrorTest; ResidentConcurrentSuspendTest, which hangs without the lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Incremental.java | 44 +++++--- .../src/tlc2/basis/Resident.java | 10 +- .../src/tlc2/tool/ModelChecker.java | 18 ++- .../basis/ResidentConcurrentSuspendTest.java | 103 ++++++++++++++++++ .../ResidentRefreshInvariantErrorTest.java | 100 +++++++++++++++++ 5 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentConcurrentSuspendTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshInvariantErrorTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index 96d372ac18..6f2fb91c14 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -467,24 +467,42 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final if (survivor) { r.survivors++; // Carried edges: copy successors and their content. - 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) : rebind(newTool, oldStore.read(to)); - 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); + 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) + : rebind(newTool, oldStore.read(to)); + 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. for (int k = 0; k < invariants.length; k++) { final String name = k < invNames.length ? invNames[k] : invariants[k].getNameOfDefault(); - if (changedInv.contains(name) && !newTool.isValid(invariants[k], state)) { + if (!changedInv.contains(name)) { + continue; + } + boolean holds; + try { + holds = newTool.isValid(invariants[k], state); + } catch (final Throwable t) { + r.error = name + ": " + t; + stop = true; + break; + } + if (!holds) { r.violations.add(new Violation(name, fp, newStore.level(fp))); if (!continueOnViolation) { stop = true; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 1959ff5240..05523000f6 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -1004,7 +1004,15 @@ private JsonObject refresh(final JsonObject request) throws Exception { 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); - final Incremental.Result r = Incremental.replay(newTool, baseStore, newStore, diff, budgetMs, cont); + 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); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index 2fe562affc..2712a6f3ec 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java @@ -1067,12 +1067,26 @@ public void stop() { 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.theStateQueue.suspendAll(); + final boolean suspended = this.suspendQueue(); if (!suspended) { synchronized (this) { this.periodicParked = false; @@ -1097,7 +1111,7 @@ public void suspend() { // 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.theStateQueue.suspendAll(); + this.suspendQueue(); synchronized (this) { this.notifyAll(); } 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/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(); + } +} From 4b783bae21426d7d3c8cbbab7f1068f4969d96fa Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 15:20:26 -0400 Subject: [PATCH 15/33] Resident review fixes: no hang on a budget during init, full rerun on TLCGet A check whose budget ran out while the initial states were generated, in a run that then ended there (an initial state violating an invariant, init failing to evaluate), waited forever in StateQueue.suspendAll: no worker ever started and nothing finished the queue. The checker thread now finishes the queue when modelCheck returns, which wakes that wait. A refresh evaluated constraints without the predecessor TLC sets, so the usual TLCGet("level") depth bound failed every replay with a ClassCastException. Setting it would not be enough: TLCGet depends on the path to a state, which an edit can shorten, so copied edges are unsound. Incremental.diff now asks for a full rerun when an action, the initial predicate, a constraint or an invariant reaches TLCGet. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Incremental.java | 58 +++++++++++++++- .../src/tlc2/basis/Resident.java | 15 ++++- .../tlc2/basis/ResidentInitBudgetTest.java | 58 ++++++++++++++++ .../tlc2/basis/ResidentRefreshTLCGetTest.java | 67 +++++++++++++++++++ 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index 6f2fb91c14..dbff147a9c 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -63,9 +63,10 @@ * {@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, a state or action constraint, the view or - * the symmetry set changed, 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. + * the symmetry set changed, 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 @@ -174,6 +175,13 @@ public static ActionDiff diff(final Tool oldTool, final Tool 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<>(); @@ -237,6 +245,50 @@ public static ActionDiff diff(final Tool oldTool, final Tool newTool) { 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"; + } + } + return null; + } + + private static boolean reachesTLCGet(final SemanticNode node) { + final Map reached = new TreeMap<>(); + reach(node, reached, new HashSet<>()); + return reached.containsKey("TLC!TLCGet"); + } + /** 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) { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 05523000f6..2c3050f611 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -606,6 +606,14 @@ private JsonObject check(final JsonObject request) throws InterruptedException { 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); @@ -615,7 +623,9 @@ private JsonObject check(final JsonObject request) throws InterruptedException { } // 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. + // 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(); @@ -935,7 +945,8 @@ private String actionName(final long id) { * 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, the view, the symmetry set or the - * config, or a first run that did not finish, leaves nothing to carry: + * config, 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. * *

    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..f28c78e367 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.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 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" // + + "Init == x \\in 1..400000\n" // + + "Next == x' = x\n" // + + "Inv == x < 390000\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/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(); + } +} From 99d2bbf280958a224438863021030ee8af52c1ef Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 15:32:07 -0400 Subject: [PATCH 16/33] Resident review fixes: evaluation failures are not violations, one trace per report, exhausted only when complete - Recorder keeps TLC_INVARIANT_EVALUATION_FAILED apart from the violation counts; check reports such an invariant as not_evaluable with its error. - A reprint of the same behaviour (TLC_BEHAVIOR_UP_TO_THIS_POINT on a trace that already has states, as when an evaluation error is re-run for its call stack under continuation) replaces the states instead of appending. - registers derives exhausted from explorationComplete(), and stopped_by says error for a run that ended on an error rather than a violation. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Recorder.java | 27 ++++++- .../src/tlc2/basis/Resident.java | 48 ++++++++--- .../ResidentEvaluationFailedContinueTest.java | 81 +++++++++++++++++++ .../basis/ResidentEvaluationFailedTest.java | 79 ++++++++++++++++++ 4 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedContinueTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentEvaluationFailedTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java index 3242460889..4c3a8d052a 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -74,6 +74,8 @@ public static final class Trace { 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; @@ -139,12 +141,29 @@ public synchronized void record(final int code, final Object... objects) { outcome = code; outcomeProperty = property; } - violationCounts.merge(property == null ? "" : property, 1, Integer::sum); + 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; 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. + 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) { @@ -223,6 +242,7 @@ public synchronized void reset() { traces.clear(); violationCounts.clear(); untraced.clear(); + evaluationFailures.clear(); finalStats = null; outcome = EC.NO_ERROR; outcomeProperty = null; @@ -253,6 +273,11 @@ 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(); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 2c3050f611..426489d0dd 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -712,17 +712,30 @@ private static JsonObject traceJson(final Recorder.Trace trace) { 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(); for (final String name : tool.getInvNames()) { final JsonObject v = new JsonObject(); v.addProperty("name", name); final Integer n = counts.get(name); - if (n != null) { - v.addProperty("verdict", "violated"); - v.addProperty("reports", n); + 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)) { + 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")); @@ -784,6 +797,21 @@ private boolean explorationComplete() { } } + /** 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_DEADLOCK_REACHED: + return true; + default: + return false; + } + } + // ─── stats ────────────────────────────────────────────────────────── private JsonObject stats() { @@ -823,14 +851,16 @@ private JsonObject stats() { private JsonObject registers() { final JsonObject r = new JsonObject(); final boolean finished = resultCode != null; - final boolean queueEmpty = checker.getStateQueueSize() == 0; + // 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", finished && queueEmpty && checkerFailure == null - && (resultCode == EC.NO_ERROR || runContinuation)); + r.addProperty("exhausted", exhausted); r.addProperty("stopped_by", checkerFailure != null ? "error" : !finished ? (checkerThread == null ? "not_started" : "budget") - : resultCode == EC.NO_ERROR ? "exhausted" - : runContinuation ? "exhausted_with_violations" : "violation"); + : exhausted ? (resultCode == EC.NO_ERROR ? "exhausted" : "exhausted_with_violations") + : isViolation(resultCode) ? "violation" : "error"); if (finished) { r.addProperty("result_code", resultCode); } 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(); + } +} From aacc660dd394cd7df5d21cde34a11cdad1e3ccbc Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 15:44:14 -0400 Subject: [PATCH 17/33] Resident review fixes: verdicts TLC skipped under continuation, violations in stopped_by, initial PROPERTY violations - Under continuation TLC checks no further invariant on a state once one fails there, so an invariant it never reported may fail only on those states. For an exhausted run that reported a violation, the resident now evaluates the unreported invariants over the store (Incremental.sweep, restricted by name), or answers not_evaluated without a store. TLC's own -continue behaviour and output are unchanged. - registers.stopped_by: a continuation run that reported violations ends on NO_ERROR; decide exhausted vs exhausted_with_violations from the recorder. - TLC_PROPERTY_VIOLATED_INITIAL (a PROPERTY false in an initial state) is a violation: recorded with its property, verdict property_violated, stopped_by violation. Tests: ResidentContinuationInvariantsTest, ...NoStoreTest, ResidentPropertyInitialTest. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Incremental.java | 16 +++- .../src/tlc2/basis/Recorder.java | 1 + .../src/tlc2/basis/Resident.java | 50 +++++++++++- ...dentContinuationInvariantsNoStoreTest.java | 53 ++++++++++++ .../ResidentContinuationInvariantsTest.java | 81 +++++++++++++++++++ .../basis/ResidentPropertyInitialTest.java | 58 +++++++++++++ 6 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsNoStoreTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationInvariantsTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index dbff147a9c..93657f3711 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -683,11 +683,23 @@ public static final class Sweep { * 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++) { - out.add(new Sweep(k < names.length ? names[k] : invariants[k].getNameOfDefault())); + final String name = k < names.length ? names[k] : invariants[k].getNameOfDefault(); + out.add(new Sweep(name)); + wanted[k] = only == null || only.contains(name); } for (final long fp : store.fingerprints()) { final TLCState state = rebind(tool, store.read(fp)); @@ -697,7 +709,7 @@ public static List sweep(final Tool tool, final GraphStore store) { final Integer level = store.level(fp); for (int k = 0; k < invariants.length; k++) { final Sweep sw = out.get(k); - if (sw.error != null) { + if (!wanted[k] || sw.error != null) { continue; } try { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java index 4c3a8d052a..1784021320 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -131,6 +131,7 @@ public synchronized void record(final int code, final Object... objects) { 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) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 426489d0dd..99aadc5f01 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -715,6 +715,28 @@ private JsonArray invariantVerdicts(final boolean finished) { 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); + } + } + final boolean skipped = exhausted && runContinuation && 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); @@ -743,6 +765,25 @@ private JsonArray invariantVerdicts(final boolean finished) { 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"); } @@ -761,6 +802,9 @@ private static String verdict(final int code, final int outcome) { 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: @@ -805,6 +849,7 @@ private static boolean isViolation(final int code) { 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: @@ -859,7 +904,10 @@ private JsonObject registers() { r.addProperty("exhausted", exhausted); r.addProperty("stopped_by", checkerFailure != null ? "error" : !finished ? (checkerThread == null ? "not_started" : "budget") - : exhausted ? (resultCode == EC.NO_ERROR ? "exhausted" : "exhausted_with_violations") + // 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); 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/ResidentPropertyInitialTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java new file mode 100644 index 0000000000..f22bdb25f2 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.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 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()); + 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(); + } +} From a4f76575de108d9759a81d2143e6cc882b6f0735 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 16:06:29 -0400 Subject: [PATCH 18/33] Resident review fixes: excluded successors checked, queries off the coverage, name slots restored, dead assignments listed - TLC checks invariants on successors a constraint excludes. GraphStore now keeps them (content once per fingerprint, each edge once), refresh carries their edges and checks invariants on excluded successors it generates, and the invariant sweeps (refresh verdicts, skipped invariants under continuation) cover them. Before, an invariant failing only on an excluded state came back no_violation_found. - Refresh evaluates constraints without a cost model: a refreshed tool has none, and Tool.isInModel threw ClassCastException on any refresh with a CONSTRAINT that generated new successors. - neighbours generates successors through Tool.getNextStatesUnrecorded, and the sweeps evaluate invariants with CostModel.DO_NOT_RECORD, so store queries no longer change the run's coverage. - A refresh that is not adopted restores every name's slot as it was before the parse, so a definition the edited spec declares a variable still evaluates as a definition. If one ever doesn't, every request that evaluates against the spec is refused with restart_required. - CoverageWalk lists an assignment that never ran (x' = 0 behind a guard that never held); a primed node counts when it runs. Tests: ResidentRefreshExcludedTest, ResidentContinuationExcludedTest, ResidentRefreshRestoreTest, and ResidentCoverageTest extended. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 124 ++++++++++++- .../src/tlc2/basis/Incremental.java | 169 +++++++++++++----- .../src/tlc2/basis/Resident.java | 75 ++++++-- .../src/tlc2/tool/coverage/CoverageWalk.java | 11 +- .../src/tlc2/tool/impl/Tool.java | 12 ++ .../ResidentContinuationExcludedTest.java | 62 +++++++ .../test/tlc2/basis/ResidentCoverageTest.java | 20 ++- .../basis/ResidentRefreshExcludedTest.java | 87 +++++++++ .../basis/ResidentRefreshRestoreTest.java | 73 ++++++++ 9 files changed, 566 insertions(+), 67 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationExcludedTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshRestoreTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index cb74a6c049..2b0011ae6e 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -64,7 +64,9 @@ * 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. Blocked guards are tallied, not logged: per (action, conjunct) + * 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 @@ -163,6 +165,14 @@ public int hashCode() { 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<>(); @@ -259,6 +269,56 @@ public void writeState(final TLCState state, final TLCState successor, final sho 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; } /** @@ -441,10 +501,14 @@ private static final class Serialiser { /** 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; - index.put(fp, new Entry(offset, data.length, level, predecessor, action)); if (pending.size() >= FLUSH_AT) { try { flush(); @@ -452,6 +516,13 @@ private void store(final long fp, final byte[] data, final int level, final long 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 { @@ -473,9 +544,9 @@ public void write(final byte[] b, final int off, final int len) throws IOExcepti pending.reset(); } - /** The stored state with this fingerprint, or null. */ + /** 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 = index.get(fp); + final Entry e = entry(fp); if (e == null) { return null; } @@ -501,7 +572,7 @@ public synchronized boolean contains(final long fp) { } public synchronized Integer level(final long fp) { - final Entry e = index.get(fp); + final Entry e = entry(fp); return e == null ? null : e.level; } @@ -521,13 +592,14 @@ public synchronized long[] fingerprints() { * state). Null when {@code fp} is not stored. */ public synchronized long[][] pathTo(final long fp) { - if (!index.containsKey(fp)) { + if (entry(fp) == null) { return null; } final List reversed = new ArrayList<>(); long cur = fp; while (true) { - final Entry e = index.get(cur); + // 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; } @@ -544,6 +616,44 @@ public synchronized long[][] pathTo(final long fp) { 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); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index 93657f3711..e8afa5fdb1 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -75,6 +75,9 @@ * 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 @@ -541,26 +544,36 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final break; } // Changed invariants on a survivor. - for (int k = 0; k < invariants.length; k++) { - final String name = k < invNames.length ? invNames[k] : invariants[k].getNameOfDefault(); - if (!changedInv.contains(name)) { - continue; - } - boolean holds; - try { - holds = newTool.isValid(invariants[k], state); - } catch (final Throwable t) { - r.error = name + ": " + t; - stop = true; - break; - } - if (!holds) { - r.violations.add(new Violation(name, fp, newStore.level(fp))); - if (!continueOnViolation) { + 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 ? rebind(newTool, oldStore.read(to)) : newStore.read(to); + 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; @@ -605,14 +618,19 @@ private static TLCState rebind(final Tool tool, final TLCState s) { return out; } - /** Expand one state under {@code actions}; returns true to stop. */ + /** + * 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.getNextStates(a, state); + next = tool.getNextStatesUnrecorded(a, state); } catch (final Throwable t) { r.error = a.getNameOfDefault() + ": " + t; return true; @@ -625,35 +643,27 @@ private static boolean expand(final Tool tool, final GraphStore store, final TLC } boolean inModel; try { - inModel = tool.isInModel(succ) && tool.isInActions(state, succ); + 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 long to = succ.fingerPrint(); final boolean unseen = !store.contains(to); store.writeState(state, succ, unseen ? tlc2.util.IStateWriter.IsUnseen : tlc2.util.IStateWriter.IsSeen, a); r.edgesGenerated++; - if (unseen) { - for (int k = 0; k < invariants.length; k++) { - boolean holds; - try { - holds = tool.isValid(invariants[k], succ); - } catch (final Throwable t) { - r.error = (k < invNames.length ? invNames[k] : "invariant") + ": " + t; - return true; - } - if (!holds) { - r.violations.add(new Violation(k < invNames.length ? invNames[k] : invariants[k].getNameOfDefault(), - to, store.level(to))); - if (!continueOnViolation) { - return true; - } - } - } + if (unseen && checkInvariants(tool, succ, to, store.level(to), invariants, invNames, null, + continueOnViolation, r)) { + return true; } if (seen.add(to)) { queue.add(to); @@ -663,6 +673,79 @@ private static boolean expand(final Tool tool, final GraphStore store, final TLC 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; @@ -677,7 +760,8 @@ public static final class Sweep { } /** - * Evaluate every invariant on every stored state: exact per-invariant + * Evaluate every invariant on every stored state, excluded successors + * 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. @@ -701,7 +785,12 @@ public static List sweep(final Tool tool, final GraphStore store, final S out.add(new Sweep(name)); wanted[k] = only == null || only.contains(name); } - for (final long fp : store.fingerprints()) { + // 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; @@ -713,7 +802,7 @@ public static List sweep(final Tool tool, final GraphStore store, final S continue; } try { - if (!tool.isValid(invariants[k], state)) { + if (!holds(tool, invariants[k], state)) { sw.violations++; if (sw.firstLevel == null || (level != null && level < sw.firstLevel)) { sw.firstLevel = level; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 99aadc5f01..68d1799dac 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -123,7 +123,8 @@ public final class Resident { private boolean storing = true; /** * Set when a refresh that did not replace the tool left TLC's static - * tables unfit for the parked checker to resume: why a restart is needed. + * 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. */ @@ -223,6 +224,22 @@ JsonObject serve(final JsonObject request) throws Exception { } 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); @@ -569,10 +586,6 @@ private JsonObject check(final JsonObject request) throws InterruptedException { 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"); } - if (restartRequired != null && resultCode == null && checkerFailure == null) { - return error(null, "restart_required", - "the paused run cannot resume in this process: " + restartRequired + "; open a new session"); - } 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; @@ -989,6 +1002,7 @@ private JsonObject storeInfo() { 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; } @@ -1070,6 +1084,9 @@ private JsonObject refresh(final JsonObject request) throws Exception { baseTool = tool; baseStore = store; } + // Parsing rebinds the process-global name slots; kept to put them back + // if the edited spec is not adopted. + final Map slots = nameSlots(); final Tool newTool; try { newTool = new FastTool(mainFile, configName, new SimpleFilenameToStream(specDir), Tool.Mode.MC, @@ -1078,7 +1095,7 @@ private JsonObject refresh(final JsonObject request) throws Exception { final JsonObject reply = error(null, "parse_failed", t.toString()); reply.add("messages", recorder.drainMessages()); // The current tool and store stay in place. - rebindStatics(tool); + rebindStatics(tool, slots); return reply; } final Incremental.ActionDiff diff = Incremental.diff(baseTool, newTool); @@ -1086,7 +1103,7 @@ private JsonObject refresh(final JsonObject request) throws Exception { reply.add("diff", Incremental.diffJson(diff)); reply.addProperty("front_end_ms", System.currentTimeMillis() - started); if (diff.fullRerunReason != null) { - rebindStatics(tool); + rebindStatics(tool, slots); return fullRerun(reply, diff.fullRerunReason, started); } reply.addProperty("mode", "incremental"); @@ -1112,7 +1129,7 @@ private JsonObject refresh(final JsonObject request) throws Exception { if (r.error != null) { // The edited spec does not evaluate; keep serving what was there. newStore.dispose(); - rebindStatics(tool); + rebindStatics(tool, slots); reply.addProperty("adopted", false); reply.addProperty("finished", false); reply.addProperty("complete", false); @@ -1268,11 +1285,20 @@ private String readConfig() { * 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; this - * puts the old spec's back. A name that was a definition of the old spec - * and a variable of the new one has lost its definition slot, which - * cannot be put back: the parked run then must not resume. + * puts the old spec's back, and every name's slot as {@code slots} + * recorded it before the parse (a definition of the old spec that the + * new one declares a variable would otherwise evaluate as that + * variable). Should a definition still read as a variable, nothing that + * evaluates against the spec is served any more. */ - private void rebindStatics(final Tool old) { + private void rebindStatics(final Tool old, final Map slots) { + // 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 = slots.get(u); + u.setLoc(loc == null ? -1 : loc); + } final tla2sany.semantic.OpDeclNode[] vars = old.getSpecProcessor().getVariablesNodes(); for (int i = 0; i < vars.length; i++) { vars[i].getName().setLoc(i); @@ -1289,6 +1315,28 @@ private void rebindStatics(final Tool old) { } } + /** Every interned name's slot in a state or the definition table (-1 for none). */ + private static Map nameSlots() { + final Map out = new java.util.IdentityHashMap<>(); + for (final util.UniqueString u : util.UniqueString.internTbl.toMap().values()) { + // A slot is either a variable's or a definition's. + out.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) { @@ -1353,7 +1401,8 @@ private JsonObject neighbours(final JsonObject request) { o.addProperty("action", a.getNameOfDefault()); o.addProperty("action_id", a.getId()); try { - final StateVec next = tool.getNextStates(a, state); + // 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) { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java index 4ef43fe845..e5920293b7 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/coverage/CoverageWalk.java @@ -190,8 +190,9 @@ private static JsonObject node(final CostModelNode n) { /** * The subexpressions under {@code root} whose own evaluation count is - * zero and that are not primed (a primed node is assigned, not - * evaluated), listed flat with their locations. + * 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(); @@ -212,7 +213,9 @@ private static void collectUnevaluated(final CostModelNode parent, final JsonArr // conjuncts run (the printer collapses such nodes into their // children), so a node is unevaluated only when nothing below // it ran either. - if (w.getEvalCount(Calculate.FRESH) == 0L && !w.isPrimed() && !anyEvaluated(w)) { + // 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) { @@ -231,7 +234,7 @@ 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 || w.isPrimed()) { + if (w.getEvalCount(Calculate.FRESH) > 0L) { return true; } } else if (child.getEvalCount() > 0L) { 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 199992857f..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(); 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/ResidentCoverageTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java index 0b10ec7a27..100ac735f2 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentCoverageTest.java @@ -30,6 +30,7 @@ import org.junit.Test; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -68,11 +69,24 @@ public void testUnevaluated() throws Exception { assertEquals(3, actions.get("A").get("found").getAsLong()); assertEquals(0, actions.get("A").getAsJsonArray("unevaluated").size()); assertEquals(0, actions.get("D").get("found").getAsLong()); - assertEquals(1, actions.get("D").getAsJsonArray("unevaluated").size()); - assertEquals("x*2>25", actions.get("D").getAsJsonArray("unevaluated").get(0).getAsJsonObject() - .get("text").getAsString()); + // 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)); 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/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()); + } + } +} From 052a9c17bb05c980c8113d3e1453afb79c689c19 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 16:22:21 -0400 Subject: [PATCH 19/33] Resident review fixes: invariants skipped under continuation stay swept after a resume without it runContinuation holds only the last check call's setting. A run paused under continuation and resumed with continue:false had already skipped further invariants on the states that violated one, yet the verdicts then called those invariants no_violation_found. A sticky everContinued flag now decides the store sweep; explorationComplete still reads runContinuation, since a run that ended on a violation without continuation is incomplete. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Resident.java | 12 +++- .../basis/ResidentContinuationToggleTest.java | 71 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 68d1799dac..e12740be69 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -150,6 +150,13 @@ public final class Resident { * 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; private volatile Integer resultCode; private volatile Throwable checkerFailure; @@ -613,6 +620,7 @@ private JsonObject check(final JsonObject request) throws InterruptedException { TLCGlobals.continuation = request.get("continue").getAsBoolean(); } runContinuation = TLCGlobals.continuation; + everContinued |= runContinuation; if (checkerThread == null) { checkerThread = new Thread(() -> { try { @@ -741,7 +749,9 @@ private JsonArray invariantVerdicts(final boolean finished) { unreported.add(name); } } - final boolean skipped = exhausted && runContinuation && anyViolated && !unreported.isEmpty(); + // 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)) { 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..810d7d6878 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.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.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" // + + "Next == x < 40 /\\ 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(); + } +} From 25ec10cc3b70498de142aaf703e160d45faf8604 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 16:50:40 -0400 Subject: [PATCH 20/33] Resident review fixes: assumptions and implied inits on refresh, VIEW survivors, serialiser reset order - refresh evaluates the edited spec's ASSUME clauses before replaying; a false (or unevaluable) one leaves the edit unadopted, as TLC would explore nothing. State-level PROPERTY formulas (implied inits) get a verdict over the stored initial states under `implied_inits`, and the open catalogue lists them. - Under a VIEW, a fingerprint in the old graph survives a replay only if the edit reaches it with the same concrete content; otherwise its old edges (generated from other content) are not carried and it is expanded under every action. - GraphStore.serialise resets the value stream before the byte buffer, so a write that threw part way cannot prefix the next stored state. - Tests: ResidentRefreshAssumptionTest; ResidentViewStoreTest (four workers, a VIEW whose hidden variable decides the successors; every stored state's successors match its recorded edges, before and after a refresh that matches a fresh run's 8 states and 13 edges). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 5 +- .../src/tlc2/basis/Incremental.java | 61 +++++++- .../src/tlc2/basis/Resident.java | 49 +++++- .../basis/ResidentRefreshAssumptionTest.java | 109 ++++++++++++++ .../tlc2/basis/ResidentViewStoreTest.java | 140 ++++++++++++++++++ 5 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshAssumptionTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index 2b0011ae6e..e88bb552d8 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -463,8 +463,11 @@ public synchronized void snapshot() throws IOException { private static byte[] serialise(final TLCState state) { final Serialiser ser = SERIALISER.get(); try { - ser.bytes.reset(); + // 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) { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index e8afa5fdb1..04d15326f3 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -482,6 +482,7 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final final Action[] invariants = newTool.getInvariants(); final String[] invNames = newTool.getInvNames(); final Set changedInv = new HashSet<>(diff.changedInvariants); + final boolean viewed = newTool.getViewSpec() != null; // Forward adjacency of the old graph restricted to carried actions: // fp -> (succ fp, new action) pairs. final Map> forward = new HashMap<>(); @@ -518,7 +519,12 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final } final long fp = queue.poll(); final TLCState state = rebind(newTool, newStore.read(fp)); - final boolean survivor = oldStates.contains(fp); + // Under a VIEW a fingerprint names several concrete states, and the + // edited spec may reach this one first through another (a changed + // or added action). Its old edges were generated from the old + // content, so it survives only if the content is the same. + final boolean survivor = oldStates.contains(fp) + && (!viewed || sameValues(state, oldStore.read(fp))); if (survivor) { r.survivors++; // Carried edges: copy successors and their content. @@ -603,6 +609,21 @@ private static int actionIndex(final Tool tool, final int id) { throw new IllegalStateException("no action with id " + id); } + /** Whether two states bind every variable to equal values. */ + private static boolean sameValues(final TLCState a, final TLCState b) { + if (a == null || b == null) { + return false; + } + for (final tla2sany.semantic.OpDeclNode v : a.getVars()) { + final tlc2.value.IValue va = a.lookup(v.getName()); + final tlc2.value.IValue vb = b.lookup(v.getName()); + if (va == null ? vb != null : vb == null || !va.equals(vb)) { + return false; + } + } + return true; + } + /** A stored state rebuilt against the new spec's variable set. */ private static TLCState rebind(final Tool tool, final TLCState s) { if (s == null) { @@ -817,6 +838,44 @@ public static List sweep(final Tool tool, final GraphStore store, final S return out; } + /** + * Evaluate every state-level PROPERTY (TLC's implied inits, which it + * checks on the initial states only) on the stored initial states: 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())); + } + for (final long fp : store.initialFingerprints()) { + 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)); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index e12740be69..49d95fa8cf 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -550,6 +550,8 @@ private JsonObject catalogue() { 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()); @@ -1056,7 +1058,9 @@ private String actionName(final long id) { * 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. + * 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 @@ -1117,6 +1121,26 @@ private JsonObject refresh(final JsonObject request) throws Exception { 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); @@ -1230,6 +1254,26 @@ private JsonObject refresh(final JsonObject request) throws Exception { 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()); @@ -1239,7 +1283,8 @@ private JsonObject refresh(final JsonObject request) throws Exception { /** * What a refresh does not recheck, so a caller does not read the - * invariant verdicts as the whole answer: temporal properties (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). */ 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/ResidentViewStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java new file mode 100644 index 0000000000..561defa3b3 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java @@ -0,0 +1,140 @@ +/******************************************************************************* + * 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, the invariant sweeps and a refresh all 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; the check is the + * same after a refresh, which replays that content and must not carry a + * state's old edges onto different content the edit reaches first. + */ +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)); + + h.write("V.tla", spec(true)); + final JsonObject r = h.ok("{\"command\":\"refresh\"}"); + assertEquals(r.toString(), "incremental", r.get("mode").getAsString()); + assertTrue(r.toString(), r.get("complete").getAsBoolean()); + assertEquals(storeStates(r), consistent(h)); + // What a fresh run of the edited spec stores (one worker, so which + // concrete state TLC keeps is fixed). The edit reaches x = 4 first + // with y = 1, whose successors reach x = 7; the old content's edges + // do not. + assertEquals(r.toString(), 8, storeStates(r)); + assertEquals(r.toString(), 13, ResidentHarness.storeEdges(r)); + assertEquals("no_violation_found", + r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString()); + h.resident.shutdown(); + } +} From 072425763fa03e95df75a2d77eb0b2ce18924e73 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 17:06:42 -0400 Subject: [PATCH 21/33] Resident review fixes: full rerun under VIEW or SYMMETRY, constraint-excluded initial states swept Refresh now answers restart_required whenever the old or edited spec has a VIEW or a SYMMETRY set. A fingerprint then names several concrete states and the store keeps whichever TLC reached first; a copied edge from another state could carry that content even when the edited spec never reaches it, and the replay explored it (a reported violation a fresh run did not find). Initial states a state constraint excludes are now kept in the store through a new IStateWriter.writeExcludedInitial hook (a no-op by default) that ModelChecker's init functor calls. TLC checks invariants and state-level properties on them, so the invariant sweeps, the implied-init check and the replay (which carries them and checks changed invariants on them) do too. Before, an edited invariant violated only there came back no_violation_found. Tests: ResidentRefreshViewTest (the repro), ResidentRefreshSymmetryTest, ResidentRefreshExcludedInitialTest; ResidentViewStoreTest now expects the full rerun. ResidentContinuationToggleTest gets a larger state space so its six-state budget reliably fires before the run ends (it failed 2 in 5). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 29 +++++ .../src/tlc2/basis/Incremental.java | 99 +++++++++-------- .../src/tlc2/basis/Resident.java | 4 +- .../src/tlc2/tool/ModelChecker.java | 4 + .../src/tlc2/util/IStateWriter.java | 8 ++ .../basis/ResidentContinuationToggleTest.java | 4 +- .../ResidentRefreshExcludedInitialTest.java | 102 ++++++++++++++++++ .../basis/ResidentRefreshSymmetryTest.java | 68 ++++++++++++ .../tlc2/basis/ResidentRefreshViewTest.java | 70 ++++++++++++ .../tlc2/basis/ResidentViewStoreTest.java | 25 ++--- 10 files changed, 350 insertions(+), 63 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshExcludedInitialTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshSymmetryTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshViewTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index e88bb552d8..f7d6a84954 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -182,6 +182,12 @@ public int hashCode() { */ 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 List excludedInitial = new ArrayList<>(); private long edges; private final LongAdder unsatisfied = new LongAdder(); private final LongAdder excluded = new LongAdder(); @@ -221,6 +227,20 @@ public void writeState(final TLCState state) { } } + @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)); + } + if (!excludedInitial.contains(fp)) { + excludedInitial.add(fp); + } + } + } + @Override public synchronized void writeState(final TLCState state, final TLCState successor, final short stateFlags) { writeState(state, successor, stateFlags, (Action) null); @@ -693,6 +713,15 @@ public synchronized long[] initialFingerprints() { 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()]; + for (int i = 0; i < out.length; i++) { + out[i] = excludedInitial.get(i); + } + return out; + } + /** The variable names the stored states were written with, in order. */ public synchronized String[] variableNames() { final TLCState e = empty(); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index 04d15326f3..cc4dfb8ae7 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -57,13 +57,13 @@ * *

    * The edited spec is parsed into a new {@link Tool}. Every action, invariant, - * initial predicate, constraint, view and symmetry set gets a signature: its + * 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, a state or action constraint, the view or - * the symmetry set changed, or anything explored reads {@code TLCGet} (whose + * 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. @@ -170,9 +170,12 @@ public static ActionDiff diff(final Tool oldTool, final Tool newTool) { d.fullRerunReason = "a state or action constraint changed"; return d; } - if (!fingerprintSignature(oldTool).equals(fingerprintSignature(newTool))) { - d.fullRerunReason = "the view or the symmetry set 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"; @@ -453,21 +456,23 @@ private static String substituted(final String rhs, final Map return def == null || def.getBody() == null ? rhs : rhs + " == " + signature(def.getBody(), null); } - /** The view and symmetry set: they decide what a fingerprint identifies. */ - private static String fingerprintSignature(final Tool tool) { - final StringBuilder sb = new StringBuilder(); - sb.append("view ").append(signature(tool.getViewSpec(), null)).append('\n'); + /** + * 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 (symmetry != null && !symmetry.isEmpty()) { - sb.append("symmetry ").append(symmetry); - final OpDefNode[] defs = tool.getSpecProcessor().getRootModule().getOpDefs(); - for (final OpDefNode def : defs == null ? new OpDefNode[0] : defs) { - if (def.getName().toString().equals(symmetry)) { - sb.append(' ').append(signature(def.getBody(), null)); - } - } + 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 sb.toString(); + return null; } /** @@ -482,7 +487,6 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final final Action[] invariants = newTool.getInvariants(); final String[] invNames = newTool.getInvNames(); final Set changedInv = new HashSet<>(diff.changedInvariants); - final boolean viewed = newTool.getViewSpec() != null; // Forward adjacency of the old graph restricted to carried actions: // fp -> (succ fp, new action) pairs. final Map> forward = new HashMap<>(); @@ -512,6 +516,24 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final 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 = rebind(newTool, oldStore.read(fp)); + 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; @@ -519,12 +541,9 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final } final long fp = queue.poll(); final TLCState state = rebind(newTool, newStore.read(fp)); - // Under a VIEW a fingerprint names several concrete states, and the - // edited spec may reach this one first through another (a changed - // or added action). Its old edges were generated from the old - // content, so it survives only if the content is the same. - final boolean survivor = oldStates.contains(fp) - && (!viewed || sameValues(state, oldStore.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. @@ -609,21 +628,6 @@ private static int actionIndex(final Tool tool, final int id) { throw new IllegalStateException("no action with id " + id); } - /** Whether two states bind every variable to equal values. */ - private static boolean sameValues(final TLCState a, final TLCState b) { - if (a == null || b == null) { - return false; - } - for (final tla2sany.semantic.OpDeclNode v : a.getVars()) { - final tlc2.value.IValue va = a.lookup(v.getName()); - final tlc2.value.IValue vb = b.lookup(v.getName()); - if (va == null ? vb != null : vb == null || !va.equals(vb)) { - return false; - } - } - return true; - } - /** A stored state rebuilt against the new spec's variable set. */ private static TLCState rebind(final Tool tool, final TLCState s) { if (s == null) { @@ -781,8 +785,8 @@ public static final class Sweep { } /** - * Evaluate every invariant on every stored state, excluded successors - * included, as TLC evaluates them: exact per-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. @@ -840,7 +844,8 @@ public static List sweep(final Tool tool, final GraphStore store, final S /** * Evaluate every state-level PROPERTY (TLC's implied inits, which it - * checks on the initial states only) on the stored initial states: one + * 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) { @@ -850,7 +855,11 @@ public static List impliedInits(final Tool tool, final GraphStore store) for (int k = 0; k < inits.length; k++) { out.add(new Sweep(k < names.length ? names[k] : inits[k].getNameOfDefault())); } - for (final long fp : store.initialFingerprints()) { + 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; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 49d95fa8cf..0978543168 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -1048,8 +1048,8 @@ private String actionName(final long id) { /** * 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, the view, the symmetry set or the - * config, a spec that reads {@code TLCGet}, or a first run that did not + * 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. * diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index 2712a6f3ec..b176d9cb7c 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java @@ -1241,6 +1241,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/util/IStateWriter.java b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java index 155d81dcb9..9af112590a 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java @@ -74,6 +74,14 @@ default boolean isSet(int v, int control) { * it drops the context and forwards only a fully assigned successor, the * only kind upstream writers ever received here. */ + /** + * 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) { + } + default void writeUnsatisfied(TLCState state, Action action, TLCState successor, SemanticNode pred, tlc2.util.Context c) { if (successor != null && successor.allAssigned()) { diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java index 810d7d6878..fdaba127a6 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentContinuationToggleTest.java @@ -43,7 +43,9 @@ public class ResidentContinuationToggleTest { + "EXTENDS Naturals\n" // + "VARIABLE x\n" // + "Init == x = 0\n" // - + "Next == x < 40 /\\ x' = x + 1\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"; 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/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/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/ResidentViewStoreTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java index 561defa3b3..e149f43dae 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentViewStoreTest.java @@ -41,12 +41,11 @@ * 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, the invariant sweeps and a refresh all work from its + * 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; the check is the - * same after a refresh, which replays that content and must not carry a - * state's old edges onto different content the edit reaches first. + * 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 { @@ -122,19 +121,15 @@ public void testViewStore() throws Exception { 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(), "incremental", r.get("mode").getAsString()); - assertTrue(r.toString(), r.get("complete").getAsBoolean()); - assertEquals(storeStates(r), consistent(h)); - // What a fresh run of the edited spec stores (one worker, so which - // concrete state TLC keeps is fixed). The edit reaches x = 4 first - // with y = 1, whose successors reach x = 7; the old content's edges - // do not. - assertEquals(r.toString(), 8, storeStates(r)); - assertEquals(r.toString(), 13, ResidentHarness.storeEdges(r)); - assertEquals("no_violation_found", - r.getAsJsonArray("invariants").get(0).getAsJsonObject().get("verdict").getAsString()); + 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(); } } From b6e3fc979ab67599f083dd013f3b2d34d8e06fce Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 17:20:39 -0400 Subject: [PATCH 22/33] Resident review fixes: initial-state violations carry their state, writeUnsatisfied documented TLC prints the state of an invariant or PROPERTY violated in an initial state only into its report's text, so the recorder's trace for it had no states: `check` answered `trace: null`, left it out of `traces`, and gave the invariant verdict `level: 0`. The recorder now keeps that text, and the resident completes each such trace with the stored initial state whose (ALIAS-evaluated) print matches it, excluded initial states included; without a store the state is kept as TLC printed it. The trace is at level 1 with action . IStateWriter: the writeUnsatisfied javadoc sat above writeExcludedInitial's; each method has its own again. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Recorder.java | 40 ++++++++++ .../src/tlc2/basis/Resident.java | 39 ++++++++++ .../src/tlc2/util/IStateWriter.java | 16 ++-- .../ResidentInitialViolationContinueTest.java | 78 +++++++++++++++++++ .../ResidentInitialViolationNoStoreTest.java | 58 ++++++++++++++ .../basis/ResidentInitialViolationTest.java | 64 +++++++++++++++ .../basis/ResidentPropertyInitialTest.java | 5 ++ 7 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationContinueTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationNoStoreTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitialViolationTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java index 1784021320..2bc44e1442 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -63,6 +63,17 @@ public static final class Trace { 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; } private final List messages = new ArrayList<>(); @@ -153,6 +164,9 @@ public synchronized void record(final int code, final Object... objects) { 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: @@ -259,6 +273,32 @@ public synchronized JsonArray drainMessages() { 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; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 0978543168..6ab36ae5a7 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -516,6 +516,8 @@ private JsonObject simulationReply(final boolean stopped) { if (property != null) { reply.addProperty("violated", property); } + // No store in simulation: an initial state's violation keeps TLC's text. + recorder.completeInitial(null); final JsonArray all = new JsonArray(); for (final Recorder.Trace t : recorder.traces()) { all.add(traceJson(t)); @@ -684,6 +686,7 @@ private JsonObject check(final JsonObject request) throws InterruptedException { if (property != null) { reply.addProperty("violated", property); } + recorder.completeInitial(initialStates()); final Recorder.Trace trace = recorder.trace(); if (trace != null) { reply.add("trace", traceJson(trace)); @@ -707,6 +710,42 @@ private JsonObject check(final JsonObject request) throws InterruptedException { 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); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java b/tlatools/org.lamport.tlatools/src/tlc2/util/IStateWriter.java index 9af112590a..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,14 @@ 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 @@ -74,14 +82,6 @@ default boolean isSet(int v, int control) { * it drops the context and forwards only a fully assigned successor, the * only kind upstream writers ever received here. */ - /** - * 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) { - } - default void writeUnsatisfied(TLCState state, Action action, TLCState successor, SemanticNode pred, tlc2.util.Context c) { if (successor != null && successor.allAssigned()) { 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/ResidentPropertyInitialTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java index f22bdb25f2..81d4bd7f97 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentPropertyInitialTest.java @@ -50,6 +50,11 @@ public void testInitialPropertyViolation() throws Exception { 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()); From 87343e3ee962a51ae34d72e0e097bc7d95abfe28 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 17:35:27 -0400 Subject: [PATCH 23/33] Resident review fixes: stale stats and registers after a refresh, capped implied-action traces on replay, excluded initial states deduplicated in constant time - stats and registers mark themselves stale after an incremental refresh and report whether the refreshed store is the whole graph, as coverage and guard_profile already do. - ModelChecker.doNextCheckImplied gates its continuation trace on the per-property cap, as the invariant branch and Worker do, so the error replay prints no trace the recorder has already counted as capped. - GraphStore keeps excluded initial states in a LinkedHashSet instead of scanning a list under the lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 11 ++- .../src/tlc2/basis/Resident.java | 20 +++++ .../src/tlc2/tool/ModelChecker.java | 4 +- .../tlc2/basis/ResidentRefreshStaleTest.java | 79 +++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshStaleTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index f7d6a84954..66343d7755 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -187,7 +187,7 @@ public int hashCode() { * {@link #excludedIndex}: TLC checks invariants and state-level * properties on them too. */ - private final List excludedInitial = new ArrayList<>(); + 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(); @@ -235,9 +235,7 @@ public void writeExcludedInitial(final TLCState state) { if (!excludedIndex.containsKey(fp)) { excludedIndex.put(fp, new Entry(append(data), data.length, 1, 0, -1)); } - if (!excludedInitial.contains(fp)) { - excludedInitial.add(fp); - } + excludedInitial.add(fp); } } @@ -716,8 +714,9 @@ public synchronized long[] initialFingerprints() { /** The initial states a state constraint excluded, in the order they were written. */ public synchronized long[] excludedInitialFingerprints() { final long[] out = new long[excludedInitial.size()]; - for (int i = 0; i < out.length; i++) { - out[i] = excludedInitial.get(i); + int i = 0; + for (final Long fp : excludedInitial) { + out[i++] = fp; } return out; } diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 6ab36ae5a7..407d444b8c 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -110,6 +110,8 @@ public final class Resident { 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 @@ -255,6 +257,7 @@ private JsonObject dispatch(final String command, final JsonObject request) thro case "stats": { final JsonObject reply = ok(); reply.add("stats", stats()); + markStale(reply); return reply; } case "trace": @@ -291,6 +294,7 @@ private JsonObject dispatch(final String command, final JsonObject request) thro final JsonObject reply = ok(); reply.add("stats", stats()); reply.add("registers", registers()); + markStale(reply); return reply; } case "store": { @@ -1222,6 +1226,7 @@ private JsonObject refresh(final JsonObject request) throws Exception { // 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; @@ -1343,6 +1348,21 @@ private JsonObject unchecked() { 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) { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java index b176d9cb7c..12b2fed118 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/tool/ModelChecker.java @@ -543,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 { 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(); + } +} From 8888ab6673af787c284524934dac6dda28c83fc4 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Wed, 23 Sep 2026 17:47:52 -0400 Subject: [PATCH 24/33] Resident review fixes: a simulation that ends on an evaluation error is an error, simulate mode tested The simulator's result code was stored but never read, so a simulation stopped by the next-state relation failing to evaluate (EC.GENERAL) reported no_violation_found with no trace. The reply now carries the result code, answers error for a non-zero code no violation explains (a budget stop excepted), and includes the last behaviour TLC printed, as check does. Tests cover a violation, an evaluation error and a final budget stop. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Resident.java | 14 +++++ .../basis/ResidentSimulateBudgetTest.java | 62 +++++++++++++++++++ .../tlc2/basis/ResidentSimulateErrorTest.java | 62 +++++++++++++++++++ .../basis/ResidentSimulateViolationTest.java | 55 ++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateBudgetTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateViolationTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 407d444b8c..c1999760fa 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -508,11 +508,19 @@ private JsonObject simulationReply(final boolean stopped) { 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"); } @@ -522,6 +530,12 @@ private JsonObject simulationReply(final boolean stopped) { } // 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)); 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..7572397f17 --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.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.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()); + 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(); + } +} From 660e761ce3fdbfe4be96070b052980bc645a5ab0 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 15:52:06 +0000 Subject: [PATCH 25/33] Resident review fixes: model values kept across refreshes, stored states checked on replay, stores removed on close Every parse resets TLC's model-value table, and a stored state names a model value by its index there. A refresh that was not adopted left the edited spec's table in place, so store queries decoded against the wrong numbering (or threw, after a parse failure). The table is now saved before the parse and restored with the other statics, and an edit that renumbers the model values the store was written under asks for a restart instead of replaying. The replay now checks that every state it carries over reads back with its stored fingerprint, and answers restart_required when one does not, instead of failing later with a NullPointerException. Closing the resident disposes its stores, so a refresh's metadir and store file no longer stay behind in the spec's directory. Store queries on a simulate session say it keeps no store. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Incremental.java | 55 ++++++++++- .../src/tlc2/basis/Resident.java | 69 ++++++++++--- .../src/tlc2/value/impl/ModelValue.java | 50 ++++++++++ .../basis/ResidentRefreshModelValueTest.java | 98 +++++++++++++++++++ .../basis/ResidentShutdownCleanupTest.java | 90 +++++++++++++++++ .../tlc2/basis/ResidentSimulateErrorTest.java | 3 + 6 files changed, 346 insertions(+), 19 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshModelValueTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index cc4dfb8ae7..f71e8bd3f7 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -128,6 +128,12 @@ public static final class Result { 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() { @@ -503,11 +509,14 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final 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 = oldStore.read(fp); + final TLCState s = carry(newTool, oldStore, fp, r); + if (r.restartReason != null) { + return r; + } if (s == null) { continue; } - newStore.writeState(rebind(newTool, s)); + newStore.writeState(s); seen.add(fp); queue.add(fp); } @@ -522,7 +531,10 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final // see every initial state however the replay stops. final List excludedInitial = new ArrayList<>(); for (final long fp : oldStore.excludedInitialFingerprints()) { - final TLCState s = rebind(newTool, oldStore.read(fp)); + final TLCState s = carry(newTool, oldStore, fp, r); + if (r.restartReason != null) { + return r; + } if (s != null) { newStore.writeExcludedInitial(s); excludedInitial.add(s); @@ -552,7 +564,10 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final final long to = e[0]; final Action a = newTool.getActions()[actionIndex(newTool, (int) e[1])]; final TLCState succ = newStore.contains(to) ? newStore.read(to) - : rebind(newTool, oldStore.read(to)); + : carry(newTool, oldStore, to, r); + if (r.restartReason != null) { + return r; + } if (succ == null) { continue; } @@ -584,7 +599,10 @@ public static Result replay(final Tool newTool, final GraphStore oldStore, final } final long to = e[0]; final boolean fresh = !newStore.contains(to) && !newStore.isExcluded(to); - final TLCState succ = fresh ? rebind(newTool, oldStore.read(to)) : newStore.read(to); + final TLCState succ = fresh ? carry(newTool, oldStore, to, r) : newStore.read(to); + if (r.restartReason != null) { + return r; + } if (succ == null) { continue; } @@ -628,6 +646,33 @@ private static int actionIndex(final Tool tool, final int id) { 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) { diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index c1999760fa..89e83b0b3e 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -1079,6 +1079,9 @@ private JsonObject storeInfo() { // ─── 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"); } @@ -1155,9 +1158,10 @@ private JsonObject refresh(final JsonObject request) throws Exception { baseTool = tool; baseStore = store; } - // Parsing rebinds the process-global name slots; kept to put them back - // if the edited spec is not adopted. - final Map slots = nameSlots(); + // 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, @@ -1173,6 +1177,11 @@ private JsonObject refresh(final JsonObject request) throws Exception { 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); @@ -1217,6 +1226,13 @@ private JsonObject refresh(final JsonObject request) throws Exception { 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(); @@ -1412,21 +1428,27 @@ private String readConfig() { /** * 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; this - * puts the old spec's back, and every name's slot as {@code slots} - * recorded it before the parse (a definition of the old spec that the - * new one declares a variable would otherwise evaluate as that - * variable). Should a definition still read as a variable, nothing that + * 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 Map slots) { + 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 = slots.get(u); + 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); @@ -1443,12 +1465,19 @@ private void rebindStatics(final Tool old, final Map } } - /** Every interned name's slot in a state or the definition table (-1 for none). */ - private static Map nameSlots() { - final Map out = new java.util.IdentityHashMap<>(); + /** 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.put(u, Math.max(u.getVarLoc(), u.getDefnLoc())); + out.slots.put(u, Math.max(u.getVarLoc(), u.getDefnLoc())); } return out; } @@ -1778,6 +1807,18 @@ void shutdown() { 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; + } } // ─── replies ──────────────────────────────────────────────────────── 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/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/ResidentShutdownCleanupTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java new file mode 100644 index 0000000000..a86fba28da --- /dev/null +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java @@ -0,0 +1,90 @@ +/******************************************************************************* + * 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(); + } + } + + /** 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(0, storeFiles(h)); + assertEquals(0, metadirs(h)); + } +} diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java index 7572397f17..a48a67d5d2 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentSimulateErrorTest.java @@ -57,6 +57,9 @@ public void testEvaluationErrorIsAnError() throws Exception { 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(); } } From d4817015a1f398174680ec21da410b56bef14b82 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 16:07:45 +0000 Subject: [PATCH 26/33] Resident review fixes: path-dependent operators behind config substitutions force a full rerun The TLCGet check before an incremental refresh walked only the syntax reachable from actions, init, constraints and invariants. A definition the config substitutes in (CONSTANT Op <- Def, Op <- [M] Def) is bound as a tool object, so a constraint reading TLCGet("level") through one slipped past: the replay evaluated it without a predecessor, excluded nothing, and ran to its budget (15.8M states where a fresh run finds 6) before the store was adopted. Every substituted definition is now walked too, and TLCExt's Trace and CounterExample, which read the predecessor chain, count as path dependent alongside TLCGet. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Incremental.java | 45 +++++++++++- ...ResidentRefreshTLCGetSubstitutionTest.java | 71 +++++++++++++++++++ .../tlc2/basis/ResidentRefreshTraceTest.java | 64 +++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTLCGetSubstitutionTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentRefreshTraceTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java index f71e8bd3f7..2455b230f1 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Incremental.java @@ -292,13 +292,56 @@ private static String tlcGetUse(final Tool tool) { 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 reached.containsKey("TLC!TLCGet"); + return pathDependent(reached); } /** Claim the first old action of {@code candidates} not yet paired; false when none is left. */ 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/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(); + } +} From 7587f7eec9553581cc3ee2b93c29fd46cfc28e3d Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 16:22:32 +0000 Subject: [PATCH 27/33] Resident review fixes: an evaluation error under continuation is an error, violation traces kept 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, overwriting the error's code. The resident trusted that code: it called the graph exhausted, gave an invariant violated only past the aborted expansion `no_violation_found`, and let a refresh replay the incomplete store. AbstractChecker now keeps every error code it set; any that is not a violation makes the exploration incomplete, `registers` report `stopped_by: error` and `check` report `verdict: error` with the code. The Recorder cleared the open trace on every "behaviour up to this point", so an error's behaviour printed after a violation's overwrote that violation's trace and level. A violation's trace is printed once; a behaviour after it now opens a trace of its own, and only an evaluation failure's reprint replaces what came before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/Recorder.java | 29 ++++- .../src/tlc2/basis/Resident.java | 37 ++++++- .../src/tlc2/tool/AbstractChecker.java | 15 +++ .../basis/ResidentNextStateErrorContinue.java | 101 ++++++++++++++++++ ...tNextStateErrorContinueNoDeadlockTest.java | 34 ++++++ .../ResidentNextStateErrorContinueTest.java | 34 ++++++ 6 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinue.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueNoDeadlockTest.java create mode 100644 tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentNextStateErrorContinueTest.java diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java index 2bc44e1442..0cfc375586 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Recorder.java @@ -76,6 +76,26 @@ 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; @@ -173,7 +193,14 @@ public synchronized void record(final int code, final Object... objects) { // 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. - if (trace != null && !trace.states.isEmpty()) { + // 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; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 89e83b0b3e..48c4714249 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -696,7 +696,19 @@ private JsonObject check(final JsonObject request) throws InterruptedException { reply.addProperty("error", checkerFailure.toString()); } else if (finished) { reply.addProperty("result_code", resultCode); - reply.addProperty("verdict", verdict(resultCode, recorder.outcome())); + 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"); } @@ -906,7 +918,7 @@ private static String verdict(final int code, final int outcome) { */ private boolean explorationComplete() { if (checkerThread == null || checkerThread.isAlive() || checkerFailure != null || resultCode == null - || checker.getStateQueueSize() != 0) { + || checker.getStateQueueSize() != 0 || errorCode() != null) { return false; } switch (resultCode) { @@ -923,6 +935,25 @@ private boolean explorationComplete() { } } + /** + * 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) { @@ -984,7 +1015,7 @@ private JsonObject registers() { final boolean exhausted = finished && explorationComplete(); r.addProperty("finished", finished); r.addProperty("exhausted", exhausted); - r.addProperty("stopped_by", checkerFailure != null ? "error" + 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. 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/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); + } +} From be9b51fde256b6036d6b84e063241fb7b0196901 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 13:29:23 -0400 Subject: [PATCH 28/33] Resident tests: JSON-escape the spec path spliced into requests On Windows the temp directory's path has backslashes, which the tests pasted into request JSON unescaped, so Gson rejected every open with "Invalid escape sequence". ResidentHarness.spec now returns the path escaped for a JSON string literal. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/tlc2/basis/ResidentHarness.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java index 5173be7253..09bd63e203 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentHarness.java @@ -50,8 +50,12 @@ void write(final String name, final String text) throws IOException { Files.write(dir.resolve(name), text.getBytes(StandardCharsets.UTF_8)); } - Path spec(final String module) { - return dir.resolve(module + ".tla"); + /** + * 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. */ From d565c6d38abbc68ec6fa8e17105cd51ba2ec02ab Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 13:43:38 -0400 Subject: [PATCH 29/33] ResidentInitBudgetTest: keep init under the FPSet eviction threshold CI runs tests with OffHeapDiskFPSet and -XX:MaxDirectMemorySize=512k, so the fingerprint set evicts to disk every 65536 states. An eviction during init trips DiskFPSet's checkFile assertion; plain TLC on master fails the same spec about half the time, so this is not the resident's doing. The test only needs init to outlast a 1 ms budget: 50000 initial states do. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/tlc2/basis/ResidentInitBudgetTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java index f28c78e367..5e571dec9e 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentInitBudgetTest.java @@ -43,9 +43,12 @@ public void testBudgetDuringInit() throws Exception { h.write("I.tla", "---- MODULE I ----\n" // + "EXTENDS Naturals\n" // + "VARIABLES x\n" // - + "Init == x \\in 1..400000\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 < 390000\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. From 13390fa5723d4b7410334a57a51362681386a12b Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 13:53:36 -0400 Subject: [PATCH 30/33] GraphStore.dispose: retry the delete when Windows holds the file On Windows another process (a virus scanner, the indexer) can hold a freshly written file open for a moment, so a single delete fails and the store's basis.states outlived the resident's shutdown in CI. Retry briefly, then leave it to deleteOnExit. The cleanup test now names what was left. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 24 +++++++++++++++++-- .../basis/ResidentShutdownCleanupTest.java | 11 +++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index 66343d7755..c74c062e18 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -434,16 +434,36 @@ public synchronized void dispose() { // Deleting below is what matters. } pending.reset(); - file.delete(); + delete(file); final File dir = file.getParentFile(); if (dir != null) { final String[] left = dir.list(); if (left != null && left.length == 0) { - dir.delete(); + 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. + */ + private 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(); diff --git a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java index a86fba28da..f2b52d1870 100644 --- a/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java +++ b/tlatools/org.lamport.tlatools/test/tlc2/basis/ResidentShutdownCleanupTest.java @@ -56,6 +56,13 @@ private static long storeFiles(final ResidentHarness h) throws Exception { } } + /** 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"); @@ -84,7 +91,7 @@ public void testShutdownRemovesStoreFiles() throws Exception { assertTrue(metadirs(h) >= 1); h.resident.shutdown(); - assertEquals(0, storeFiles(h)); - assertEquals(0, metadirs(h)); + assertEquals(left(h), 0, storeFiles(h)); + assertEquals(left(h), 0, metadirs(h)); } } From 4153da6106750b949bb8f9f1fb38e40a20288963 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 14:19:33 -0400 Subject: [PATCH 31/33] customBuild test: create target/GeneratedTESpecs before the JUnit runs Tests pass -teSpecOutDir target/GeneratedTESpecs, and specs with a _TLCTrace POSTCONDITION write their .bin there before TLC creates the directory. Which test got there first used to be an accident of test order; with the resident's test classes added, CodePlexBug08EWD840FL1Test ran first on macOS and its postcondition failed (register 42 null). Co-Authored-By: Claude Opus 5.5 (1M context) --- tlatools/org.lamport.tlatools/customBuild.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tlatools/org.lamport.tlatools/customBuild.xml b/tlatools/org.lamport.tlatools/customBuild.xml index d24e9f77a7..ae4409b1b0 100644 --- a/tlatools/org.lamport.tlatools/customBuild.xml +++ b/tlatools/org.lamport.tlatools/customBuild.xml @@ -505,6 +505,10 @@ Running tests across ${threadLimit} threads + + Date: Thu, 24 Sep 2026 14:29:06 -0400 Subject: [PATCH 32/33] Resident.shutdown: remove the run's metadir too TLC deletes a run's metadir when the run ends, but on Windows a file another process still holds makes that fail, and CI found the session's metadir (state queue and fingerprint files) left in the spec directory. The session created that directory, so shutdown deletes what is left of it with GraphStore's retrying delete, once the checker has stopped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/tlc2/basis/GraphStore.java | 2 +- .../src/tlc2/basis/Resident.java | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java index c74c062e18..be6f6e94b6 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/GraphStore.java @@ -449,7 +449,7 @@ public synchronized void dispose() { * 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. */ - private static void delete(final File f) { + static void delete(final File f) { for (int attempt = 0; attempt < 20; attempt++) { if (f.delete() || !f.exists()) { return; diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 48c4714249..028fa3cb58 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -160,6 +160,8 @@ public final class Resident { */ 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; @@ -366,6 +368,7 @@ private JsonObject open(final JsonObject request) { 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; @@ -426,6 +429,7 @@ private JsonObject openSimulate(final JsonObject request, final File specFile, f 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; @@ -1849,6 +1853,29 @@ void shutdown() { } 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) { + 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); } } From 9b42e240b855a9c9ce80cfc52e9dc95690efc20a Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 24 Sep 2026 14:39:22 -0400 Subject: [PATCH 33/33] Resident.shutdown: close the workers' trace files before deleting the metadir A run's cleanup closes the shared trace but never the workers' own trace files (-.st). The command-line tool exits so nothing notices; the resident outlives its run, and on Windows the still-open C-0.st kept the metadir from being deleted. Worker.closeTrace lets shutdown close them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../org.lamport.tlatools/src/tlc2/basis/Resident.java | 11 +++++++++++ .../org.lamport.tlatools/src/tlc2/tool/Worker.java | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java index 028fa3cb58..3d2f364f97 100644 --- a/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java +++ b/tlatools/org.lamport.tlatools/src/tlc2/basis/Resident.java @@ -1857,6 +1857,17 @@ void shutdown() { // 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(); diff --git a/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java b/tlatools/org.lamport.tlatools/src/tlc2/tool/Worker.java index 0e6ef1b888..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();