From 7b2ef8f9736911be2808e15bb6436c86b672fc98 Mon Sep 17 00:00:00 2001 From: Immad Date: Wed, 29 Jul 2026 18:10:21 -0500 Subject: [PATCH 1/6] Add lazy (on-the-fly) state-graph construction with a unified eager/lazy driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an optional lazy exploration mode for StateGraph.ExploreStateGraph so consumers (e.g. model checking) can build the reachable graph on demand and stop at the first counterexample without materializing a huge — possibly intractable — graph up front. Rather than duplicate the traversal, both eager and lazy exploration are driven by a single StateGraphExpander: * Successor generation is factored into one path-independent kernel (StateGraph.GenerateSuccessors); constraint filtering, depth bounding, node interning, edge de-duplication and pre/post hooks all live in the shared ExpandNode. * The eager driver walks a worklist and stores each node's edges up front; the lazy driver expands a node the first time its Edges are accessed (StateGraphNode.EnsureExpanded). Eager nodes carry no back-reference to the expander so they never self-recompute. * The root->node traversal path and the discovery depth are both read from the node itself, reconstructed from per-node discovery back-pointers (DiscoveredFrom/DiscoveredVia). The path is flattened at most once and memoized (StateGraphNode.Path), and shared structurally across descendants. Consequently a given node is expanded with an identical path and depth in either mode, and neither driver has to thread that context. Adds base-project tests: LazyEagerEquivalenceTests (randomized graphs assert eager and lazy produce identical unbounded graphs / hooks, and that bounded graphs are subgraphs of the unbounded one) and StateGraphPathTests (eager and lazy hand each node the identical, well-formed, prefix-closed path, and the reconstructed path is memoized). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Accordant/StateGraph.cs | 390 +++++++++++------- Accordant/StateGraphExpander.cs | 188 +++++++++ .../LazyEagerEquivalenceTests.cs | 329 +++++++++++++++ Tests/Accordant.Tests/StateGraphPathTests.cs | 246 +++++++++++ 4 files changed, 1010 insertions(+), 143 deletions(-) create mode 100644 Accordant/StateGraphExpander.cs create mode 100644 Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs create mode 100644 Tests/Accordant.Tests/StateGraphPathTests.cs diff --git a/Accordant/StateGraph.cs b/Accordant/StateGraph.cs index 38d2ebc..9a64506 100644 --- a/Accordant/StateGraph.cs +++ b/Accordant/StateGraph.cs @@ -5,7 +5,6 @@ namespace Microsoft.Accordant; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.IO.Hashing; using System.Linq; using System.Security.Cryptography; @@ -36,187 +35,162 @@ public static StateGraphNode ExploreStateGraph( Action hook = null, Action postHook = null, Func stateConstraint = null, - Func shouldIncludeStepFunctionResult = null) + Func shouldIncludeStepFunctionResult = null, + bool lazy = false) { - var processedNodeMap = new Dictionary(); - var nodeMap = new Dictionary(); - var parentChildMap = new Dictionary< - string, - List<(StateGraphNode child, IStepFunction step, object edgeMetadata)>>(); - var stack = new Stack<( - StateGraphNode node, - int depth, - StateGraphNode parent, - IStepFunction parentStep, - object edgeMetadata, - ImmutableList<(IStepFunction, StateGraphNode)> path)>(); - - // Helper method to return a state graph node if it's already been seen, - // or create a new one otherwise. - StateGraphNode GetOrCreateStateGraphNode( - IState state, - IList stepFunctions) + if (lazy && !generateStateGraph) { - var nodeFingerprint = StateGraphNode.GetNodeFingerprint( - state, - stepFunctions); + throw new ArgumentException( + "Lazy exploration builds the graph on demand and therefore requires generateStateGraph = true.", + nameof(generateStateGraph)); + } - if (!nodeMap.ContainsKey(nodeFingerprint)) - { - nodeMap[nodeFingerprint] = new StateGraphNode() - { - State = state, - StepFunctions = stepFunctions - }; - } + var expander = new StateGraphExpander( + maxDepth, + stateConstraint, + shouldIncludeStepFunctionResult, + hook, + postHook, + lazy); - return nodeMap[nodeFingerprint]; + var rootGraphNode = expander.GetOrCreateNode( + startingState, + steps.OrderBy(s => s.StepFunctionId).ToList(), + discoveredFrom: null, + discoveredVia: null, + depth: 1); + + if (lazy) + { + // Return a root whose outgoing edges (and, transitively, the + // whole reachable graph) materialize on demand as the graph is + // walked — e.g. by the model-checking emptiness search, which + // then stops at the first counterexample without ever building + // the unreached remainder of the graph. + return rootGraphNode; } - var rootGraphNode = GetOrCreateStateGraphNode( - startingState, - steps.OrderBy(s => s.StepFunctionId).ToList()); + // Eager exploration: drive the same per-node expansion over a + // worklist so every reachable node is materialized up front. Each + // node is expanded at most once (its first pop); the resulting edges + // are stored on the node and their targets are queued for expansion. + // Both the traversal path and the depth handed to expansion are read + // from each node (reconstructed from its discovery back-pointers), + // exactly as in lazy mode, so the worklist carries only the nodes. + var processed = new HashSet(); + var stack = new Stack(); - stack.Push(( - rootGraphNode, - 1, - null, - null, - null, - ImmutableList<(IStepFunction, StateGraphNode)>.Empty.Add((null, rootGraphNode)))); + stack.Push(rootGraphNode); while (stack.Count > 0) { - var (node, depth, parent, parentStep, edgeMetadata, path) = stack.Pop(); + var node = stack.Pop(); - if (maxDepth != -1 && depth > maxDepth) + if (!processed.Add(node.GetNodeFingerprint())) { continue; } - if (stateConstraint != null && !stateConstraint(node.State)) + var edges = expander.ExpandNode(node); + + if (generateStateGraph) { - continue; + node.SetExpandedEdges(edges); } - if (generateStateGraph && parent != null) + foreach (var edge in edges) { - var parentFingerprint = parent.GetNodeFingerprint(); - - if (!parentChildMap.ContainsKey(parentFingerprint)) + var child = edge.Target; + if (!processed.Contains(child.GetNodeFingerprint())) { - parentChildMap[parentFingerprint] = - new List<(StateGraphNode child, IStepFunction step, object edgeMetadata)>(); + stack.Push(child); } - - parentChildMap[parentFingerprint].Add((node, parentStep, edgeMetadata)); } + } - var fingerprint = node.GetNodeFingerprint(); - if (processedNodeMap.ContainsKey(fingerprint)) - { - continue; - } + return generateStateGraph ? + rootGraphNode : + null; + } - processedNodeMap[fingerprint] = node; + /// + /// Generates the raw successors of a node — the single source of truth + /// for successor generation shared by eager + /// () and lazy + /// () construction. + /// + /// For each step function attached to the node it invokes + /// (wrapping failures in a + /// ), skips empty results, + /// applies the optional + /// filter, and computes the successor's step-function set (the current + /// step consumed, any newly-produced steps added, ordered by id). + /// + /// It deliberately does not apply the + /// stateConstraint or maxDepth bounds, perform node + /// interning, or build edges: those differ between the eager and lazy + /// drivers and remain each driver's responsibility. Keeping only the + /// path-independent per-step logic here guarantees the two drivers can + /// never silently diverge on how a successor state and its step-function + /// set are derived. + /// + internal static IEnumerable<( + IStepFunction stepFunction, + IState childState, + IList childStepFunctions, + object edgeMetadata)> GenerateSuccessors( + StateGraphNode node, + IReadOnlyList<(IStepFunction, StateGraphNode)> path, + Func shouldIncludeStepFunctionResult) + { + var state = node.State; + var stepFunctions = node.StepFunctions; - var state = node.State; - var stepFunctions = node.StepFunctions; + foreach (var stepFunction in stepFunctions) + { + IList stepResults; - if (hook != null) + try { - hook(node); + stepResults = stepFunction.Apply(state, path); } - - foreach (var stepFunction in stepFunctions) + catch (Exception ex) { - IList stepResults; - - try - { - stepResults = stepFunction.Apply(state, path); - } - catch (Exception ex) - { - throw new StepFunctionApplicationException( - ex, - node, - path, - stepFunction); - } - - if (stepResults == null || stepResults.Count == 0) - { - continue; - } - - foreach (var stepResult in stepResults) - { - if (shouldIncludeStepFunctionResult != null && - !shouldIncludeStepFunctionResult( - state, - stepFunction, - stepResult)) - { - continue; - } - - var newStepFunctions = stepFunctions.Where(s => s.StepFunctionId != stepFunction.StepFunctionId).ToList(); - if (stepResult.StepFunctions != null) - { - newStepFunctions.AddRange(stepResult.StepFunctions); - } - - var nextGraphNode = GetOrCreateStateGraphNode( - stepResult.State, - newStepFunctions.OrderBy(s => s.StepFunctionId).ToList()); - - stack.Push(( - nextGraphNode, - depth + 1, - node, - stepFunction, - stepResult.EdgeMetadata, - path.Add((stepFunction, nextGraphNode)))); - } + throw new StepFunctionApplicationException( + ex, + node, + path.ToList(), + stepFunction); } - if (postHook != null) + if (stepResults == null || stepResults.Count == 0) { - postHook(node); + continue; } - } - if (generateStateGraph) - { - foreach (var kvp in parentChildMap) + foreach (var stepResult in stepResults) { - var parentFingerprint = kvp.Key; - var parent = processedNodeMap[parentFingerprint]; + if (shouldIncludeStepFunctionResult != null && + !shouldIncludeStepFunctionResult(state, stepFunction, stepResult)) + { + continue; + } - foreach (var (child, step, edgeMetadata) in kvp.Value) + var newStepFunctions = stepFunctions + .Where(s => s.StepFunctionId != stepFunction.StepFunctionId) + .ToList(); + if (stepResult.StepFunctions != null) { - var childFingerprint = child.GetNodeFingerprint(); - var exists = parent.Edges.Any(e => - e.StepFunction.StepFunctionId == step.StepFunctionId && - e.Target.GetNodeFingerprint() == childFingerprint); - - if (!exists) - { - parent.Edges.Add(new StateGraphEdge() - { - StepFunction = step, - Target = child, - Metadata = edgeMetadata - }); - } + newStepFunctions.AddRange(stepResult.StepFunctions); } + + yield return ( + stepFunction, + stepResult.State, + newStepFunctions.OrderBy(s => s.StepFunctionId).ToList(), + stepResult.EdgeMetadata); } } - - return generateStateGraph ? - rootGraphNode : - null; } } @@ -244,10 +218,140 @@ public class StateGraphNode /// public IList StepFunctions { get; set; } + private List edges = new List(); + + private bool expanded; + + /// + /// The expander that lazily computes this node's outgoing edges the + /// first time is accessed. Non-null only for + /// nodes produced by lazy (on-the-fly) exploration + /// (StateGraph.ExploreStateGraph(..., lazy: true)). For eagerly + /// explored or manually constructed nodes this is null and the + /// lazy machinery is inert — behaves as a plain list. + /// + internal StateGraphExpander Expander { get; set; } + + /// + /// Discovery depth of this node (root = 1), set once when the node is + /// first created. Both eager and lazy expansion read it to create + /// successors at Depth + 1 and to honor the construction-time + /// maxDepth bound. + /// + internal int Depth { get; set; } + + /// + /// The node from which this node was first discovered (its parent in the + /// discovery tree), or null for the root. Together with + /// this forms an immutable, prefix-shared + /// chain from any node back to the root — the single source of truth for + /// the traversal , used identically by eager and lazy + /// exploration. Set exactly once, when the node is first created. + /// + internal StateGraphNode DiscoveredFrom { get; set; } + + /// + /// The step function whose edge first reached this node, or null + /// for the root. See . + /// + internal IStepFunction DiscoveredVia { get; set; } + + private IReadOnlyList<(IStepFunction, StateGraphNode)> materializedPath; + + /// + /// The root→this traversal path handed to step functions during + /// expansion, in the shape [(null, root), …, (DiscoveredVia, this)] + /// (root first, this node last). + /// + /// The path is not stored eagerly: it is reconstructed on + /// demand by walking the / + /// back-pointer chain — which is O(1) memory per node and structurally + /// shared across descendants — and then flattened once and memoized here, + /// so repeat readers never recompute. Eager and lazy exploration derive + /// the path the same way, so a given node is handed an identical path in + /// either mode. + /// + /// This is the discovery witness path: interning means a + /// node is expanded at most once, so exactly one of the (possibly many) + /// root→node paths is materialized. Because successor generation is + /// path-independent, the path only feeds step functions that read history + /// (e.g. to prune), never the computed successor set. + /// + internal IReadOnlyList<(IStepFunction, StateGraphNode)> Path + { + get + { + if (materializedPath == null) + { + var reversed = new List<(IStepFunction, StateGraphNode)>(); + for (var node = this; node != null; node = node.DiscoveredFrom) + { + reversed.Add((node.DiscoveredVia, node)); + } + + reversed.Reverse(); + materializedPath = reversed; + } + + return materializedPath; + } + } + /// /// Edges which lead to the outgoing set of state graph nodes. + /// + /// For lazily explored nodes the outgoing edges are computed and + /// memoized on first access via . This makes + /// on-the-fly model checking transparent to every consumer that walks + /// the graph through (emptiness checkers, dot + /// visualization, BFS traversals) — the graph materializes only as far + /// as it is actually walked. + /// + public List Edges + { + get + { + EnsureExpanded(); + return edges; + } + + set => edges = value; + } + + /// + /// Ensures this node's outgoing edges have been computed. A no-op for + /// eager / manually built nodes ( is null) + /// and idempotent for lazy nodes (expansion runs at most once). + /// + internal void EnsureExpanded() + { + if (expanded) + { + return; + } + + // Mark expanded before invoking the expander so that any re-entrant + // access to this node's Edges during expansion returns the + // (currently empty) backing list rather than recursing. + expanded = true; + + if (Expander != null) + { + edges = Expander.ComputeEdges(this); + } + } + + /// + /// Stores the eagerly-computed outgoing edges for this node and marks it + /// expanded so that a later access is a no-op rather + /// than triggering (re)computation. Used by the eager explorer, whose + /// worklist has already produced the node's edges. /// - public List Edges { get; set; } = new List(); + internal void SetExpandedEdges(List computedEdges) + { + edges = computedEdges; + expanded = true; + } /// /// Returns the node fingerprint which is a hash computed over the state hash diff --git a/Accordant/StateGraphExpander.cs b/Accordant/StateGraphExpander.cs new file mode 100644 index 0000000..c530198 --- /dev/null +++ b/Accordant/StateGraphExpander.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Accordant; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// The single driver for state-graph construction, shared by both the eager +/// and the lazy (on-the-fly) exploration modes so that the two apply the +/// state constraint, depth bound, node interning, edge de-duplication and +/// pre/post hooks in exactly the same way. Every node — in either mode — is +/// expanded through . +/// +/// The modes differ in only three, deliberate, ways: +/// +/// Who drives expansion. The eager explorer +/// () walks a worklist and expands +/// every reachable node up front. The lazy driver expands a node the first +/// time its are accessed +/// (), which is what lets model +/// checking stop at the first counterexample without materializing the +/// unreached remainder of the graph. +/// The traversal path handed to step functions. Both modes +/// hand step functions the same reconstructed root→node +/// (walked from the node's discovery +/// back-pointers), so a given node is expanded with an identical path in +/// either mode. Successor generation is path-independent for model +/// programs; the path only feeds step functions that read history. +/// Whether created nodes self-expand. Lazy nodes carry a back +/// reference to this expander () so +/// their edges materialize on demand; eager nodes do not, since the worklist +/// has already computed and stored their edges. +/// +/// +internal sealed class StateGraphExpander +{ + private readonly int maxDepth; + private readonly Func stateConstraint; + private readonly Func shouldIncludeStepFunctionResult; + private readonly Action hook; + private readonly Action postHook; + private readonly bool lazy; + + // Shared fingerprint -> node intern map. Guarantees that two paths + // reaching the same (state, step-functions) fingerprint resolve to the + // same node object, so a state's edges are computed at most once and the + // graph stays a proper DAG-with-cycles. + private readonly Dictionary nodeMap = + new Dictionary(); + + public StateGraphExpander( + int maxDepth, + Func stateConstraint, + Func shouldIncludeStepFunctionResult, + Action hook, + Action postHook, + bool lazy) + { + this.maxDepth = maxDepth; + this.stateConstraint = stateConstraint; + this.shouldIncludeStepFunctionResult = shouldIncludeStepFunctionResult; + this.hook = hook; + this.postHook = postHook; + this.lazy = lazy; + } + + /// + /// Returns the interned node for a given (state, step-functions) pair, + /// creating a fresh node if it has not been seen before. A newly created + /// node records the parent and edge it was first reached through + /// (/) + /// so its traversal can be reconstructed + /// on demand. In lazy mode the new node is bound to this expander so it + /// expands on first access; in eager + /// mode it is left unbound because the worklist expands it directly. An + /// existing node keeps its original discovery back-pointer and + /// . + /// + internal StateGraphNode GetOrCreateNode( + IState state, + IList stepFunctions, + StateGraphNode discoveredFrom, + IStepFunction discoveredVia, + int depth) + { + var fingerprint = StateGraphNode.GetNodeFingerprint(state, stepFunctions); + + if (!nodeMap.TryGetValue(fingerprint, out var node)) + { + node = new StateGraphNode + { + State = state, + StepFunctions = stepFunctions, + Expander = this.lazy ? this : null, + DiscoveredFrom = discoveredFrom, + DiscoveredVia = discoveredVia, + Depth = depth + }; + + nodeMap[fingerprint] = node; + } + + return node; + } + + /// + /// The lazy on-demand entry point, invoked from + /// the first time a node's + /// edges are accessed. + /// + internal List ComputeEdges(StateGraphNode node) + => ExpandNode(node); + + /// + /// Expands a single node — the one place both modes compute a node's + /// outgoing edges. A node failing the state constraint contributes no + /// edges and fires no hook. Otherwise the pre-hook runs, successors are + /// drawn from the path-independent + /// kernel (handed the node's + /// reconstructed root→node ), each + /// successor is filtered by the state constraint and the depth bound, the + /// surviving target states are interned — recording the discovery + /// back-pointer and depth — and recorded as de-duplicated edges, and the + /// post-hook runs. + /// + /// Both the traversal path and the depth are read from the node + /// itself ( / ), + /// set once at discovery, so eager and lazy expand a given node + /// identically without either driver having to thread that context. + /// + /// The node to expand; successors are created at + /// + 1. + internal List ExpandNode(StateGraphNode node) + { + var edges = new List(); + + // A node failing the state constraint is treated as having no + // successors and is not hooked. + if (stateConstraint != null && !stateConstraint(node.State)) + { + return edges; + } + + hook?.Invoke(node); + + var childDepth = node.Depth + 1; + var withinDepth = maxDepth == -1 || childDepth <= maxDepth; + + foreach (var (stepFunction, childState, childStepFunctions, edgeMetadata) in + StateGraph.GenerateSuccessors(node, node.Path, shouldIncludeStepFunctionResult)) + { + // Drop edges leading to constraint-violating states or beyond the + // depth bound; such targets never become nodes or edges. + if (stateConstraint != null && !stateConstraint(childState)) + { + continue; + } + + if (!withinDepth) + { + continue; + } + + var child = GetOrCreateNode(childState, childStepFunctions, node, stepFunction, childDepth); + + var childFingerprint = child.GetNodeFingerprint(); + var alreadyPresent = edges.Any(e => + e.StepFunction.StepFunctionId == stepFunction.StepFunctionId && + e.Target.GetNodeFingerprint() == childFingerprint); + + if (!alreadyPresent) + { + edges.Add(new StateGraphEdge + { + StepFunction = stepFunction, + Target = child, + Metadata = edgeMetadata + }); + } + } + + postHook?.Invoke(node); + return edges; + } +} diff --git a/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs b/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs new file mode 100644 index 0000000..ea16914 --- /dev/null +++ b/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Accordant.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Accordant; +using NUnit.Framework; + +/// +/// Property-based (randomized/generative) tests asserting that eager and +/// lazy state-graph construction are equivalent — they now share a single +/// successor kernel (StateGraph.GenerateSuccessors), so any divergence +/// is a bug. These tests exercise only the core graph-construction contract +/// (nodes, edges, hooks, constraints, depth bounds); equivalence of the +/// model-checking verdicts built on top of the graph is covered separately +/// in the model-checking test project. +/// +/// The invariants asserted, and the one place the two may +/// legitimately differ: +/// +/// Unbounded (maxDepth == -1): the eager and lazy graphs are +/// identical as a set of nodes and a set of edges, and they hook exactly the +/// same set of nodes. +/// Bounded (maxDepth >= 0): depth is memoized at first +/// discovery, and discovery order differs (eager DFS stack vs. lazy +/// consumer-driven walk), so the two truncated graphs may genuinely differ. +/// We therefore assert only the order-independent invariant that each +/// bounded graph is a subgraph of the unbounded one — not that they equal +/// each other. +/// +/// +[TestFixture] +public class LazyEagerEquivalenceTests +{ + private const int Seeds = 200; + + #region Randomized model + + private sealed class VectorState : State + { + public int[] Values { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + => clonedMap[this] = new VectorState { Values = (int[])this.Values.Clone() }; + + protected override string StringRepresentationInternal( + Dictionary objectPaths, string path, bool forceRecompute) + => "V=[" + string.Join(",", this.Values) + "]"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + // Re-adds itself: moves one component by +/-1 while it stays in [0, bound). + private sealed class MoveStep : BaseStepFunction + { + private readonly string id; + private readonly int component; + private readonly int delta; + private readonly int bound; + + public MoveStep(string id, int component, int delta, int bound) + { + this.id = id; + this.component = component; + this.delta = delta; + this.bound = bound; + } + + public override string StepFunctionId => this.id; + + protected override IList ApplyInternal(IState state) + { + var s = (VectorState)state; + var v = s.Values[this.component] + this.delta; + if (v < 0 || v >= this.bound) return null; + var next = (VectorState)s.Clone(); + next.Values[this.component] = v; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + // Re-adds itself, but branches non-deterministically: emits both the +1 + // and -1 in-bounds successors of a component in a single application. + private sealed class SpawnStep : BaseStepFunction + { + private readonly string id; + private readonly int component; + private readonly int bound; + + public SpawnStep(string id, int component, int bound) + { + this.id = id; + this.component = component; + this.bound = bound; + } + + public override string StepFunctionId => this.id; + + protected override IList ApplyInternal(IState state) + { + var s = (VectorState)state; + var results = new List(); + foreach (var delta in new[] { +1, -1 }) + { + var v = s.Values[this.component] + delta; + if (v < 0 || v >= this.bound) continue; + var next = (VectorState)s.Clone(); + next.Values[this.component] = v; + results.Add(new StepResult { State = next, StepFunctions = new IStepFunction[] { this } }); + } + + return results.Count == 0 ? null : results; + } + } + + // Fires once when a component hits a target value, then consumes itself + // (adds no step functions), so downstream nodes carry a smaller + // step-function set. Exercises the successor step-set computation. + private sealed class ConsumeStep : BaseStepFunction + { + private readonly string id; + private readonly int component; + private readonly int target; + + public ConsumeStep(string id, int component, int target) + { + this.id = id; + this.component = component; + this.target = target; + } + + public override string StepFunctionId => this.id; + + protected override IList ApplyInternal(IState state) + { + var s = (VectorState)state; + if (s.Values[this.component] != this.target) return null; + var next = (VectorState)s.Clone(); + // No StepFunctions -> this step is consumed at the successor node. + return new[] { new StepResult { State = next, StepFunctions = Array.Empty() } }; + } + } + + private static (IStepFunction[] steps, VectorState start) BuildModel(Random rnd) + { + var dims = rnd.Next(1, 3); // 1..2 + var bound = rnd.Next(2, 4); // 2..3 + var numSteps = rnd.Next(2, 5); // 2..4 + + var steps = new List(); + var hasMovement = false; + for (var i = 0; i < numSteps; i++) + { + var id = "s" + i; + var component = rnd.Next(0, dims); + switch (rnd.Next(0, 3)) + { + case 0: + steps.Add(new MoveStep(id, component, rnd.Next(0, 2) == 0 ? +1 : -1, bound)); + hasMovement = true; + break; + case 1: + steps.Add(new SpawnStep(id, component, bound)); + hasMovement = true; + break; + default: + steps.Add(new ConsumeStep(id, component, rnd.Next(0, bound))); + break; + } + } + + // Guarantee a non-trivial graph. + if (!hasMovement) + { + steps.Add(new MoveStep("sMove", 0, +1, bound)); + } + + return (steps.ToArray(), new VectorState { Values = new int[dims] }); + } + + #endregion + + #region Graph signatures + + // Fingerprints of every node reachable from the root by walking Edges. + private static HashSet NodeSet(StateGraphNode root) + { + var seen = new HashSet(); + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var n = stack.Pop(); + if (!seen.Add(n.GetNodeFingerprint())) continue; + foreach (var e in n.Edges) stack.Push(e.Target); + } + + return seen; + } + + // "source|stepId|target" for every edge reachable from the root. + private static HashSet EdgeSet(StateGraphNode root) + { + var seenNodes = new HashSet(); + var edges = new HashSet(); + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var n = stack.Pop(); + var nf = n.GetNodeFingerprint(); + if (!seenNodes.Add(nf)) continue; + foreach (var e in n.Edges) + { + edges.Add(nf + "|" + e.StepFunction.StepFunctionId + "|" + e.Target.GetNodeFingerprint()); + stack.Push(e.Target); + } + } + + return edges; + } + + #endregion + + [Test] + public void EagerAndLazy_ProduceIdenticalGraphs_WhenUnbounded() + { + for (var seed = 0; seed < Seeds; seed++) + { + var (steps, start) = BuildModel(new Random(seed)); + + var eagerHooks = new HashSet(); + var eagerRoot = StateGraph.ExploreStateGraph( + steps, (VectorState)start.Clone(), + hook: n => eagerHooks.Add(n.GetNodeFingerprint())); + + var lazyHooks = new HashSet(); + var lazyRoot = StateGraph.ExploreStateGraph( + steps, (VectorState)start.Clone(), + hook: n => lazyHooks.Add(n.GetNodeFingerprint()), + lazy: true); + + var eagerNodes = NodeSet(eagerRoot); + var lazyNodes = NodeSet(lazyRoot); // full walk materializes lazy graph + var eagerEdges = EdgeSet(eagerRoot); + var lazyEdges = EdgeSet(lazyRoot); + + Assert.That(lazyNodes.SetEquals(eagerNodes), Is.True, + $"seed {seed}: node sets differ (eager={eagerNodes.Count}, lazy={lazyNodes.Count})"); + Assert.That(lazyEdges.SetEquals(eagerEdges), Is.True, + $"seed {seed}: edge sets differ (eager={eagerEdges.Count}, lazy={lazyEdges.Count})"); + + // Hook alignment: a full walk of the lazy graph fires the hook on + // exactly the same set of nodes the eager explorer hooks. + Assert.That(lazyHooks.SetEquals(eagerHooks), Is.True, + $"seed {seed}: hooked node sets differ"); + Assert.That(eagerHooks.SetEquals(eagerNodes), Is.True, + $"seed {seed}: eager should hook every reachable node exactly once"); + } + } + + [Test] + public void BoundedGraphs_AreSubgraphsOfUnbounded_ForBothDrivers() + { + for (var seed = 0; seed < Seeds; seed++) + { + var rnd = new Random(seed); + var (steps, start) = BuildModel(rnd); + var maxDepth = rnd.Next(1, 6); + + var unboundedNodes = NodeSet( + StateGraph.ExploreStateGraph(steps, (VectorState)start.Clone())); + var unboundedEdges = EdgeSet( + StateGraph.ExploreStateGraph(steps, (VectorState)start.Clone())); + + foreach (var lazy in new[] { false, true }) + { + var root = StateGraph.ExploreStateGraph( + steps, (VectorState)start.Clone(), maxDepth: maxDepth, lazy: lazy); + + var boundedNodes = NodeSet(root); + var boundedEdges = EdgeSet(root); + + Assert.That(boundedNodes.IsSubsetOf(unboundedNodes), Is.True, + $"seed {seed} lazy={lazy}: bounded nodes must be a subset of the unbounded graph"); + Assert.That(boundedEdges.IsSubsetOf(unboundedEdges), Is.True, + $"seed {seed} lazy={lazy}: bounded edges must be a subset of the unbounded graph"); + } + } + } + + [Test] + public void ConstraintFailingRoot_FiresNoHooks_InBothDrivers() + { + var steps = new IStepFunction[] { new MoveStep("s0", 0, +1, 3) }; + Func rejectAll = _ => false; + + var eagerHooks = new List(); + var eagerPostHooks = new List(); + StateGraph.ExploreStateGraph( + steps, new VectorState { Values = new int[1] }, + hook: n => eagerHooks.Add(n.GetNodeFingerprint()), + postHook: n => eagerPostHooks.Add(n.GetNodeFingerprint()), + stateConstraint: rejectAll); + + var lazyHooks = new List(); + var lazyPostHooks = new List(); + var lazyRoot = StateGraph.ExploreStateGraph( + steps, new VectorState { Values = new int[1] }, + hook: n => lazyHooks.Add(n.GetNodeFingerprint()), + postHook: n => lazyPostHooks.Add(n.GetNodeFingerprint()), + stateConstraint: rejectAll, + lazy: true); + + // Force lazy expansion of the (constraint-failing) root. + var edges = lazyRoot.Edges; + + Assert.That(edges, Is.Empty, "constraint-failing root has no successors"); + Assert.That(eagerHooks, Is.Empty, "eager must not hook a constraint-failing node"); + Assert.That(eagerPostHooks, Is.Empty, "eager must not post-hook a constraint-failing node"); + Assert.That(lazyHooks, Is.Empty, "lazy must not hook a constraint-failing node"); + Assert.That(lazyPostHooks, Is.Empty, "lazy must not post-hook a constraint-failing node"); + } +} diff --git a/Tests/Accordant.Tests/StateGraphPathTests.cs b/Tests/Accordant.Tests/StateGraphPathTests.cs new file mode 100644 index 0000000..8a0361b --- /dev/null +++ b/Tests/Accordant.Tests/StateGraphPathTests.cs @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Accordant.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Accordant; +using NUnit.Framework; + +/// +/// Tests for the root→node traversal path that state-graph +/// construction hands to each step function's +/// . The path is reconstructed on demand +/// from each node's discovery back-pointers, so: +/// +/// eager and lazy exploration hand every node an identical +/// path (the same discovery witness), even when a node is reachable by +/// several routes (a diamond); +/// the path is well formed — root first with a null incoming step, +/// this node last, and every interior element carrying a real step; and +/// a child's path is exactly its discovery parent's path plus the child +/// (the "parent's path + this node" construction). +/// +/// A separate check confirms the reconstructed path is memoized (repeat reads +/// return the same instance rather than recomputing). +/// +[TestFixture] +public class StateGraphPathTests +{ + #region Diamond model + + // Two independent bits, each flippable 0 -> 1 exactly once. From (0,0) + // the graph is a diamond: (1,1) is reachable both via A-then-B and + // B-then-A, so exactly one of those becomes its discovery witness path. + private sealed class BitState : State + { + public int A { get; set; } + + public int B { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + => clonedMap[this] = new BitState { A = this.A, B = this.B }; + + protected override string StringRepresentationInternal( + Dictionary objectPaths, string path, bool forceRecompute) + => $"({this.A},{this.B})"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + // Flips one bit 0 -> 1, re-adding itself so the step set is stable. + // Records the path it was handed, keyed by the expanding node, into the + // currently-installed sink. Returns null once the bit is already 1. + private sealed class FlipStep : BaseStepFunction + { + private readonly string id; + private readonly bool flipA; + + public FlipStep(string id, bool flipA) + { + this.id = id; + this.flipA = flipA; + } + + public override string StepFunctionId => this.id; + + // The map recording, per expanding node, the path handed to Apply. + public Dictionary> Sink { get; set; } + + protected override IList ApplyInternal( + IState state, + IReadOnlyList<(IStepFunction, StateGraphNode)> path) + { + // The node currently being expanded is the last path element. + var nodeFingerprint = path[path.Count - 1].Item2.GetNodeFingerprint(); + if (!this.Sink.ContainsKey(nodeFingerprint)) + { + this.Sink[nodeFingerprint] = path; + } + + var s = (BitState)state; + var bitIsSet = this.flipA ? s.A == 1 : s.B == 1; + if (bitIsSet) + { + return null; + } + + var next = (BitState)s.Clone(); + if (this.flipA) + { + next.A = 1; + } + else + { + next.B = 1; + } + + return new[] + { + new StepResult { State = next, StepFunctions = new IStepFunction[] { this } }, + }; + } + } + + #endregion + + private static string Sig(IReadOnlyList<(IStepFunction, StateGraphNode)> path) + => string.Join( + " -> ", + path.Select(e => (e.Item1?.StepFunctionId ?? "ε") + ":" + e.Item2.GetNodeFingerprint())); + + // Walk the whole graph so every node's edges (and, in lazy mode, its + // on-demand expansion) are materialized, firing the recording steps. + private static void MaterializeAll(StateGraphNode root) + { + var seen = new HashSet(); + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var n = stack.Pop(); + if (!seen.Add(n.GetNodeFingerprint())) + { + continue; + } + + foreach (var e in n.Edges) + { + stack.Push(e.Target); + } + } + } + + private static Dictionary> RecordPaths( + FlipStep[] steps, BitState start, bool lazy, out StateGraphNode root) + { + var sink = new Dictionary>(); + foreach (var s in steps) + { + s.Sink = sink; + } + + root = StateGraph.ExploreStateGraph(steps, (BitState)start.Clone(), lazy: lazy); + MaterializeAll(root); + return sink; + } + + [Test] + public void EagerAndLazy_HandEachNode_TheIdenticalPath() + { + // Shared step instances so the two runs order steps identically and + // therefore discover nodes in the same order. + var steps = new[] { new FlipStep("A", flipA: true), new FlipStep("B", flipA: false) }; + var start = new BitState { A = 0, B = 0 }; + + var eager = RecordPaths(steps, start, lazy: false, out var eagerRoot); + var lazy = RecordPaths(steps, start, lazy: true, out _); + + // Sanity: the diamond really merges — all four bit combinations are + // reached, including the doubly-reachable (1,1). + Assert.That(eager.Count, Is.EqualTo(4), "expected the 4-node diamond to be fully explored"); + + Assert.That(lazy.Keys.ToHashSet().SetEquals(eager.Keys), Is.True, + "eager and lazy must expand (and record a path for) the same set of nodes"); + + foreach (var key in eager.Keys) + { + Assert.That(Sig(lazy[key]), Is.EqualTo(Sig(eager[key])), + $"eager and lazy handed node {key} different paths"); + } + } + + [Test] + public void ReconstructedPaths_AreWellFormed_AndPrefixClosed() + { + var steps = new[] { new FlipStep("A", flipA: true), new FlipStep("B", flipA: false) }; + var start = new BitState { A = 0, B = 0 }; + + var paths = RecordPaths(steps, start, lazy: false, out var root); + var rootFingerprint = root.GetNodeFingerprint(); + + foreach (var (key, path) in paths.Select(kv => (kv.Key, kv.Value))) + { + Assert.That(path.Count, Is.GreaterThanOrEqualTo(1), "a path is never empty"); + + // Root first, with no incoming step. + Assert.That(path[0].Item1, Is.Null, "the first path element (root) has no incoming step"); + Assert.That(path[0].Item2.GetNodeFingerprint(), Is.EqualTo(rootFingerprint), + "every path starts at the root"); + + // This node last. + Assert.That(path[path.Count - 1].Item2.GetNodeFingerprint(), Is.EqualTo(key), + "a path ends at the node it belongs to"); + + // Only the root element lacks an incoming step. + for (var i = 1; i < path.Count; i++) + { + Assert.That(path[i].Item1, Is.Not.Null, + "every non-root path element carries the step that reached it"); + } + + // Prefix closure: child path == discovery-parent path + child. + if (path.Count >= 2) + { + var parentFingerprint = path[path.Count - 2].Item2.GetNodeFingerprint(); + Assert.That(paths.ContainsKey(parentFingerprint), Is.True, + "the discovery parent must itself have been expanded"); + + var expectedParentSig = Sig(path.Take(path.Count - 1).ToList()); + Assert.That(Sig(paths[parentFingerprint]), Is.EqualTo(expectedParentSig), + "a node's path must be exactly its discovery parent's path plus itself"); + } + } + } + + [Test] + public void ReconstructedPath_IsMemoized_SoRepeatReadsDoNotRecompute() + { + var steps = new[] { new FlipStep("A", flipA: true), new FlipStep("B", flipA: false) }; + foreach (var s in steps) + { + s.Sink = new Dictionary>(); + } + + var root = StateGraph.ExploreStateGraph(steps, new BitState { A = 0, B = 0 }); + + // Path is an internal member; read it twice via reflection and assert + // the same instance comes back (i.e. it is flattened at most once). + var pathProperty = typeof(StateGraphNode).GetProperty( + "Path", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(pathProperty, Is.Not.Null, "StateGraphNode.Path should exist"); + + var target = root.Edges[0].Target; + var first = pathProperty.GetValue(target); + var second = pathProperty.GetValue(target); + + Assert.That(first, Is.Not.Null); + Assert.That(ReferenceEquals(first, second), Is.True, + "the reconstructed path must be memoized and returned by reference"); + } +} From 8986a1baec11963b37094b45262e503af8e18932 Mon Sep 17 00:00:00 2001 From: Immad Date: Wed, 5 Aug 2026 16:25:58 -0500 Subject: [PATCH 2/6] Rename Expander to LazyExpander and guard the eager/lazy invariant Clarify the subtle eager/lazy expansion contract on StateGraphNode: - Rename the node's Expander property to LazyExpander, documenting that null means eager (edges pre-computed and stored by the worklist) and non-null means lazy (edges materialize on first access). - Remove the ComputeEdges wrapper so EnsureExpanded calls ExpandNode directly, eliminating a layer of indirection. - Add an explicit, Release-surviving guard in SetExpandedEdges that throws if a lazy-bound node is expanded eagerly, making the previously implicit invariant fail loudly instead of silently re-expanding with an empty path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Accordant/StateGraph.cs | 37 ++++++++++++++++++++++++--------- Accordant/StateGraphExpander.cs | 17 ++++++--------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Accordant/StateGraph.cs b/Accordant/StateGraph.cs index 9a64506..b2b9114 100644 --- a/Accordant/StateGraph.cs +++ b/Accordant/StateGraph.cs @@ -223,14 +223,20 @@ public class StateGraphNode private bool expanded; /// - /// The expander that lazily computes this node's outgoing edges the - /// first time is accessed. Non-null only for - /// nodes produced by lazy (on-the-fly) exploration - /// (StateGraph.ExploreStateGraph(..., lazy: true)). For eagerly - /// explored or manually constructed nodes this is null and the - /// lazy machinery is inert — behaves as a plain list. + /// The expander bound to this node in lazy (on-the-fly) exploration, which + /// computes the node's outgoing edges the first time is + /// accessed. This is the single flag distinguishing the two modes: + /// + /// null ⇒ an eager (or manually constructed) node. The + /// eager worklist has already computed and stored its edges via + /// , so the lazy machinery is inert and + /// behaves as a plain list. + /// non-null ⇒ a lazy node + /// (StateGraph.ExploreStateGraph(..., lazy: true)) that materializes + /// its edges on first access. + /// /// - internal StateGraphExpander Expander { get; set; } + internal StateGraphExpander LazyExpander { get; set; } /// /// Discovery depth of this node (root = 1), set once when the node is @@ -320,7 +326,7 @@ public List Edges /// /// Ensures this node's outgoing edges have been computed. A no-op for - /// eager / manually built nodes ( is null) + /// eager / manually built nodes ( is null) /// and idempotent for lazy nodes (expansion runs at most once). /// internal void EnsureExpanded() @@ -335,9 +341,9 @@ internal void EnsureExpanded() // (currently empty) backing list rather than recursing. expanded = true; - if (Expander != null) + if (LazyExpander != null) { - edges = Expander.ComputeEdges(this); + edges = LazyExpander.ExpandNode(this); } } @@ -349,6 +355,17 @@ internal void EnsureExpanded() /// internal void SetExpandedEdges(List computedEdges) { + // Eager and lazy are mutually exclusive per node: an eager node must + // never be lazy-bound, or a later Edges access would re-expand it + // (with an empty path) and overwrite these edges. Guard explicitly so + // the invariant fails loudly in every build, not just DEBUG. + if (LazyExpander != null) + { + throw new InvalidOperationException( + "SetExpandedEdges is the eager store path and must not be called on a " + + "lazy-bound node (LazyExpander != null)."); + } + edges = computedEdges; expanded = true; } diff --git a/Accordant/StateGraphExpander.cs b/Accordant/StateGraphExpander.cs index c530198..91040c3 100644 --- a/Accordant/StateGraphExpander.cs +++ b/Accordant/StateGraphExpander.cs @@ -30,7 +30,7 @@ namespace Microsoft.Accordant; /// either mode. Successor generation is path-independent for model /// programs; the path only feeds step functions that read history. /// Whether created nodes self-expand. Lazy nodes carry a back -/// reference to this expander () so +/// reference to this expander () so /// their edges materialize on demand; eager nodes do not, since the worklist /// has already computed and stored their edges. /// @@ -94,7 +94,7 @@ internal StateGraphNode GetOrCreateNode( { State = state, StepFunctions = stepFunctions, - Expander = this.lazy ? this : null, + LazyExpander = this.lazy ? this : null, DiscoveredFrom = discoveredFrom, DiscoveredVia = discoveredVia, Depth = depth @@ -106,17 +106,12 @@ internal StateGraphNode GetOrCreateNode( return node; } - /// - /// The lazy on-demand entry point, invoked from - /// the first time a node's - /// edges are accessed. - /// - internal List ComputeEdges(StateGraphNode node) - => ExpandNode(node); - /// /// Expands a single node — the one place both modes compute a node's - /// outgoing edges. A node failing the state constraint contributes no + /// outgoing edges. It is the eager worklist's per-node step and, via + /// , the lazy on-demand entry + /// point invoked the first time a node's edges are accessed. + /// A node failing the state constraint contributes no /// edges and fires no hook. Otherwise the pre-hook runs, successors are /// drawn from the path-independent /// kernel (handed the node's From 24c4b51a6694695079b7e42fe8b47728aa383167 Mon Sep 17 00:00:00 2001 From: Immad Date: Wed, 5 Aug 2026 16:31:47 -0500 Subject: [PATCH 3/6] Strengthen eager/lazy equivalence tests Broaden the property-based coverage of state-graph construction: - Widen the randomized model generator (up to 3 dimensions, bound 4, and 5 step functions) and raise the seed count to 400, exercising larger and more varied graphs across every property test. - Add an eager-determinism test asserting repeated eager runs of the same model produce identical node sets, edge sets, and hooked node sets. - Add a generateStateGraph:false hook-parity test asserting the memory-lean eager traversal (the SystemChecker mode, which retains no edges and returns no root) still fires its pre- and post-hooks on exactly the same node set as the full eager graph. These guard the eager-only risk surface on this branch, which has no production lazy consumer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LazyEagerEquivalenceTests.cs | 81 ++++++++++++++++++- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs b/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs index ea16914..1736380 100644 --- a/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs +++ b/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs @@ -31,11 +31,18 @@ namespace Microsoft.Accordant.Tests; /// bounded graph is a subgraph of the unbounded one — not that they equal /// each other. /// +/// +/// Two further eager-only invariants guard the risk surface exercised +/// in production on this branch (there is no production lazy consumer here): +/// eager exploration is deterministic across repeated runs, and the +/// memory-lean generateStateGraph:false traversal (used by +/// SystemChecker, which retains no edges) still fires its pre- and +/// post-hooks on exactly the same node set as the full eager graph. /// [TestFixture] public class LazyEagerEquivalenceTests { - private const int Seeds = 200; + private const int Seeds = 400; #region Randomized model @@ -148,9 +155,9 @@ protected override IList ApplyInternal(IState state) private static (IStepFunction[] steps, VectorState start) BuildModel(Random rnd) { - var dims = rnd.Next(1, 3); // 1..2 - var bound = rnd.Next(2, 4); // 2..3 - var numSteps = rnd.Next(2, 5); // 2..4 + var dims = rnd.Next(1, 4); // 1..3 + var bound = rnd.Next(2, 5); // 2..4 + var numSteps = rnd.Next(2, 6); // 2..5 var steps = new List(); var hasMovement = false; @@ -326,4 +333,70 @@ public void ConstraintFailingRoot_FiresNoHooks_InBothDrivers() Assert.That(lazyHooks, Is.Empty, "lazy must not hook a constraint-failing node"); Assert.That(lazyPostHooks, Is.Empty, "lazy must not post-hook a constraint-failing node"); } + + [Test] + public void EagerExploration_IsDeterministic_AcrossRepeatedRuns() + { + for (var seed = 0; seed < Seeds; seed++) + { + var (steps, start) = BuildModel(new Random(seed)); + + var hooksA = new HashSet(); + var rootA = StateGraph.ExploreStateGraph( + steps, (VectorState)start.Clone(), + hook: n => hooksA.Add(n.GetNodeFingerprint())); + + // Re-run the same model with the same (stateless) step instances; + // interning, ordering and hook firing must reproduce exactly. + var hooksB = new HashSet(); + var rootB = StateGraph.ExploreStateGraph( + steps, (VectorState)start.Clone(), + hook: n => hooksB.Add(n.GetNodeFingerprint())); + + Assert.That(NodeSet(rootB).SetEquals(NodeSet(rootA)), Is.True, + $"seed {seed}: repeated eager runs produced different node sets"); + Assert.That(EdgeSet(rootB).SetEquals(EdgeSet(rootA)), Is.True, + $"seed {seed}: repeated eager runs produced different edge sets"); + Assert.That(hooksB.SetEquals(hooksA), Is.True, + $"seed {seed}: repeated eager runs hooked different node sets"); + } + } + + [Test] + public void GenerateStateGraphFalse_HooksSameNodes_AsEagerGraph() + { + for (var seed = 0; seed < Seeds; seed++) + { + var (steps, start) = BuildModel(new Random(seed)); + + // Full eager graph: collect the pre- and post-hooked node sets and + // the reachable node set. + var graphHooks = new HashSet(); + var graphPostHooks = new HashSet(); + var graphRoot = StateGraph.ExploreStateGraph( + steps, (VectorState)start.Clone(), + hook: n => graphHooks.Add(n.GetNodeFingerprint()), + postHook: n => graphPostHooks.Add(n.GetNodeFingerprint())); + + // Memory-lean eager traversal (the SystemChecker mode): it retains + // no edges and returns no root, but must still visit — and hook — + // exactly the same nodes. + var leanHooks = new HashSet(); + var leanPostHooks = new HashSet(); + var leanRoot = StateGraph.ExploreStateGraph( + steps, (VectorState)start.Clone(), + generateStateGraph: false, + hook: n => leanHooks.Add(n.GetNodeFingerprint()), + postHook: n => leanPostHooks.Add(n.GetNodeFingerprint())); + + Assert.That(leanRoot, Is.Null, + $"seed {seed}: generateStateGraph:false must return no graph root"); + Assert.That(graphHooks.SetEquals(NodeSet(graphRoot)), Is.True, + $"seed {seed}: eager should hook every reachable node exactly once"); + Assert.That(leanHooks.SetEquals(graphHooks), Is.True, + $"seed {seed}: lean traversal hooked a different node set than the full graph"); + Assert.That(leanPostHooks.SetEquals(graphPostHooks), Is.True, + $"seed {seed}: lean traversal post-hooked a different node set than the full graph"); + } + } } From 0fd322f0689374984b4c168de8592a2b04e8dfa1 Mon Sep 17 00:00:00 2001 From: Immad Date: Wed, 5 Aug 2026 16:38:23 -0500 Subject: [PATCH 4/6] Bump version to 0.1.7 and reword test doc comment - Reword the LazyEagerEquivalenceTests summary to describe the eager-only invariants without referring to the branch or production state, which does not belong in a long-lived code comment. - Bump the package version from 0.1.6 to 0.1.7 in Directory.Build.props and both NuGet nuspec files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 2 +- Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs | 11 +++++------ nuget/Microsoft.Accordant.Cli.nuspec | 2 +- nuget/Microsoft.Accordant.nuspec | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 205841d..8afb693 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ CS1591 - 0.1.6 + 0.1.7 Microsoft Microsoft Copyright (c) 2024-2026 Microsoft Corporation diff --git a/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs b/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs index 1736380..61cfa9f 100644 --- a/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs +++ b/Tests/Accordant.Tests/LazyEagerEquivalenceTests.cs @@ -32,12 +32,11 @@ namespace Microsoft.Accordant.Tests; /// each other. /// /// -/// Two further eager-only invariants guard the risk surface exercised -/// in production on this branch (there is no production lazy consumer here): -/// eager exploration is deterministic across repeated runs, and the -/// memory-lean generateStateGraph:false traversal (used by -/// SystemChecker, which retains no edges) still fires its pre- and -/// post-hooks on exactly the same node set as the full eager graph. +/// Two further eager-only invariants: eager exploration is +/// deterministic across repeated runs, and the memory-lean +/// generateStateGraph:false traversal (used by SystemChecker, +/// which retains no edges) still fires its pre- and post-hooks on exactly +/// the same node set as the full eager graph. /// [TestFixture] public class LazyEagerEquivalenceTests diff --git a/nuget/Microsoft.Accordant.Cli.nuspec b/nuget/Microsoft.Accordant.Cli.nuspec index 2eba7f3..ffb6fe5 100644 --- a/nuget/Microsoft.Accordant.Cli.nuspec +++ b/nuget/Microsoft.Accordant.Cli.nuspec @@ -2,7 +2,7 @@ Microsoft.Accordant.Cli - 0.1.6 + 0.1.7 Microsoft false MIT diff --git a/nuget/Microsoft.Accordant.nuspec b/nuget/Microsoft.Accordant.nuspec index 8466716..268ba71 100644 --- a/nuget/Microsoft.Accordant.nuspec +++ b/nuget/Microsoft.Accordant.nuspec @@ -2,7 +2,7 @@ Microsoft.Accordant - 0.1.6 + 0.1.7 Microsoft false MIT From b1c81760b5a4431603db1a169469aa4e77e716f7 Mon Sep 17 00:00:00 2001 From: Immad Date: Wed, 5 Aug 2026 16:56:25 -0500 Subject: [PATCH 5/6] Rebase RLTL model-checking port onto the unified lazy state-graph Reconstruct the RLTL/model-checking work on top of the refined lazy state-graph branch (itself based on current main) so the lazy state-graph implementation lives in exactly one place and is consumed here rather than duplicated: - Add the Accordant.ModelChecking and Accordant.ModelChecking.Bdd projects, the model-checking test suite, the concurrency samples (AlternatingBit, DiningPhilosophers, Paxos, Peterson, TerminationDetection) and the Accordant.slnx solution. - Model checking consumes lazy (on-the-fly) exploration via StateGraph.ExploreStateGraph(..., lazy: true); the checkers walk the graph through Edges so it materializes only as far as the emptiness search needs. - Package Accordant.ModelChecking in the Microsoft.Accordant NuGet package. The lazy state-graph core (StateGraph, StateGraphExpander, SystemChecker and the eager/lazy equivalence + path tests) is inherited unchanged from the lazy-state-graph branch, not re-introduced here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Accordant.ModelChecking.Bdd.csproj | 18 + Accordant.ModelChecking.Bdd/BddBackend.cs | 30 + .../BddStatePropEba.cs | 179 ++ .../CharSetSolverBridge.cs | 203 +++ .../Accordant.ModelChecking.csproj | 20 + Accordant.ModelChecking/ConsList.cs | 78 + Accordant.ModelChecking/CycleFairness.cs | 154 ++ Accordant.ModelChecking/Fairness.cs | 155 ++ Accordant.ModelChecking/IntUnionFind.cs | 125 ++ Accordant.ModelChecking/Ltl/LtlCheck.cs | 899 ++++++++++ Accordant.ModelChecking/Ltl/LtlFormula.cs | 942 ++++++++++ .../ModelCheckExtensions.cs | 37 + Accordant.ModelChecking/Observation.cs | 176 ++ Accordant.ModelChecking/Properties.cs | 152 ++ .../PropertyCheckingResult.cs | 173 ++ Accordant.ModelChecking/Rltl/Regex.cs | 111 ++ Accordant.ModelChecking/Rltl/RltlCheck.cs | 33 + Accordant.ModelChecking/Rltl/RltlFormula.cs | 158 ++ .../Symbolic/AlternationElimination.cs | 465 +++++ .../Symbolic/BpWeakEquivalenceMinimizer.cs | 586 +++++++ .../Symbolic/ConditionRegistry.cs | 228 +++ Accordant.ModelChecking/Symbolic/Dnf.cs | 254 +++ .../Symbolic/EbaExtensions.cs | 80 + .../Symbolic/EndToEndCheck.cs | 254 +++ Accordant.ModelChecking/Symbolic/Ere.cs | 1514 +++++++++++++++++ .../Symbolic/EreBuilder.cs | 104 ++ .../Symbolic/EreCanonicalizer.cs | 88 + .../Symbolic/EreDerivative.cs | 418 +++++ .../Symbolic/EreEmptinessChecker.cs | 420 +++++ .../Symbolic/EreEmptinessCheckerOptions.cs | 40 + .../Symbolic/EreEquivalenceChecker.cs | 393 +++++ .../Symbolic/EreEquivalenceCheckerOptions.cs | 51 + Accordant.ModelChecking/Symbolic/EreJson.cs | 263 +++ .../Symbolic/EreWitness.cs | 145 ++ Accordant.ModelChecking/Symbolic/EreqSExpr.cs | 242 +++ .../Symbolic/IEffectiveBooleanAlgebra.cs | 18 + .../Symbolic/IEffectiveBooleanAlgebraEx.cs | 31 + .../Symbolic/ILeafAlgebra.cs | 71 + .../Symbolic/IPredicateAlgebra.cs | 38 + .../Symbolic/IPredicateAlgebraEx.cs | 41 + .../Symbolic/IncrementalAE.cs | 431 +++++ Accordant.ModelChecking/Symbolic/JsonUtil.cs | 141 ++ Accordant.ModelChecking/Symbolic/Ltl.cs | 359 ++++ .../Symbolic/LtlAlgebra.cs | 237 +++ .../Symbolic/LtlDerivative.cs | 167 ++ Accordant.ModelChecking/Symbolic/LtlJson.cs | 432 +++++ .../Symbolic/MacroReduction.cs | 44 + .../Symbolic/NbwAeProduct.cs | 267 +++ .../Symbolic/NbwProduct.cs | 306 ++++ .../Symbolic/NestedDfsCheck.cs | 408 +++++ .../Symbolic/PredCompare.cs | 63 + Accordant.ModelChecking/Symbolic/Rltl.cs | 632 +++++++ .../Symbolic/RltlAlgebra.cs | 236 +++ .../Symbolic/RltlBreakpointCanonicalizer.cs | 112 ++ .../Symbolic/RltlBuilder.cs | 58 + .../Symbolic/RltlCanonicalizer.cs | 97 ++ .../Symbolic/RltlColour.cs | 62 + .../Symbolic/RltlDerivative.cs | 346 ++++ .../Symbolic/RltlDerivativeBisim.cs | 260 +++ Accordant.ModelChecking/Symbolic/RltlJson.cs | 267 +++ .../Symbolic/RltlLanguageEquivalence.cs | 106 ++ .../Symbolic/RltlMacrostateTransitionMerge.cs | 101 ++ Accordant.ModelChecking/Symbolic/RltlSExpr.cs | 452 +++++ Accordant.ModelChecking/Symbolic/SExpr.cs | 245 +++ .../Symbolic/SccProductCheck.cs | 448 +++++ Accordant.ModelChecking/Symbolic/StateProp.cs | 311 ++++ .../Symbolic/StatePropEbaProvider.cs | 61 + Accordant.ModelChecking/Symbolic/StateSet.cs | 231 +++ .../Symbolic/StringFreeAlgebra.cs | 20 + .../Symbolic/SymbolicABW.cs | 265 +++ .../Symbolic/SymbolicLtlCheck.cs | 525 ++++++ .../Symbolic/SymbolicNBW.cs | 137 ++ .../Symbolic/SymbolicNbwEmptiness.cs | 176 ++ .../Symbolic/SymbolicRltlCheck.cs | 186 ++ .../Symbolic/TraceInstantiation.cs | 74 + .../Symbolic/TransitionTerm.cs | 281 +++ .../Symbolic/TransitionTermAlgebra.cs | 569 +++++++ Accordant.ModelChecking/TarjanSCC.cs | 200 +++ .../Testing/LtlMultiBackendCrossCheck.cs | 245 +++ .../Testing/LtlRltlCrossCheck.cs | 153 ++ .../Testing/RandomLtlGenerator.cs | 86 + Accordant.ModelChecking/TraceItem.cs | 66 + Accordant.slnx | 8 + Samples/AlternatingBit/AltBit.cs | 266 +++ .../AltBitAdditionalLtlTests.cs | 135 ++ Samples/AlternatingBit/AltBitBugDemoTests.cs | 64 + .../AlternatingBit/AltBitCrossCheckTests.cs | 55 + .../AltBitFusionShowcaseTests.cs | 176 ++ Samples/AlternatingBit/AltBitLtlTests.cs | 93 + .../AlternatingBit/AltBitOracleSweepTests.cs | 75 + Samples/AlternatingBit/AltBitRltlTests.cs | 105 ++ Samples/AlternatingBit/AltBitState.cs | 40 + Samples/AlternatingBit/AlternatingBit.csproj | 25 + Samples/DiningPhilosophers/Dining.cs | 254 +++ .../DiningAdditionalLtlTests.cs | 138 ++ .../DiningCrossCheckTests.cs | 89 + .../DiningGraphProbeTests.cs | 51 + Samples/DiningPhilosophers/DiningLtlTests.cs | 129 ++ .../DiningOracleSweepTests.cs | 112 ++ .../DiningPhilosophers.csproj | 25 + .../DiningRltlShowcaseTests.cs | 131 ++ Samples/DiningPhilosophers/DiningRltlTests.cs | 94 + Samples/DiningPhilosophers/DiningState.cs | 42 + Samples/Paxos/Paxos.cs | 338 ++++ Samples/Paxos/Paxos.csproj | 24 + Samples/Paxos/PaxosBugDemoTests.cs | 60 + Samples/Paxos/PaxosLtlTests.cs | 99 ++ Samples/Paxos/PaxosRltlShowcaseTests.cs | 121 ++ Samples/Paxos/PaxosState.cs | 37 + Samples/Peterson/Peterson.cs | 250 +++ Samples/Peterson/Peterson.csproj | 24 + .../Peterson/PetersonModelCheckingTests.cs | 158 ++ Samples/Peterson/PetersonState.cs | 43 + Samples/TerminationDetection/EWD998.cs | 495 ++++++ .../EWD998AdditionalLtlTests.cs | 151 ++ .../EWD998BugDemoTests.cs | 72 + .../EWD998CrossCheckTests.cs | 98 ++ .../TerminationDetection/EWD998LtlTests.cs | 324 ++++ .../EWD998OracleSweepTests.cs | 138 ++ .../EWD998RltlShowcaseTests.cs | 98 ++ .../TerminationDetection/EWD998RltlTests.cs | 245 +++ Samples/TerminationDetection/EWD998State.cs | 35 + .../TerminationDetection/TLAStepFunction.cs | 45 + .../TerminationDetection.csproj | 24 + .../Accordant.ModelChecking.Tests.csproj | 24 + .../ConsListTests.cs | 64 + .../DegenerateInputsTests.cs | 264 +++ .../FairnessDirectUnitTests.cs | 309 ++++ .../IntUnionFindTests.cs | 84 + .../LazyStateGraphTests.cs | 329 ++++ ...lCheckFairnessProjectionRegressionTests.cs | 184 ++ .../Ltl/LtlFormulaCanonicalizationTests.cs | 146 ++ .../Rltl/RltlDslTests.cs | 170 ++ .../Symbolic/ABWTests.cs | 733 ++++++++ .../Symbolic/BddStatePropEbaTests.cs | 205 +++ .../Symbolic/BoundedDepthFrontierTests.cs | 202 +++ .../Symbolic/BoundedModelCheckingE2ETests.cs | 646 +++++++ .../BpWeakEquivalenceBpEndToEndTests.cs | 292 ++++ .../ConditionRegistryPropositionTests.cs | 120 ++ .../ConditionRegistrySolverAwareTests.cs | 125 ++ .../Symbolic/EbaExtensionsTests.cs | 142 ++ .../Symbolic/EreComplementPushThroughTests.cs | 103 ++ .../EreDerivativePreciseEquivalenceTests.cs | 184 ++ .../Symbolic/EreEquivalenceCheckerTests.cs | 249 +++ .../EreEquivalenceDifferentialOracleTests.cs | 262 +++ .../Symbolic/EreFreePropsTests.cs | 68 + .../Symbolic/EreJsonTests.cs | 76 + .../Symbolic/EreLengthBoundsTests.cs | 134 ++ .../Symbolic/EreMetadataTests.cs | 112 ++ .../Symbolic/ErePredicateStarRewriteTests.cs | 134 ++ .../Symbolic/EreTests.cs | 712 ++++++++ .../Symbolic/EreUnionContainsMergeTests.cs | 80 + .../Symbolic/EreUnionHeadFactoringTests.cs | 103 ++ .../Symbolic/EreUnionPlusCollapseTests.cs | 85 + .../EreUnionSigmaStarAbsorptionTests.cs | 105 ++ .../EreUnionSigmaStarTailSubsumptionTests.cs | 91 + .../Symbolic/EreWitnessTests.cs | 240 +++ .../Symbolic/FairnessTests.cs | 252 +++ .../Symbolic/JacmExample51EndToEndTests.cs | 183 ++ .../Symbolic/LtlIntegrationTests.cs | 481 ++++++ .../Symbolic/LtlSerializationAndAETests.cs | 512 ++++++ .../MacrostateTransitionMergeEndToEndTests.cs | 185 ++ .../Symbolic/NbwProductTests.cs | 552 ++++++ .../Symbolic/NestedDfsCheckTests.cs | 365 ++++ .../Symbolic/PredCompareTests.cs | 306 ++++ .../RltlBreakpointCanonicalizerTests.cs | 172 ++ .../Symbolic/RltlCanonicalizerTests.cs | 110 ++ .../Symbolic/RltlColourTests.cs | 114 ++ .../Symbolic/RltlDerivativeBisimTests.cs | 176 ++ .../RltlDerivativeTableauDedupTests.cs | 148 ++ .../RltlDistanceNFusionWiringTests.cs | 300 ++++ .../Symbolic/RltlEreCanonicalizerTests.cs | 184 ++ .../Symbolic/RltlJsonTests.cs | 75 + .../Symbolic/RltlLanguageEquivalenceTests.cs | 214 +++ .../RltlPrefixUnionDistributionTests.cs | 122 ++ .../Symbolic/RltlSExprDslTests.cs | 368 ++++ .../Symbolic/RltlTests.cs | 536 ++++++ .../Symbolic/StatePropEbaSatTests.cs | 101 ++ .../Symbolic/SymbolicLtlCheckTests.cs | 365 ++++ .../Symbolic/SymbolicRltlCheckTests.cs | 406 +++++ .../Symbolic/TestHelpers.cs | 157 ++ .../Symbolic/TraceInstantiationTests.cs | 185 ++ .../TransitionTermAlgebraPropositionTests.cs | 129 ++ .../Symbolic/TransitionTermTests.cs | 685 ++++++++ nuget/Microsoft.Accordant.nuspec | 4 + 185 files changed, 38400 insertions(+) create mode 100644 Accordant.ModelChecking.Bdd/Accordant.ModelChecking.Bdd.csproj create mode 100644 Accordant.ModelChecking.Bdd/BddBackend.cs create mode 100644 Accordant.ModelChecking.Bdd/BddStatePropEba.cs create mode 100644 Accordant.ModelChecking.Bdd/CharSetSolverBridge.cs create mode 100644 Accordant.ModelChecking/Accordant.ModelChecking.csproj create mode 100644 Accordant.ModelChecking/ConsList.cs create mode 100644 Accordant.ModelChecking/CycleFairness.cs create mode 100644 Accordant.ModelChecking/Fairness.cs create mode 100644 Accordant.ModelChecking/IntUnionFind.cs create mode 100644 Accordant.ModelChecking/Ltl/LtlCheck.cs create mode 100644 Accordant.ModelChecking/Ltl/LtlFormula.cs create mode 100644 Accordant.ModelChecking/ModelCheckExtensions.cs create mode 100644 Accordant.ModelChecking/Observation.cs create mode 100644 Accordant.ModelChecking/Properties.cs create mode 100644 Accordant.ModelChecking/PropertyCheckingResult.cs create mode 100644 Accordant.ModelChecking/Rltl/Regex.cs create mode 100644 Accordant.ModelChecking/Rltl/RltlCheck.cs create mode 100644 Accordant.ModelChecking/Rltl/RltlFormula.cs create mode 100644 Accordant.ModelChecking/Symbolic/AlternationElimination.cs create mode 100644 Accordant.ModelChecking/Symbolic/BpWeakEquivalenceMinimizer.cs create mode 100644 Accordant.ModelChecking/Symbolic/ConditionRegistry.cs create mode 100644 Accordant.ModelChecking/Symbolic/Dnf.cs create mode 100644 Accordant.ModelChecking/Symbolic/EbaExtensions.cs create mode 100644 Accordant.ModelChecking/Symbolic/EndToEndCheck.cs create mode 100644 Accordant.ModelChecking/Symbolic/Ere.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreBuilder.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreCanonicalizer.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreDerivative.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreEmptinessChecker.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreEmptinessCheckerOptions.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreEquivalenceChecker.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreEquivalenceCheckerOptions.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreJson.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreWitness.cs create mode 100644 Accordant.ModelChecking/Symbolic/EreqSExpr.cs create mode 100644 Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebra.cs create mode 100644 Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebraEx.cs create mode 100644 Accordant.ModelChecking/Symbolic/ILeafAlgebra.cs create mode 100644 Accordant.ModelChecking/Symbolic/IPredicateAlgebra.cs create mode 100644 Accordant.ModelChecking/Symbolic/IPredicateAlgebraEx.cs create mode 100644 Accordant.ModelChecking/Symbolic/IncrementalAE.cs create mode 100644 Accordant.ModelChecking/Symbolic/JsonUtil.cs create mode 100644 Accordant.ModelChecking/Symbolic/Ltl.cs create mode 100644 Accordant.ModelChecking/Symbolic/LtlAlgebra.cs create mode 100644 Accordant.ModelChecking/Symbolic/LtlDerivative.cs create mode 100644 Accordant.ModelChecking/Symbolic/LtlJson.cs create mode 100644 Accordant.ModelChecking/Symbolic/MacroReduction.cs create mode 100644 Accordant.ModelChecking/Symbolic/NbwAeProduct.cs create mode 100644 Accordant.ModelChecking/Symbolic/NbwProduct.cs create mode 100644 Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs create mode 100644 Accordant.ModelChecking/Symbolic/PredCompare.cs create mode 100644 Accordant.ModelChecking/Symbolic/Rltl.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlAlgebra.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlBreakpointCanonicalizer.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlBuilder.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlCanonicalizer.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlColour.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlDerivative.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlDerivativeBisim.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlJson.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlLanguageEquivalence.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlMacrostateTransitionMerge.cs create mode 100644 Accordant.ModelChecking/Symbolic/RltlSExpr.cs create mode 100644 Accordant.ModelChecking/Symbolic/SExpr.cs create mode 100644 Accordant.ModelChecking/Symbolic/SccProductCheck.cs create mode 100644 Accordant.ModelChecking/Symbolic/StateProp.cs create mode 100644 Accordant.ModelChecking/Symbolic/StatePropEbaProvider.cs create mode 100644 Accordant.ModelChecking/Symbolic/StateSet.cs create mode 100644 Accordant.ModelChecking/Symbolic/StringFreeAlgebra.cs create mode 100644 Accordant.ModelChecking/Symbolic/SymbolicABW.cs create mode 100644 Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs create mode 100644 Accordant.ModelChecking/Symbolic/SymbolicNBW.cs create mode 100644 Accordant.ModelChecking/Symbolic/SymbolicNbwEmptiness.cs create mode 100644 Accordant.ModelChecking/Symbolic/SymbolicRltlCheck.cs create mode 100644 Accordant.ModelChecking/Symbolic/TraceInstantiation.cs create mode 100644 Accordant.ModelChecking/Symbolic/TransitionTerm.cs create mode 100644 Accordant.ModelChecking/Symbolic/TransitionTermAlgebra.cs create mode 100644 Accordant.ModelChecking/TarjanSCC.cs create mode 100644 Accordant.ModelChecking/Testing/LtlMultiBackendCrossCheck.cs create mode 100644 Accordant.ModelChecking/Testing/LtlRltlCrossCheck.cs create mode 100644 Accordant.ModelChecking/Testing/RandomLtlGenerator.cs create mode 100644 Accordant.ModelChecking/TraceItem.cs create mode 100644 Samples/AlternatingBit/AltBit.cs create mode 100644 Samples/AlternatingBit/AltBitAdditionalLtlTests.cs create mode 100644 Samples/AlternatingBit/AltBitBugDemoTests.cs create mode 100644 Samples/AlternatingBit/AltBitCrossCheckTests.cs create mode 100644 Samples/AlternatingBit/AltBitFusionShowcaseTests.cs create mode 100644 Samples/AlternatingBit/AltBitLtlTests.cs create mode 100644 Samples/AlternatingBit/AltBitOracleSweepTests.cs create mode 100644 Samples/AlternatingBit/AltBitRltlTests.cs create mode 100644 Samples/AlternatingBit/AltBitState.cs create mode 100644 Samples/AlternatingBit/AlternatingBit.csproj create mode 100644 Samples/DiningPhilosophers/Dining.cs create mode 100644 Samples/DiningPhilosophers/DiningAdditionalLtlTests.cs create mode 100644 Samples/DiningPhilosophers/DiningCrossCheckTests.cs create mode 100644 Samples/DiningPhilosophers/DiningGraphProbeTests.cs create mode 100644 Samples/DiningPhilosophers/DiningLtlTests.cs create mode 100644 Samples/DiningPhilosophers/DiningOracleSweepTests.cs create mode 100644 Samples/DiningPhilosophers/DiningPhilosophers.csproj create mode 100644 Samples/DiningPhilosophers/DiningRltlShowcaseTests.cs create mode 100644 Samples/DiningPhilosophers/DiningRltlTests.cs create mode 100644 Samples/DiningPhilosophers/DiningState.cs create mode 100644 Samples/Paxos/Paxos.cs create mode 100644 Samples/Paxos/Paxos.csproj create mode 100644 Samples/Paxos/PaxosBugDemoTests.cs create mode 100644 Samples/Paxos/PaxosLtlTests.cs create mode 100644 Samples/Paxos/PaxosRltlShowcaseTests.cs create mode 100644 Samples/Paxos/PaxosState.cs create mode 100644 Samples/Peterson/Peterson.cs create mode 100644 Samples/Peterson/Peterson.csproj create mode 100644 Samples/Peterson/PetersonModelCheckingTests.cs create mode 100644 Samples/Peterson/PetersonState.cs create mode 100644 Samples/TerminationDetection/EWD998.cs create mode 100644 Samples/TerminationDetection/EWD998AdditionalLtlTests.cs create mode 100644 Samples/TerminationDetection/EWD998BugDemoTests.cs create mode 100644 Samples/TerminationDetection/EWD998CrossCheckTests.cs create mode 100644 Samples/TerminationDetection/EWD998LtlTests.cs create mode 100644 Samples/TerminationDetection/EWD998OracleSweepTests.cs create mode 100644 Samples/TerminationDetection/EWD998RltlShowcaseTests.cs create mode 100644 Samples/TerminationDetection/EWD998RltlTests.cs create mode 100644 Samples/TerminationDetection/EWD998State.cs create mode 100644 Samples/TerminationDetection/TLAStepFunction.cs create mode 100644 Samples/TerminationDetection/TerminationDetection.csproj create mode 100644 Tests/Accordant.ModelChecking.Tests/Accordant.ModelChecking.Tests.csproj create mode 100644 Tests/Accordant.ModelChecking.Tests/ConsListTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/DegenerateInputsTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/FairnessDirectUnitTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/IntUnionFindTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/LazyStateGraphTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Ltl/LtlCheckFairnessProjectionRegressionTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Ltl/LtlFormulaCanonicalizationTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Rltl/RltlDslTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/ABWTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/BddStatePropEbaTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedDepthFrontierTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedModelCheckingE2ETests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/BpWeakEquivalenceBpEndToEndTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistryPropositionTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistrySolverAwareTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EbaExtensionsTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreComplementPushThroughTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreDerivativePreciseEquivalenceTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceCheckerTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceDifferentialOracleTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreFreePropsTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreJsonTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreLengthBoundsTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreMetadataTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/ErePredicateStarRewriteTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionContainsMergeTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionHeadFactoringTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionPlusCollapseTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarAbsorptionTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarTailSubsumptionTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/EreWitnessTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/FairnessTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/JacmExample51EndToEndTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/LtlIntegrationTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/LtlSerializationAndAETests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/MacrostateTransitionMergeEndToEndTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/NbwProductTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/NestedDfsCheckTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/PredCompareTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlBreakpointCanonicalizerTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlCanonicalizerTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlColourTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeBisimTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeTableauDedupTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDistanceNFusionWiringTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlEreCanonicalizerTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlJsonTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlLanguageEquivalenceTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlPrefixUnionDistributionTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlSExprDslTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/RltlTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/StatePropEbaSatTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicLtlCheckTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicRltlCheckTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/TestHelpers.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/TraceInstantiationTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermAlgebraPropositionTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermTests.cs diff --git a/Accordant.ModelChecking.Bdd/Accordant.ModelChecking.Bdd.csproj b/Accordant.ModelChecking.Bdd/Accordant.ModelChecking.Bdd.csproj new file mode 100644 index 0000000..e18b746 --- /dev/null +++ b/Accordant.ModelChecking.Bdd/Accordant.ModelChecking.Bdd.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + latest + Library + ../bin + false + Microsoft.Accordant.ModelChecking.Bdd + $(NoWarn);CA2255 + BDD-backed Effective Boolean Algebra for Accordant.ModelChecking. Reflection-wraps the in-box System.Text.RegularExpressions.Symbolic.CharSetSolver (.NET 7+) to provide precise propositional decisions (IsSatisfiable, AreEquivalent, Implies) without any external SAT/SMT dependency. Self-registers as the default StatePropEba via a module initializer. + + + + + + + diff --git a/Accordant.ModelChecking.Bdd/BddBackend.cs b/Accordant.ModelChecking.Bdd/BddBackend.cs new file mode 100644 index 0000000..f611bf9 --- /dev/null +++ b/Accordant.ModelChecking.Bdd/BddBackend.cs @@ -0,0 +1,30 @@ +namespace Microsoft.Accordant.ModelChecking.Bdd +{ + using System.Runtime.CompilerServices; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// Entry point for the BDD backend. Referencing this assembly is + /// sufficient to register as the + /// default propositional EBA used by the symbolic LTL/RLTL pipelines + /// — a module initializer fires on first load and calls + /// . + /// + /// Callers that want to opt back out can call + /// at any time. + /// + public static class BddBackend + { + /// + /// Explicit registration entry point. Idempotent. Equivalent to + /// the module initializer; provided for tests and consumers who + /// prefer an explicit opt-in. + /// + public static void RegisterAsDefault() + => StatePropEbaProvider.SetDefault(BddStatePropEba.Instance); + + [ModuleInitializer] + internal static void AutoRegister() + => RegisterAsDefault(); + } +} diff --git a/Accordant.ModelChecking.Bdd/BddStatePropEba.cs b/Accordant.ModelChecking.Bdd/BddStatePropEba.cs new file mode 100644 index 0000000..81807b5 --- /dev/null +++ b/Accordant.ModelChecking.Bdd/BddStatePropEba.cs @@ -0,0 +1,179 @@ +namespace Microsoft.Accordant.ModelChecking.Bdd +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// BDD-backed Effective Boolean Algebra over + /// . + /// + /// + /// Structural operations (, , + /// , , ) and + /// the relation are delegated to the toy + /// so the rest of the engine keeps seeing + /// the existing structural trees and + /// nothing about the predicate IR changes. + /// + /// + /// + /// What this adapter does add is precise propositional + /// decisions: , + /// and all translate the predicate into the + /// in-box BDD package via and then + /// rely on BDD canonicalisation: + /// + /// IsSatisfiable(p) ≡ bdd(p) ≠ ⊥ + /// AreEquivalent(p,q) ≡ bdd(p) == bdd(q) + /// Implies(p,q) ≡ bdd(p ∧ ¬q) == ⊥ + /// + /// All of these are O(1) on top of the apply cost, with no atom-count + /// cap. The toy backend's brute-force enumeration caps out at 20 + /// atoms; this adapter doesn't. + /// + /// + /// + /// Atoms are mapped to BDD variable ordinals on first sight, indexed + /// by . The mapping is per-instance so + /// equivalent atoms across calls on the same adapter hit the same + /// BDD variable. + /// + /// + /// + /// is + /// deliberately not implemented (the adapter would need a way to + /// fabricate a from an assignment of opaque atom + /// callbacks, which it does not have). + /// callers therefore still get a false via the fallback. + /// + /// + public sealed class BddStatePropEba + : IEffectiveBooleanAlgebra, + IPredicateAlgebraEx + { + /// Shared instance — atom ordinal cache is intentionally + /// per-process so independent callers re-use the same BDD nodes. + public static readonly BddStatePropEba Instance = new BddStatePropEba(); + + private readonly CharSetSolverBridge _bridge = CharSetSolverBridge.Instance; + private readonly StatePropEba _structural = StatePropEba.Instance; + + // StateProp.Id -> BDD ordinal. Allocate-on-first-sight; ordinals + // monotonically increase. Variable order matters for BDD size; a + // first-seen heuristic is the simplest reasonable default. + private readonly Dictionary _propToOrdinal = new Dictionary(); + // Reverse map, useful for diagnostics / future model-lift. + private readonly Dictionary _ordinalToProp = new Dictionary(); + private int _nextOrdinal; + private readonly object _mapGate = new object(); + + // --- Structural ops (delegate) ------------------------------------- + + public IStatePredicate Top => _structural.Top; + public IStatePredicate Bottom => _structural.Bottom; + + public IStatePredicate And(IStatePredicate a, IStatePredicate b) + => _structural.And(a, b); + + public IStatePredicate Or(IStatePredicate a, IStatePredicate b) + => _structural.Or(a, b); + + public IStatePredicate Not(IStatePredicate a) + => _structural.Not(a); + + public bool Models(State element, IStatePredicate predicate) + => _structural.Models(element, predicate); + + // --- Decisions (precise, BDD-backed) ------------------------------- + + public bool IsSatisfiable(IStatePredicate predicate) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (predicate is StatePredFalse) return false; + if (predicate is StatePredTrue) return true; + if (TryEncode(predicate, out var bdd)) + return !_bridge.IsFalse(bdd); + // Foreign IStatePredicate subtree -> conservative-true, matching + // the toy and the EBA contract. + return true; + } + + public bool AreEquivalent(IStatePredicate a, IStatePredicate b) + { + if (a == null) throw new ArgumentNullException(nameof(a)); + if (b == null) throw new ArgumentNullException(nameof(b)); + if (ReferenceEquals(a, b)) return true; + if (!TryEncode(a, out var ba)) return false; + if (!TryEncode(b, out var bb)) return false; + return _bridge.AreSame(ba, bb); + } + + public bool Implies(IStatePredicate a, IStatePredicate b) + { + if (a == null) throw new ArgumentNullException(nameof(a)); + if (b == null) throw new ArgumentNullException(nameof(b)); + if (!TryEncode(a, out var ba)) return false; + if (!TryEncode(b, out var bb)) return false; + // a ⇒ b iff a ∧ ¬b ≡ ⊥ + var notB = _bridge.Not(bb); + var conj = _bridge.And(ba, notB); + return _bridge.IsFalse(conj); + } + + // --- Encoding ------------------------------------------------------ + + private int OrdinalFor(StateProp prop) + { + lock (_mapGate) + { + if (_propToOrdinal.TryGetValue(prop.Id, out var ord)) return ord; + ord = _nextOrdinal++; + _propToOrdinal[prop.Id] = ord; + _ordinalToProp[ord] = prop; + return ord; + } + } + + /// + /// Translate a propositional tree to + /// a BDD. Returns false if the tree contains a foreign + /// subclass we cannot interpret. In that case the caller falls + /// back to a conservative answer rather than risking unsoundness. + /// + private bool TryEncode(IStatePredicate p, out object bdd) + { + switch (p) + { + case StatePredTrue _: + bdd = _bridge.Top; + return true; + case StatePredFalse _: + bdd = _bridge.Bottom; + return true; + case StatePredAtom atom: + bdd = _bridge.MkVar(OrdinalFor(atom.Prop)); + return true; + case StatePredNot neg: + if (!TryEncode(neg.Inner, out var inner)) { bdd = null; return false; } + bdd = _bridge.Not(inner); + return true; + case StatePredAnd conj: + if (!TryEncode(conj.Left, out var la) || + !TryEncode(conj.Right, out var ra)) + { bdd = null; return false; } + bdd = _bridge.And(la, ra); + return true; + case StatePredOr disj: + if (!TryEncode(disj.Left, out var ld) || + !TryEncode(disj.Right, out var rd)) + { bdd = null; return false; } + bdd = _bridge.Or(ld, rd); + return true; + default: + bdd = null; + return false; + } + } + } +} diff --git a/Accordant.ModelChecking.Bdd/CharSetSolverBridge.cs b/Accordant.ModelChecking.Bdd/CharSetSolverBridge.cs new file mode 100644 index 0000000..608467c --- /dev/null +++ b/Accordant.ModelChecking.Bdd/CharSetSolverBridge.cs @@ -0,0 +1,203 @@ +namespace Microsoft.Accordant.ModelChecking.Bdd +{ + using System; + using System.Collections.Generic; + using System.Reflection; + using System.Text.RegularExpressions; + + /// + /// Reflection wrapper around the in-box BDD package shipped inside + /// System.Text.RegularExpressions.dll as + /// System.Text.RegularExpressions.Symbolic.CharSetSolver (.NET 7+). + /// + /// + /// The non-backtracking symbolic regex engine carries a complete + /// canonicalising BDD apply-with-memoisation package (Bryant 1986 + /// style). Reflection-unwrapping it lets us reuse it as a + /// general-purpose propositional decision procedure: BDD identity + /// decides equivalence in O(1), apply is time-proportional to the + /// product of operand sizes, and there is no fixed variable cap. + /// + /// + /// + /// The bridge is a process-wide singleton. All public methods are + /// synchronised with a single lock because CharSetSolver is + /// not documented as thread-safe and the apply-cache it maintains + /// internally would race under concurrent ApplyBinaryOp calls. + /// + /// + public sealed class CharSetSolverBridge + { + private static readonly Lazy _instance = + new Lazy(() => new CharSetSolverBridge(), + System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); + + /// The lazily-constructed singleton bridge. + public static CharSetSolverBridge Instance => _instance.Value; + + // --- Reflected handles -------------------------------------------------- + private readonly object _solver; + private readonly object _tt; + private readonly object _ff; + private readonly MethodInfo _not; + private readonly MethodInfo _binOp; + private readonly MethodInfo _mkBdd; + private readonly FieldInfo _bddOrdinal; + private readonly FieldInfo _bddOne; + private readonly FieldInfo _bddZero; + private readonly object _gate = new object(); + + // The op-id constants used by CharSetSolver.ApplyBinaryOp. These + // correspond to enum members declared inside the engine. We keep + // them as ints to avoid taking a hard reference to the internal + // enum type. + private const int OrId = 0; + private const int AndId = 1; + private const int XorId = 2; + + /// Canonical ⊤ BDD (full universe). + public object Top => _tt; + + /// Canonical ⊥ BDD (empty set). + public object Bottom => _ff; + + private CharSetSolverBridge() + { + var asm = typeof(Regex).Assembly; + + var solverType = asm.GetType( + "System.Text.RegularExpressions.Symbolic.CharSetSolver") + ?? throw new InvalidOperationException( + "Could not locate System.Text.RegularExpressions.Symbolic.CharSetSolver. " + + "The BDD backend requires .NET 7 or later."); + + var bddType = asm.GetType( + "System.Text.RegularExpressions.Symbolic.BDD") + ?? throw new InvalidOperationException( + "Could not locate System.Text.RegularExpressions.Symbolic.BDD."); + + var ctor = solverType.GetConstructor( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, types: Type.EmptyTypes, modifiers: null) + ?? throw new InvalidOperationException( + "CharSetSolver has no parameterless constructor on this runtime."); + _solver = ctor.Invoke(Array.Empty()); + + _not = solverType.GetMethod("Not", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("CharSetSolver.Not not found."); + _binOp = solverType.GetMethod("ApplyBinaryOp", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("CharSetSolver.ApplyBinaryOp not found."); + _mkBdd = solverType.GetMethod("GetOrCreateBDD", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("CharSetSolver.GetOrCreateBDD not found."); + + _tt = solverType.GetProperty("Full", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(_solver) + ?? throw new InvalidOperationException("CharSetSolver.Full not found."); + _ff = solverType.GetProperty("Empty", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(_solver) + ?? throw new InvalidOperationException("CharSetSolver.Empty not found."); + + _bddOrdinal = bddType.GetField("Ordinal", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("BDD.Ordinal not found."); + _bddOne = bddType.GetField("One", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("BDD.One not found."); + _bddZero = bddType.GetField("Zero", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("BDD.Zero not found."); + } + + /// Create the BDD representing the single variable with + /// the given ordinal. + public object MkVar(int ordinal) + { + lock (_gate) + return _mkBdd.Invoke(_solver, new object[] { ordinal, _tt, _ff }); + } + + /// Boolean complement. + public object Not(object bdd) + { + lock (_gate) + return _not.Invoke(_solver, new[] { bdd }); + } + + /// Boolean conjunction (canonicalising). + public object And(object a, object b) + { + lock (_gate) + return _binOp.Invoke(_solver, new[] { (object)AndId, a, b }); + } + + /// Boolean disjunction (canonicalising). + public object Or(object a, object b) + { + lock (_gate) + return _binOp.Invoke(_solver, new[] { (object)OrId, a, b }); + } + + /// Boolean xor — convenient for equivalence via + /// Xor(a,b) == ⊥. + public object Xor(object a, object b) + { + lock (_gate) + return _binOp.Invoke(_solver, new[] { (object)XorId, a, b }); + } + + /// Reference-equality (≡ semantic equality, since the BDD + /// package is canonicalising) check against ⊥. + public bool IsFalse(object bdd) => ReferenceEquals(bdd, _ff); + + /// Reference-equality check against ⊤. + public bool IsTrue(object bdd) => ReferenceEquals(bdd, _tt); + + /// Reference-equality of two BDDs — equivalent to + /// semantic equivalence under the canonicalisation invariant. + public bool AreSame(object a, object b) => ReferenceEquals(a, b); + + /// + /// Walk a satisfying path of from the root + /// down to ⊤, recording the variable assignments along the way. + /// Returns an empty dictionary when is ⊤; + /// throws when it is ⊥. Variables not appearing on the chosen + /// path are left unassigned (don't care). + /// + public IReadOnlyDictionary ExtractModel(object bdd) + { + if (bdd == null) throw new ArgumentNullException(nameof(bdd)); + if (IsFalse(bdd)) + throw new ArgumentException( + "Cannot extract a model from ⊥.", nameof(bdd)); + + var sol = new Dictionary(); + var node = bdd; + while (!IsTrue(node)) + { + var ordinal = (int)_bddOrdinal.GetValue(node); + var oneBranch = _bddOne.GetValue(node); + var zeroBranch = _bddZero.GetValue(node); + + // Prefer the branch that doesn't lead immediately to ⊥; + // if both are non-⊥ we pick the 0-branch arbitrarily to + // keep the assignment minimal. + if (!IsFalse(zeroBranch)) + { + sol[ordinal] = false; + node = zeroBranch; + } + else + { + sol[ordinal] = true; + node = oneBranch; + } + } + return sol; + } + } +} diff --git a/Accordant.ModelChecking/Accordant.ModelChecking.csproj b/Accordant.ModelChecking/Accordant.ModelChecking.csproj new file mode 100644 index 0000000..e1d7bd8 --- /dev/null +++ b/Accordant.ModelChecking/Accordant.ModelChecking.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + latest + Library + ../bin + false + Microsoft.Accordant.ModelChecking + + + + + + + + + + + diff --git a/Accordant.ModelChecking/ConsList.cs b/Accordant.ModelChecking/ConsList.cs new file mode 100644 index 0000000..0f3f62e --- /dev/null +++ b/Accordant.ModelChecking/ConsList.cs @@ -0,0 +1,78 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using System.Collections; + using System.Collections.Generic; + + /// + /// Immutable singly-linked list with structural sharing — used as a + /// cheap "backwards-witness" accumulator during regex emptiness / + /// equivalence checks. Cons is O(1); enumeration walks head-to-tail. + /// + /// Conventions: + /// * is a shared singleton representing Nil. + /// * Cons(head, tail) prepends a new head. + /// * For symbolic witnesses we build the list in reverse (most recent + /// condition at the head); callers reverse on extraction. + /// + public sealed class ConsList : IEnumerable + { + public static readonly ConsList Empty = new ConsList(); + + public T Head { get; } + public ConsList Tail { get; } + public int Count { get; } + public bool IsEmpty => Count == 0; + + private ConsList() + { + Head = default; + Tail = null; + Count = 0; + } + + private ConsList(T head, ConsList tail) + { + Head = head; + Tail = tail; + Count = tail.Count + 1; + } + + public static ConsList Cons(T head, ConsList tail) + { + if (tail == null) throw new ArgumentNullException(nameof(tail)); + return new ConsList(head, tail); + } + + public ConsList Push(T head) => new ConsList(head, this); + + /// Reverses the list (head becomes tail). + public ConsList Reverse() + { + var acc = Empty; + for (var node = this; !node.IsEmpty; node = node.Tail) + { + acc = new ConsList(node.Head, acc); + } + return acc; + } + + public IEnumerator GetEnumerator() + { + for (var node = this; !node.IsEmpty; node = node.Tail) + { + yield return node.Head; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public static ConsList FromEnumerableReversed(IEnumerable items) + { + if (items == null) throw new ArgumentNullException(nameof(items)); + var acc = Empty; + foreach (var x in items) acc = new ConsList(x, acc); + return acc; + } + } +} diff --git a/Accordant.ModelChecking/CycleFairness.cs b/Accordant.ModelChecking/CycleFairness.cs new file mode 100644 index 0000000..9da0c08 --- /dev/null +++ b/Accordant.ModelChecking/CycleFairness.cs @@ -0,0 +1,154 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Single source of truth for the enabled / continuously-enabled / + /// taken-in-cycle computation underlying every fairness decision in + /// the model checker. + /// + /// + /// Four call sites previously duplicated this logic, each with + /// slightly different group/edge iteration patterns: + /// + /// + /// — system SCC for the + /// explicit non-product fairness path. + /// — explicit LTL + /// product SCC projected to its unique system nodes; "taken" comes + /// from product edges that stay inside the product SCC. + /// SccProductCheck.IsFairProductCycle — symbolic + /// product SCC, the symbolic analogue of the explicit-LTL + /// projection. + /// 's + /// GetEnabledButNotTakenSteps — diagnostic hint emitting + /// labels for steps that were enabled but never fired inside the + /// bad cycle. + /// + /// + /// Each call site differs only in how it enumerates (a) the + /// "groups" (one per unique system node visited by the cycle), + /// (b) the step functions enabled at each group's system state, and + /// (c) the step functions actually fired on edges that stay inside + /// the cycle. This helper factors that variation behind + /// and returns a single + /// from which fairness verdicts and the + /// diagnostic hint are derived. + /// + /// + internal static class CycleFairness + { + /// + /// Aggregated enabled / continuouslyEnabled / taken sets for a + /// cycle, plus a representative per + /// id so that fairness predicates can be evaluated. + /// + internal sealed class Analysis + { + public HashSet Enabled { get; } + public HashSet ContinuouslyEnabled { get; } + public HashSet Taken { get; } + public Dictionary StepById { get; } + + public Analysis( + HashSet enabled, + HashSet continuouslyEnabled, + HashSet taken, + Dictionary stepById) + { + Enabled = enabled; + ContinuouslyEnabled = continuouslyEnabled; + Taken = taken; + StepById = stepById; + } + } + + /// + /// Build the per-cycle . + /// is called once per group; + /// continuouslyEnabled is computed as the intersection of + /// per-group enabled sets. is the + /// global enumeration of step functions actually fired by edges + /// that stay inside the cycle — callers decide whether that's + /// system-edge intra-SCC firing (system-SCC case) or product- + /// edge intra-product-SCC firing (product-SCC case). + /// + internal static Analysis Compute( + IEnumerable groups, + Func> enabledAt, + IEnumerable taken) + { + var stepById = new Dictionary(); + + // Per-group enabled sets. Materialize so we can intersect. + var perGroup = new List>(); + foreach (var g in groups) + { + var local = new HashSet(); + foreach (var sf in enabledAt(g)) + { + if (sf == null) continue; + local.Add(sf.StepFunctionId); + if (!stepById.ContainsKey(sf.StepFunctionId)) + stepById[sf.StepFunctionId] = sf; + } + perGroup.Add(local); + } + + var enabled = new HashSet(); + foreach (var s in perGroup) enabled.UnionWith(s); + + var continuouslyEnabled = new HashSet(); + if (perGroup.Count > 0) + { + foreach (var id in enabled) + { + bool atAll = true; + for (int i = 0; i < perGroup.Count; i++) + { + if (!perGroup[i].Contains(id)) { atAll = false; break; } + } + if (atAll) continuouslyEnabled.Add(id); + } + } + + var takenSet = new HashSet(); + foreach (var sf in taken) + { + if (sf == null) continue; + takenSet.Add(sf.StepFunctionId); + if (!stepById.ContainsKey(sf.StepFunctionId)) + stepById[sf.StepFunctionId] = sf; + } + + return new Analysis(enabled, continuouslyEnabled, takenSet, stepById); + } + + /// + /// Apply to the analysis. Returns + /// true iff the cycle is fair: every weakly-fair step + /// that is continuously enabled is taken, and every strongly- + /// fair step that is enabled at all is taken. + /// + internal static bool IsFair(Analysis a, Fairness fairness) + { + if (fairness == null) return true; + foreach (var id in a.Enabled) + { + if (!a.StepById.TryGetValue(id, out var rep)) continue; + + if (fairness.WeakFairPredicate(rep) && a.ContinuouslyEnabled.Contains(id)) + { + if (!a.Taken.Contains(id)) return false; + } + if (fairness.StrongFairPredicate(rep)) + { + if (!a.Taken.Contains(id)) return false; + } + } + return true; + } + } +} diff --git a/Accordant.ModelChecking/Fairness.cs b/Accordant.ModelChecking/Fairness.cs new file mode 100644 index 0000000..1603868 --- /dev/null +++ b/Accordant.ModelChecking/Fairness.cs @@ -0,0 +1,155 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Represents fairness constraints for liveness checking. + /// Fairness filters out "unfair" cycles where enabled actions are never taken. + /// + public class Fairness + { + /// + /// No fairness - all cycles are considered valid counterexamples. + /// This is the strictest setting. + /// + public static Fairness None { get; } = new Fairness + { + WeakFairPredicate = _ => false, + StrongFairPredicate = _ => false + }; + + /// + /// Weak fairness on all step functions. + /// "If an action is continuously enabled, it will eventually be taken." + /// This is the default. + /// + public static Fairness WeakFairAll { get; } = new Fairness + { + WeakFairPredicate = _ => true, + StrongFairPredicate = _ => false + }; + + /// + /// Predicate that returns true for step functions that should have weak fairness. + /// + public Func WeakFairPredicate { get; set; } = _ => true; + + /// + /// Predicate that returns true for step functions that should have strong fairness. + /// + public Func StrongFairPredicate { get; set; } = _ => false; + + /// + /// Creates weak fairness for step functions matching the predicate. + /// + public static Fairness WeakFair(Func predicate) + { + return new Fairness + { + WeakFairPredicate = predicate, + StrongFairPredicate = _ => false + }; + } + + /// + /// Creates weak fairness for a specific step function type. + /// + public static Fairness WeakFair() where T : IStepFunction + { + return WeakFair(sf => sf is T); + } + + /// + /// Creates strong fairness for step functions matching the predicate. + /// + public static Fairness StrongFair(Func predicate) + { + return new Fairness + { + WeakFairPredicate = _ => false, + StrongFairPredicate = predicate + }; + } + + /// + /// Creates strong fairness for a specific step function type. + /// + public static Fairness StrongFair() where T : IStepFunction + { + return StrongFair(sf => sf is T); + } + + /// + /// Combines two fairness constraints. + /// + public static Fairness operator +(Fairness a, Fairness b) + { + return new Fairness + { + WeakFairPredicate = sf => a.WeakFairPredicate(sf) || b.WeakFairPredicate(sf), + StrongFairPredicate = sf => a.StrongFairPredicate(sf) || b.StrongFairPredicate(sf) + }; + } + + /// + /// Checks if a cycle (SCC) is fair according to these constraints. + /// A cycle is unfair if there's a fair action that is enabled but + /// never taken inside the SCC. + /// + /// + /// + /// "Enabled at a node" is derived from the node's outgoing edges: + /// the state-graph explorer records an outgoing edge only for a + /// step function whose Apply succeeded on that state, so + /// the set of step-function ids appearing on outgoing edges is + /// exactly the set actually enabled there. The static + /// list is the model- + /// wide step menu and is not a per-state "enabled" set — + /// using it would over-approximate the enabled set and incorrectly + /// reject genuinely fair cycles (e.g., deadlock self-loops where + /// only the stutter step is actually enabled). + /// + /// + /// Definitions: + /// + /// + /// + /// enabledInSCC — union over SCC nodes of "enabled at this + /// node"; the steps that fire infinitely often along any cyclic + /// run through the SCC. + /// + /// + /// continuouslyEnabled — enabled at every SCC node; + /// the steps that are continuously enabled along any cyclic run. + /// + /// + /// takenInSCC — labels of edges whose source and target are + /// both in the SCC. + /// + /// + /// + public bool IsFairCycle(StronglyConnectedComponent scc) + { + var nodesInSCC = new HashSet(scc.Nodes.Select(n => n.GetNodeFingerprint())); + + // Per-node enabled = step functions on the node's outgoing edges. + // Taken-in-cycle = step functions on edges whose source AND + // target are both inside the SCC. + IEnumerable EnabledAt(StateGraphNode n) + => n.Edges.Select(e => e.StepFunction); + + IEnumerable Taken() + { + foreach (var n in scc.Nodes) + foreach (var e in n.Edges) + if (nodesInSCC.Contains(e.Target.GetNodeFingerprint())) + yield return e.StepFunction; + } + + var analysis = CycleFairness.Compute(scc.Nodes, EnabledAt, Taken()); + return CycleFairness.IsFair(analysis, this); + } + } +} diff --git a/Accordant.ModelChecking/IntUnionFind.cs b/Accordant.ModelChecking/IntUnionFind.cs new file mode 100644 index 0000000..d7f4291 --- /dev/null +++ b/Accordant.ModelChecking/IntUnionFind.cs @@ -0,0 +1,125 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + + /// + /// Flat-array disjoint-set (union-find) data structure over non-negative + /// integer keys, with path compression and union-by-rank. + /// + /// + /// The store grows on demand (doubling) so callers may use any + /// non-negative as a key without pre-sizing. Until a + /// key is referenced it is implicitly a singleton class representing + /// itself; materializes the slot lazily. + /// + /// This is a domain-agnostic utility — designed for hash-consed + /// term Ids (e.g., Ere<TPred>.Id) but usable for any + /// integer keying. See EreEquivalenceChecker for the + /// bisimulation use case described in + /// C:\git\ere\cav26\paper.tex, §4 and §6. + /// + /// Not thread-safe. + /// + public sealed class IntUnionFind + { + private int[] _parent; + private byte[] _rank; + private int _capacity; + + /// + /// Creates an empty union-find with the given initial capacity + /// (number of pre-allocated slots; the structure grows on demand). + /// + public IntUnionFind(int initialCapacity = 64) + { + if (initialCapacity < 1) initialCapacity = 1; + _parent = new int[initialCapacity]; + _rank = new byte[initialCapacity]; + _capacity = initialCapacity; + for (int i = 0; i < _capacity; i++) _parent[i] = -1; + } + + /// + /// Returns the representative of 's class, + /// materializing the singleton class {x} if x has not been + /// referenced before. + /// + public int Find(int x) + { + if (x < 0) throw new ArgumentOutOfRangeException(nameof(x)); + EnsureCapacity(x); + if (_parent[x] == -1) + { + _parent[x] = x; + return x; + } + // Path compression via two-pass to avoid recursion stack growth. + int root = x; + while (_parent[root] != root) root = _parent[root]; + int cur = x; + while (_parent[cur] != root) + { + int next = _parent[cur]; + _parent[cur] = root; + cur = next; + } + return root; + } + + /// + /// Returns true iff and + /// are in the same class. Materializes singletons for unseen keys. + /// + public bool InSameClass(int x, int y) => Find(x) == Find(y); + + /// + /// Unions the classes of and . + /// Returns false if they were already in the same class (no + /// change), true if a union was performed. + /// + public bool Union(int x, int y) + { + int rx = Find(x); + int ry = Find(y); + if (rx == ry) return false; + int rkx = _rank[rx]; + int rky = _rank[ry]; + if (rkx < rky) + { + _parent[rx] = ry; + } + else if (rkx > rky) + { + _parent[ry] = rx; + } + else + { + _parent[ry] = rx; + _rank[rx] = (byte)(rkx + 1); + } + return true; + } + + /// + /// Returns true iff has ever been referenced + /// (i.e., its slot is materialized). Unreferenced keys are + /// implicit singletons. + /// + public bool Contains(int x) + { + if (x < 0 || x >= _capacity) return false; + return _parent[x] != -1; + } + + private void EnsureCapacity(int index) + { + if (index < _capacity) return; + int newCap = _capacity; + while (newCap <= index) newCap *= 2; + Array.Resize(ref _parent, newCap); + Array.Resize(ref _rank, newCap); + for (int i = _capacity; i < newCap; i++) _parent[i] = -1; + _capacity = newCap; + } + } +} diff --git a/Accordant.ModelChecking/Ltl/LtlCheck.cs b/Accordant.ModelChecking/Ltl/LtlCheck.cs new file mode 100644 index 0000000..f5c9980 --- /dev/null +++ b/Accordant.ModelChecking/Ltl/LtlCheck.cs @@ -0,0 +1,899 @@ +using Microsoft.Accordant; + +namespace Microsoft.Accordant.ModelChecking.Ltl +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Sentinel step function representing implicit stutter at a deadlocked + /// (no-outgoing-edge) system node. The explicit-LTL backend injects this + /// in so that terminal nodes are + /// treated as self-looping under the standard LTL convention that + /// paths are infinite. This brings the explicit backend into semantic + /// agreement with the symbolic backends (which already stutter at + /// terminals — see line ~153 and + /// line ~95). + /// + public sealed class StutterStep : IStepFunction + { + public static readonly StutterStep Instance = new StutterStep(); + private StutterStep() { } + public string StepFunctionId => ""; + public IList Apply(IState state, IReadOnlyList<(IStepFunction, StateGraphNode)> path) + => null; + } + + /// + /// A node in the product graph: (SystemState, LtlFormula). + /// Used for on-the-fly LTL model checking with derivatives. + /// + public class ProductNode + { + public StateGraphNode SystemNode { get; } + public LtlFormula Formula { get; } + public List Edges { get; } = new List(); + + private readonly string _fingerprint; + + public ProductNode(StateGraphNode systemNode, LtlFormula formula) + { + SystemNode = systemNode; + Formula = formula; + // Use canonical ToString() for structural fingerprint (not just hash code). + // NOTE: ToString-based fingerprinting means syntactically distinct but + // semantically equivalent derivatives are NOT deduplicated. This is the + // dominant scalability limit of the explicit checker; see + // Microsoft.Accordant.ModelChecking/Symbolic/SCALABILITY.md for details. + _fingerprint = $"{systemNode.GetNodeFingerprint()}|{formula}"; + } + + public string GetFingerprint() => _fingerprint; + + public override string ToString() => $"({SystemNode.State}, {Formula})"; + } + + /// + /// An edge in the product graph. + /// + public class ProductEdge + { + public ProductNode Target { get; } + public IStepFunction StepFunction { get; } + + public ProductEdge(ProductNode target, IStepFunction stepFunction) + { + Target = target; + StepFunction = stepFunction; + } + } + + /// + /// SCC in the product graph, wrapping the formula information. + /// + public class ProductSCC + { + public List Nodes { get; } = new List(); + public bool HasCycle { get; internal set; } + + /// + /// Returns all distinct Until formulas that are "active" (still waiting) in any node of this SCC. + /// + public IEnumerable GetActiveUntilFormulas() + { + return Nodes + .SelectMany(n => GetActiveUntils(n.Formula)) + .Distinct(); + } + + private static IEnumerable GetActiveUntils(LtlFormula formula) + { + // An Until is "active" if it appears in the formula (not yet discharged) + return formula.GetUntilSubformulas(); + } + } + + /// + /// LTL model checker using derivative-based on-the-fly construction. + /// Integrates with the existing SCC-based checking infrastructure. + /// + public static class LtlCheck + { + /// + /// Check an arbitrary LTL formula over a state graph. + /// + /// This is the main entry point for full LTL model checking. + /// Uses derivative-based on-the-fly product construction with SCC analysis. + /// + /// The root of the system state graph. + /// The LTL formula to check. + /// Fairness constraints (default: weak fairness on all). + /// Result indicating success or failure with counterexample. + public static PropertyCheckingResult Check( + StateGraphNode root, + LtlFormula formula, + Fairness fairness = null) + { + fairness ??= Fairness.WeakFairAll; + + // Build the product graph on-the-fly + var (productRoot, allNodes) = BuildProductGraph(root, formula); + + // If the initial formula is already false, fail immediately + if (formula.IsFalse) + { + return PropertyCheckingResult.Failure(new List + { + new TraceItem(null, root, isInCycle: false) + }); + } + + // Safety violation detection: any reachable (sys, False) sink node + // indicates a finite-trace counterexample. BuildProductGraph routes + // edges whose derivative becomes False to such sinks (rather than + // silently dropping them) so this BFS will find them. + var safetyTrace = FindSafetyCounterexample(productRoot); + if (safetyTrace != null) + { + return PropertyCheckingResult.Failure(safetyTrace); + } + + // Find SCCs in the product graph + var productSCCs = FindProductSCCs(productRoot, allNodes); + + // Check each SCC for "bad" cycles + foreach (var scc in productSCCs) + { + if (!scc.HasCycle) continue; + + var badSub = FindBadFairSubCycle(scc, fairness); + if (badSub != null) + { + // Found a counterexample + var trace = BuildCounterexampleTrace(productRoot, badSub, allNodes); + var systemSCC = ExtractSystemSCC(badSub); + return PropertyCheckingResult.Failure(trace, systemSCC); + } + } + + return PropertyCheckingResult.Success(); + } + + /// + /// Builds the product graph (System × Formula) on-the-fly using derivatives. + /// + private static (ProductNode root, Dictionary allNodes) BuildProductGraph( + StateGraphNode systemRoot, + LtlFormula initialFormula) + { + var allNodes = new Dictionary(); + var queue = new Queue(); + + var root = new ProductNode(systemRoot, initialFormula); + allNodes[root.GetFingerprint()] = root; + queue.Enqueue(root); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + + // Standard LTL convention: paths are infinite. If the system + // node has no outgoing edges, inject an implicit stutter + // self-loop so the property is evaluated against an infinite + // run rather than dropped silently. This matches the symbolic + // backends' terminal-stutter handling. + if (current.SystemNode.Edges == null || current.SystemNode.Edges.Count == 0) + { + var derivedFormula = current.Formula.Derivative(current.SystemNode.State); + + if (derivedFormula.IsFalse) + { + var rejectSink = new ProductNode(current.SystemNode, derivedFormula); + var rejectFp = rejectSink.GetFingerprint(); + if (!allNodes.TryGetValue(rejectFp, out var existingReject)) + { + allNodes[rejectFp] = rejectSink; + existingReject = rejectSink; + } + current.Edges.Add(new ProductEdge(existingReject, StutterStep.Instance)); + continue; + } + + var stutterTarget = new ProductNode(current.SystemNode, derivedFormula); + var stutterFp = stutterTarget.GetFingerprint(); + if (!allNodes.TryGetValue(stutterFp, out var existingStutter)) + { + allNodes[stutterFp] = stutterTarget; + queue.Enqueue(stutterTarget); + existingStutter = stutterTarget; + } + current.Edges.Add(new ProductEdge(existingStutter, StutterStep.Instance)); + continue; + } + + foreach (var edge in current.SystemNode.Edges) + { + // Compute derivative: what formula remains after seeing this state? + var derivedFormula = current.Formula.Derivative(current.SystemNode.State); + + if (derivedFormula.IsFalse) + { + // Safety violation: this transition would force a False + // continuation. Route to a reject sink (one per target + // system state) so the violation is preserved for the + // safety counterexample search instead of being dropped. + var rejectSink = new ProductNode(edge.Target, derivedFormula); + var rejectFp = rejectSink.GetFingerprint(); + if (!allNodes.TryGetValue(rejectFp, out var existingReject)) + { + allNodes[rejectFp] = rejectSink; + existingReject = rejectSink; + // Do NOT enqueue: a (sys, False) node is a terminal sink. + } + current.Edges.Add(new ProductEdge(existingReject, edge.StepFunction)); + continue; + } + + var successor = new ProductNode(edge.Target, derivedFormula); + var successorFp = successor.GetFingerprint(); + + if (!allNodes.TryGetValue(successorFp, out var existingNode)) + { + allNodes[successorFp] = successor; + queue.Enqueue(successor); + existingNode = successor; + } + + current.Edges.Add(new ProductEdge(existingNode, edge.StepFunction)); + } + } + + return (root, allNodes); + } + + /// + /// BFS from the product root looking for any reachable (sys, False) + /// sink node, which encodes a safety violation: no infinite extension + /// can satisfy the formula past that transition. Returns the trace from + /// root to the sink (inclusive), or null if no safety violation is + /// reachable. + /// + private static List FindSafetyCounterexample(ProductNode productRoot) + { + var visited = new Dictionary(); + visited[productRoot.GetFingerprint()] = (null, null); + var queue = new Queue(); + queue.Enqueue(productRoot); + + ProductNode rejectNode = null; + while (queue.Count > 0) + { + var n = queue.Dequeue(); + if (n.Formula.IsFalse && !ReferenceEquals(n, productRoot)) + { + rejectNode = n; + break; + } + foreach (var e in n.Edges) + { + var tid = e.Target.GetFingerprint(); + if (!visited.ContainsKey(tid)) + { + visited[tid] = (n, e.StepFunction); + queue.Enqueue(e.Target); + } + } + } + + if (rejectNode == null) return null; + + // Reconstruct path root → ... → rejectNode + var path = new List<(ProductNode node, IStepFunction step)>(); + var cur = rejectNode; + while (cur != null) + { + var (parent, step) = visited[cur.GetFingerprint()]; + path.Add((cur, step)); + cur = parent; + } + path.Reverse(); + + var trace = new List(); + foreach (var (node, step) in path) + { + trace.Add(new TraceItem(step, node.SystemNode, isInCycle: false)); + } + return trace; + } + + /// + /// Find SCCs in the product graph using Tarjan's algorithm. + /// + private static List FindProductSCCs( + ProductNode root, + Dictionary allNodes) + { + var result = new List(); + var indexMap = new Dictionary(); + var lowLinkMap = new Dictionary(); + var onStack = new HashSet(); + var stack = new Stack(); + int index = 0; + + void StrongConnect(ProductNode node) + { + var nodeId = node.GetFingerprint(); + indexMap[nodeId] = index; + lowLinkMap[nodeId] = index; + index++; + stack.Push(node); + onStack.Add(nodeId); + + foreach (var edge in node.Edges) + { + var successor = edge.Target; + var successorId = successor.GetFingerprint(); + + if (!indexMap.ContainsKey(successorId)) + { + StrongConnect(successor); + lowLinkMap[nodeId] = Math.Min(lowLinkMap[nodeId], lowLinkMap[successorId]); + } + else if (onStack.Contains(successorId)) + { + lowLinkMap[nodeId] = Math.Min(lowLinkMap[nodeId], indexMap[successorId]); + } + } + + if (lowLinkMap[nodeId] == indexMap[nodeId]) + { + var scc = new ProductSCC(); + ProductNode w; + do + { + w = stack.Pop(); + onStack.Remove(w.GetFingerprint()); + scc.Nodes.Add(w); + } while (w.GetFingerprint() != nodeId); + + scc.HasCycle = DetermineHasCycle(scc); + result.Add(scc); + } + } + + StrongConnect(root); + return result; + } + + private static bool DetermineHasCycle(ProductSCC scc) + { + if (scc.Nodes.Count > 1) + return true; + + if (scc.Nodes.Count == 1) + { + var node = scc.Nodes[0]; + var nodeId = node.GetFingerprint(); + return node.Edges.Any(e => e.Target.GetFingerprint() == nodeId); + } + + return false; + } + + /// + /// Check if an SCC is a "bad" cycle (violates the LTL formula). + /// A cycle is bad if some Until obligation is never discharged. + /// + /// Key insight: An Until (φ U ψ) represents an obligation that ψ must eventually hold. + /// But if the Until never becomes "active" (i.e., we're never in a state where we're + /// waiting for ψ), then it's not a violation. + /// + /// + /// Find a "bad" sub-cycle of that simultaneously + /// (a) witnesses an undischarged Until obligation and + /// (b) is fair with respect to . + /// Returns the offending sub-SCC, or null if no such cycle exists. + /// + /// Standard LTL acceptance check: for each Until (φ U ψ) in the SCC, + /// project to the subset of nodes where ψ does NOT yet hold; if that + /// subgraph contains a non-trivial SCC, the Until is never discharged + /// along an infinite path through it. We additionally require the + /// witnessing sub-cycle to satisfy the system-level fairness constraint. + /// + private static ProductSCC FindBadFairSubCycle(ProductSCC scc, Fairness fairness) + { + var allUntils = scc.GetActiveUntilFormulas().ToList(); + + foreach (var until in allUntils) + { + var noGoal = scc.Nodes + .Where(n => !until.Goal.Derivative(n.SystemNode.State).IsTrue) + .ToList(); + + if (noGoal.Count == 0) + continue; // ψ holds in every SCC state → Until always discharged + + bool untilActiveSomewhere = noGoal.Any(n => + ContainsActiveUntil(n.Formula, until, n.SystemNode.State)); + + if (!untilActiveSomewhere) + continue; // pending Until is dormant everywhere ψ fails — vacuous + + foreach (var subScc in FindSubSCCsWithCycle(noGoal)) + { + if (IsFairCycle(subScc, fairness)) + return subScc; + } + } + + return null; + } + + /// + /// Enumerate all non-trivial SCCs (size >1, or a single node with a + /// self-loop) of the subgraph induced by , + /// considering only edges whose targets are also in the subset. + /// Uses an iterative Tarjan to avoid deep-recursion stack overflows. + /// + private static IEnumerable FindSubSCCsWithCycle(IReadOnlyList subset) + { + var results = new List(); + if (subset.Count == 0) return results; + var ids = new HashSet(subset.Select(n => n.GetFingerprint())); + + var indexMap = new Dictionary(); + var lowLink = new Dictionary(); + var onStack = new HashSet(); + var sccStack = new Stack(); + int index = 0; + + foreach (var start in subset) + { + if (indexMap.ContainsKey(start.GetFingerprint())) continue; + + var work = new Stack<(ProductNode node, IEnumerator it)>(); + + void Push(ProductNode n) + { + var nid = n.GetFingerprint(); + indexMap[nid] = index; + lowLink[nid] = index; + index++; + sccStack.Push(n); + onStack.Add(nid); + var edges = n.Edges + .Where(e => ids.Contains(e.Target.GetFingerprint())) + .GetEnumerator(); + work.Push((n, edges)); + } + + Push(start); + + while (work.Count > 0) + { + var (node, it) = work.Peek(); + var nid = node.GetFingerprint(); + + if (it.MoveNext()) + { + var target = it.Current.Target; + var tid = target.GetFingerprint(); + if (!indexMap.ContainsKey(tid)) + { + Push(target); + } + else if (onStack.Contains(tid)) + { + lowLink[nid] = Math.Min(lowLink[nid], indexMap[tid]); + } + } + else + { + work.Pop(); + if (work.Count > 0) + { + var parentId = work.Peek().node.GetFingerprint(); + lowLink[parentId] = Math.Min(lowLink[parentId], lowLink[nid]); + } + + if (lowLink[nid] == indexMap[nid]) + { + var members = new List(); + ProductNode w; + do + { + w = sccStack.Pop(); + onStack.Remove(w.GetFingerprint()); + members.Add(w); + } while (w.GetFingerprint() != nid); + + bool hasCycle = members.Count > 1 + || node.Edges.Any(e => + ids.Contains(e.Target.GetFingerprint()) && + e.Target.GetFingerprint() == nid); + + if (hasCycle) + { + var sub = new ProductSCC(); + foreach (var m in members) sub.Nodes.Add(m); + sub.HasCycle = true; + results.Add(sub); + } + } + } + } + } + + return results; + } + + /// + /// Check if a specific Until formula is "actively pending" in the given formula. + /// An Until is pending if it appears at a position where it could actually fail + /// (not inside an Or where another branch is already satisfied at this state). + /// + private static bool ContainsActiveUntil(LtlFormula formula, LtlUntil targetUntil, IState state) + { + // Base cases + if (formula.IsTrue || formula.IsFalse) + return false; + + if (formula.Equals(targetUntil)) + return true; + + // Recursive cases + if (formula is LtlAnd and) + { + // Until is active in And if it's active in ANY child + return and.Children.Any(c => ContainsActiveUntil(c, targetUntil, state)); + } + + if (formula is LtlOr or) + { + // Until is active in Or only if we can't satisfy the Or without the Until. + // Check if any non-Until-containing branch evaluates to true. + foreach (var child in or.Children) + { + // Check if this child can satisfy the Or without needing the Until + if (!ContainsUntil(child, targetUntil)) + { + // This child doesn't contain the Until - check if it evaluates to true + var childDeriv = child.Derivative(state); + if (childDeriv.IsTrue) + { + // This branch satisfies the Or, so Until is dormant + return false; + } + } + } + // All non-Until branches are false, so Until is active + return or.Children.Any(c => ContainsActiveUntil(c, targetUntil, state)); + } + + if (formula is LtlNot not) + return ContainsActiveUntil(not.Inner, targetUntil, state); + + if (formula is LtlNext next) + return ContainsActiveUntil(next.Inner, targetUntil, state); + + if (formula is LtlUntil until) + { + if (formula.Equals(targetUntil)) + return true; + return ContainsActiveUntil(until.Hold, targetUntil, state) || + ContainsActiveUntil(until.Goal, targetUntil, state); + } + + if (formula is LtlRelease release) + { + return ContainsActiveUntil(release.Release_, targetUntil, state) || + ContainsActiveUntil(release.Hold, targetUntil, state); + } + + return false; + } + + /// + /// Check if a formula structurally contains a specific Until (without state evaluation). + /// + private static bool ContainsUntil(LtlFormula formula, LtlUntil targetUntil) + { + if (formula.IsTrue || formula.IsFalse) + return false; + + if (formula.Equals(targetUntil)) + return true; + + if (formula is LtlAnd and) + return and.Children.Any(c => ContainsUntil(c, targetUntil)); + + if (formula is LtlOr or) + return or.Children.Any(c => ContainsUntil(c, targetUntil)); + + if (formula is LtlNot not) + return ContainsUntil(not.Inner, targetUntil); + + if (formula is LtlNext next) + return ContainsUntil(next.Inner, targetUntil); + + if (formula is LtlUntil until) + { + if (formula.Equals(targetUntil)) + return true; + return ContainsUntil(until.Hold, targetUntil) || + ContainsUntil(until.Goal, targetUntil); + } + + if (formula is LtlRelease release) + { + return ContainsUntil(release.Release_, targetUntil) || + ContainsUntil(release.Hold, targetUntil); + } + + return false; + } + + /// + /// Check whether a product SCC corresponds to a fair cycle under + /// system-level fairness constraints. + /// + /// + /// + /// The projected run through the product SCC induces a system + /// trace that visits exactly the distinct system states underlying + /// the SCC's product nodes. Fairness is a property of that + /// projected system trace, so: + /// + /// + /// + /// enabled at a visited system state s is the set of + /// step functions appearing on s's system outgoing + /// edges (system-level enablement; independent of the NBW + /// component). + /// + /// + /// taken in the cycle is the set of step functions on + /// product edges whose source and target both lie in + /// the product SCC. Using system edges here over-counts: + /// a system edge s→s' may exist but no product edge between + /// formula-variants of s and s' may stay within the SCC, so + /// that step is never actually taken by the projected run. + /// The previous implementation extracted a system-only SCC + /// and used , which made + /// exactly this over-counting error and could classify + /// genuinely unfair product cycles as fair, leading to + /// spurious counterexamples on LTL liveness properties. + /// + /// + /// + /// + /// Public for testability. Internal callers (the SCC pass above) + /// invoke this directly. + /// + public static bool IsFairCycle(ProductSCC productSCC, Fairness fairness) + { + // Unique system nodes visited by the product SCC. + var systemNodes = new Dictionary(); + foreach (var pn in productSCC.Nodes) + { + var fp = pn.SystemNode.GetNodeFingerprint(); + if (!systemNodes.ContainsKey(fp)) + systemNodes[fp] = pn.SystemNode; + } + + // "enabled at group" comes from system edges (system-level + // enablement). "taken" comes from product edges whose source + // AND target are both inside the product SCC. + var productSccFps = new HashSet(productSCC.Nodes.Select(n => n.GetFingerprint())); + + IEnumerable EnabledAt(StateGraphNode sys) + => sys.Edges.Select(e => e.StepFunction); + + IEnumerable Taken() + { + foreach (var pn in productSCC.Nodes) + foreach (var edge in pn.Edges) + if (productSccFps.Contains(edge.Target.GetFingerprint())) + yield return edge.StepFunction; + } + + var analysis = CycleFairness.Compute(systemNodes.Values, EnabledAt, Taken()); + return CycleFairness.IsFair(analysis, fairness); + } + + /// + /// Project a product SCC to its underlying system-level SCC. + /// Used purely for reporting (attaching the SCC to a + /// ); fairness is checked + /// against the product SCC directly to avoid the over-counting + /// trap described on . + /// + private static StronglyConnectedComponent ExtractSystemSCC(ProductSCC productSCC) + { + var systemSCC = new StronglyConnectedComponent(); + + var seenSystemNodes = new HashSet(); + foreach (var productNode in productSCC.Nodes) + { + var systemFp = productNode.SystemNode.GetNodeFingerprint(); + if (seenSystemNodes.Add(systemFp)) + { + systemSCC.Nodes.Add(productNode.SystemNode); + } + } + + systemSCC.GetType() + .GetProperty(nameof(StronglyConnectedComponent.HasCycle)) + ?.SetValue(systemSCC, productSCC.HasCycle); + + return systemSCC; + } + + /// + /// Build a counterexample trace from the root to the bad cycle. + /// + private static List BuildCounterexampleTrace( + ProductNode root, + ProductSCC badSCC, + Dictionary allNodes) + { + var trace = new List(); + var sccNodeIds = new HashSet(badSCC.Nodes.Select(n => n.GetFingerprint())); + + // BFS to find path from root to SCC + var visited = new Dictionary(); + var queue = new Queue(); + + visited[root.GetFingerprint()] = (null, null); + queue.Enqueue(root); + + ProductNode entryNode = null; + + while (queue.Count > 0 && entryNode == null) + { + var node = queue.Dequeue(); + var nodeId = node.GetFingerprint(); + + if (sccNodeIds.Contains(nodeId)) + { + entryNode = node; + break; + } + + foreach (var edge in node.Edges) + { + var successorId = edge.Target.GetFingerprint(); + if (!visited.ContainsKey(successorId)) + { + visited[successorId] = (node, edge.StepFunction); + queue.Enqueue(edge.Target); + } + } + } + + if (entryNode == null) + return trace; + + // Reconstruct path from root to entry node + var pathNodes = new List<(ProductNode node, IStepFunction step)>(); + var current = entryNode; + while (current != null) + { + var currentId = current.GetFingerprint(); + var (parent, step) = visited[currentId]; + pathNodes.Add((current, step)); + current = parent; + } + + pathNodes.Reverse(); + + // Add path to trace (not in cycle) + foreach (var (node, step) in pathNodes) + { + trace.Add(new TraceItem(step, node.SystemNode, isInCycle: false)); + } + + // Add cycle portion + var cycleNode = entryNode; + var cycleVisited = new HashSet { cycleNode.GetFingerprint() }; + + for (int i = 0; i < Math.Min(badSCC.Nodes.Count, 5); i++) + { + foreach (var edge in cycleNode.Edges) + { + var edgeTargetId = edge.Target.GetFingerprint(); + if (sccNodeIds.Contains(edgeTargetId) && !cycleVisited.Contains(edgeTargetId)) + { + trace.Add(new TraceItem(edge.StepFunction, edge.Target.SystemNode, isInCycle: true)); + cycleVisited.Add(edgeTargetId); + cycleNode = edge.Target; + break; + } + } + } + + return trace; + } + + #region Convenience Methods (Mapping to Existing Interface) + + /// + /// Check that a property always holds in every reachable state. + /// Equivalent to Check.Always() but using LTL infrastructure. + /// + /// LTL formula: □P (G P) + /// + public static PropertyCheckingResult Always( + StateGraphNode root, + Func predicate) + { + var p = LtlFormula.Prop(predicate, "P"); + return Check(root, LtlFormula.Always(p), Fairness.None); + } + + /// + /// Check that a property eventually becomes true on all paths. + /// Equivalent to Check.Eventually() but using LTL infrastructure. + /// + /// LTL formula: ◇P (F P) + /// + public static PropertyCheckingResult Eventually( + StateGraphNode root, + Func predicate, + Fairness fairness = null) + { + var p = LtlFormula.Prop(predicate, "P"); + return Check(root, LtlFormula.Eventually(p), fairness); + } + + /// + /// Check that a property holds infinitely often. + /// Equivalent to Check.InfinitelyOften() but using LTL infrastructure. + /// + /// LTL formula: □◇P (GF P) + /// + public static PropertyCheckingResult InfinitelyOften( + StateGraphNode root, + Func predicate, + Fairness fairness = null) + { + var p = LtlFormula.Prop(predicate, "P"); + return Check(root, LtlFormula.InfinitelyOften(p), fairness); + } + + /// + /// Leads-to (response) property: whenever P holds, Q eventually holds. + /// Equivalent to Check.LeadsTo() but using LTL infrastructure. + /// + /// LTL formula: □(P → ◇Q) + /// + public static PropertyCheckingResult LeadsTo( + StateGraphNode root, + Func trigger, + Func response, + Fairness fairness = null) + { + var p = LtlFormula.Prop(trigger, "P"); + var q = LtlFormula.Prop(response, "Q"); + return Check(root, LtlFormula.LeadsTo(p, q), fairness); + } + + /// + /// Check that a property eventually stabilizes (becomes true and stays true). + /// Equivalent to Check.Stabilizes() but using LTL infrastructure. + /// + /// LTL formula: ◇□P (FG P) + /// + public static PropertyCheckingResult Stabilizes( + StateGraphNode root, + Func predicate, + Fairness fairness = null) + { + var p = LtlFormula.Prop(predicate, "P"); + return Check(root, LtlFormula.Stabilizes(p), fairness); + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Ltl/LtlFormula.cs b/Accordant.ModelChecking/Ltl/LtlFormula.cs new file mode 100644 index 0000000..ea35d5c --- /dev/null +++ b/Accordant.ModelChecking/Ltl/LtlFormula.cs @@ -0,0 +1,942 @@ +using Microsoft.Accordant; + +namespace Microsoft.Accordant.ModelChecking.Ltl +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Helper for hash code computation (netstandard2.0 compatible). + /// + internal static class HashHelper + { + public static int Combine(object a, object b) + { + unchecked + { + int hash = 17; + hash = hash * 31 + (a?.GetHashCode() ?? 0); + hash = hash * 31 + (b?.GetHashCode() ?? 0); + return hash; + } + } + + public static int Combine(object a, object b, object c) + { + unchecked + { + int hash = 17; + hash = hash * 31 + (a?.GetHashCode() ?? 0); + hash = hash * 31 + (b?.GetHashCode() ?? 0); + hash = hash * 31 + (c?.GetHashCode() ?? 0); + return hash; + } + } + } + + /// + /// Base class for LTL (Linear Temporal Logic) formulas. + /// Supports full LTL: propositions, boolean operators, and temporal operators + /// (Next, Until, Release, and derived operators Eventually, Always). + /// + /// Uses derivative-based semantics for efficient on-the-fly model checking. + /// + public abstract class LtlFormula : IEquatable + { + // Cached hash code for efficient dictionary/set operations + private int? _cachedHashCode; + + /// + /// Compute the derivative of this formula with respect to a state. + /// The derivative δ(φ, s) represents "what remains to be satisfied after seeing state s". + /// + public abstract LtlFormula Derivative(IState state); + + /// + /// Returns all Until subformulas in this formula (for acceptance condition checking). + /// + public abstract IEnumerable GetUntilSubformulas(); + + /// + /// Check if this formula is syntactically equivalent to True. + /// + public virtual bool IsTrue => false; + + /// + /// Check if this formula is syntactically equivalent to False. + /// + public virtual bool IsFalse => false; + + public abstract bool Equals(LtlFormula other); + public abstract override int GetHashCode(); + + public override bool Equals(object obj) => Equals(obj as LtlFormula); + + protected int GetCachedHashCode(Func compute) + { + _cachedHashCode ??= compute(); + return _cachedHashCode.Value; + } + + /// + /// Structural kind ordinal used by to give + /// formulas of different syntactic categories a deterministic relative + /// order. Values are stable within a process run. + /// + internal abstract int Kind { get; } + + /// + /// Deterministic total order over values. + /// Used by and to canonicalize + /// operand order (commutativity) so that ACI-equivalent conjunctions / + /// disjunctions hash-cons to the same node. Compares by + /// first, then recursively on subformulas. For + /// compares by Name (nulls last) then by + /// + /// on the predicate delegate to break ties; the latter is stable within + /// a process run but not across runs. + /// + internal static int CompareStructural(LtlFormula a, LtlFormula b) + { + if (ReferenceEquals(a, b)) return 0; + int kc = a.Kind.CompareTo(b.Kind); + if (kc != 0) return kc; + switch (a) + { + case LtlTrue _: + case LtlFalse _: + return 0; + case LtlProp pa: + { + var pb = (LtlProp)b; + int nc = string.CompareOrdinal(pa.Name, pb.Name); + if (nc != 0) return nc; + return System.Runtime.CompilerServices.RuntimeHelpers + .GetHashCode(pa.Predicate) + .CompareTo(System.Runtime.CompilerServices.RuntimeHelpers + .GetHashCode(pb.Predicate)); + } + case LtlNot na: + return CompareStructural(na.Inner, ((LtlNot)b).Inner); + case LtlNext nx: + return CompareStructural(nx.Inner, ((LtlNext)b).Inner); + case LtlUntil u: + { + var ub = (LtlUntil)b; + int c = CompareStructural(u.Hold, ub.Hold); + return c != 0 ? c : CompareStructural(u.Goal, ub.Goal); + } + case LtlRelease r: + { + var rb = (LtlRelease)b; + int c = CompareStructural(r.Release_, rb.Release_); + return c != 0 ? c : CompareStructural(r.Hold, rb.Hold); + } + case LtlAnd an: + { + var bn = (LtlAnd)b; + int lc = an.Children.Count.CompareTo(bn.Children.Count); + if (lc != 0) return lc; + for (int i = 0; i < an.Children.Count; i++) + { + int cc = CompareStructural(an.Children[i], bn.Children[i]); + if (cc != 0) return cc; + } + return 0; + } + case LtlOr ao: + { + var bo = (LtlOr)b; + int lc = ao.Children.Count.CompareTo(bo.Children.Count); + if (lc != 0) return lc; + for (int i = 0; i < ao.Children.Count; i++) + { + int cc = CompareStructural(ao.Children[i], bo.Children[i]); + if (cc != 0) return cc; + } + return 0; + } + default: + throw new InvalidOperationException( + $"Unhandled LtlFormula kind: {a.GetType().Name}"); + } + } + + internal sealed class StructuralComparer : IComparer + { + public static readonly StructuralComparer Instance = new StructuralComparer(); + public int Compare(LtlFormula x, LtlFormula y) => CompareStructural(x, y); + } + + /// + /// Reference-identity equality comparer for + /// delegates. Matches , which + /// also uses . + /// + internal sealed class PredicateRefComparer : IEqualityComparer> + { + public static readonly PredicateRefComparer Instance = new PredicateRefComparer(); + public bool Equals(Func x, Func y) => ReferenceEquals(x, y); + public int GetHashCode(Func obj) => + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } + + // ============ Static Factory Methods ============ + + /// True constant. + public static LtlFormula True { get; } = LtlTrue.Instance; + + /// False constant. + public static LtlFormula False { get; } = LtlFalse.Instance; + + /// Creates a proposition from a state predicate. + public static LtlFormula Prop(Func predicate, string name = null) + => new LtlProp(predicate, name); + + /// Negation: ¬φ + public static LtlFormula Not(LtlFormula inner) => LtlNot.Create(inner); + + /// Conjunction: φ ∧ ψ + public static LtlFormula And(LtlFormula left, LtlFormula right) => LtlAnd.Create(left, right); + + /// Conjunction of multiple formulas. + public static LtlFormula And(params LtlFormula[] formulas) + => formulas.Aggregate(True, (acc, f) => And(acc, f)); + + /// Disjunction: φ ∨ ψ + public static LtlFormula Or(LtlFormula left, LtlFormula right) => LtlOr.Create(left, right); + + /// Disjunction of multiple formulas. + public static LtlFormula Or(params LtlFormula[] formulas) + => formulas.Aggregate(False, (acc, f) => Or(acc, f)); + + /// Implication: φ → ψ (equivalent to ¬φ ∨ ψ) + public static LtlFormula Implies(LtlFormula antecedent, LtlFormula consequent) + => Or(Not(antecedent), consequent); + + /// Next: Xφ — φ holds in the next state. + /// On infinite traces (the model's convention; terminal states get a + /// stutter self-loop) X ⊤ ≡ ⊤ and X ⊥ ≡ ⊥, so the + /// constants are short-circuited at construction. + public static LtlFormula Next(LtlFormula inner) + { + if (inner.IsTrue) return True; + if (inner.IsFalse) return False; + return new LtlNext(inner); + } + + /// Until: φ U ψ — φ holds until ψ becomes true (ψ must eventually hold). + public static LtlFormula Until(LtlFormula hold, LtlFormula goal) => LtlUntil.Create(hold, goal); + + /// Release: φ R ψ — ψ holds until and including when φ becomes true (or forever if φ never holds). + public static LtlFormula Release(LtlFormula release, LtlFormula hold) => LtlRelease.Create(release, hold); + + /// Eventually: ◇φ (F φ) — φ holds at some future state. Equivalent to true U φ. + public static LtlFormula Eventually(LtlFormula inner) => Until(True, inner); + + /// Always: □φ (G φ) — φ holds in all future states. Equivalent to false R φ. + public static LtlFormula Always(LtlFormula inner) => Release(False, inner); + + /// Infinitely Often: □◇φ (GF φ) — φ holds infinitely often. + public static LtlFormula InfinitelyOften(LtlFormula inner) => Always(Eventually(inner)); + + /// Stabilizes: ◇□φ (FG φ) — φ eventually becomes true and stays true forever. + public static LtlFormula Stabilizes(LtlFormula inner) => Eventually(Always(inner)); + + /// Leads-To: φ ~> ψ — whenever φ holds, ψ eventually holds. Equivalent to □(φ → ◇ψ). + public static LtlFormula LeadsTo(LtlFormula trigger, LtlFormula response) + => Always(Implies(trigger, Eventually(response))); + + // ============ Operator Overloads for Fluent API ============ + + public static LtlFormula operator &(LtlFormula left, LtlFormula right) => And(left, right); + public static LtlFormula operator |(LtlFormula left, LtlFormula right) => Or(left, right); + public static LtlFormula operator !(LtlFormula inner) => Not(inner); + } + + // ============ Concrete Formula Types ============ + + /// True constant — always satisfied. + public sealed class LtlTrue : LtlFormula + { + public static LtlTrue Instance { get; } = new LtlTrue(); + private LtlTrue() { } + + public override bool IsTrue => true; + internal override int Kind => 1; + public override LtlFormula Derivative(IState state) => this; + public override IEnumerable GetUntilSubformulas() => Enumerable.Empty(); + public override bool Equals(LtlFormula other) => other is LtlTrue; + public override int GetHashCode() => 1; + public override string ToString() => "true"; + } + + /// False constant — never satisfied. + public sealed class LtlFalse : LtlFormula + { + public static LtlFalse Instance { get; } = new LtlFalse(); + private LtlFalse() { } + + public override bool IsFalse => true; + internal override int Kind => 2; + public override LtlFormula Derivative(IState state) => this; + public override IEnumerable GetUntilSubformulas() => Enumerable.Empty(); + public override bool Equals(LtlFormula other) => other is LtlFalse; + public override int GetHashCode() => 0; + public override string ToString() => "false"; + } + + /// Atomic proposition — evaluates a predicate on the current state. + public sealed class LtlProp : LtlFormula + { + public Func Predicate { get; } + public string Name { get; } + + public LtlProp(Func predicate, string name = null) + { + Predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + Name = name; + } + + internal override int Kind => 3; + + public override LtlFormula Derivative(IState state) + => Predicate(state) ? True : False; + + public override IEnumerable GetUntilSubformulas() => Enumerable.Empty(); + + public override bool Equals(LtlFormula other) + => other is LtlProp prop && ReferenceEquals(Predicate, prop.Predicate); + + public override int GetHashCode() + => GetCachedHashCode(() => Predicate.GetHashCode()); + + public override string ToString() => Name ?? "prop"; + } + + /// + /// Negation: ¬φ. By construction, is always a + /// — pushes + /// negation to atoms via De Morgan and temporal-operator duality + /// (negation normal form, NNF). This keeps the formula tree in a + /// canonical shape so syntactically distinct but semantically + /// equivalent formulas hash-cons to the same node. + /// + public sealed class LtlNot : LtlFormula + { + public LtlFormula Inner { get; } + + private LtlNot(LtlFormula inner) => Inner = inner; + + internal override int Kind => 4; + + /// + /// Smart constructor that pushes negation to atoms (NNF). Rewrites: + /// + /// ¬⊤ → ⊥, ¬⊥ → ⊤ + /// ¬¬φ → φ + /// ¬(φ ∧ ψ) → ¬φ ∨ ¬ψ (De Morgan) + /// ¬(φ ∨ ψ) → ¬φ ∧ ¬ψ (De Morgan) + /// ¬Xφ → X¬φ + /// ¬(φ U ψ) → (¬φ) R (¬ψ) (LTL duality) + /// ¬(φ R ψ) → (¬φ) U (¬ψ) (LTL duality) + /// + /// The only Not nodes that ever survive wrap an . + /// + public static LtlFormula Create(LtlFormula inner) + { + switch (inner) + { + case LtlTrue _: return False; + case LtlFalse _: return True; + case LtlNot not: return not.Inner; + case LtlAnd and: + { + LtlFormula acc = False; + foreach (var c in and.Children) + acc = Or(acc, Create(c)); + return acc; + } + case LtlOr or: + { + LtlFormula acc = True; + foreach (var c in or.Children) + acc = And(acc, Create(c)); + return acc; + } + case LtlNext nx: + return Next(Create(nx.Inner)); + case LtlUntil u: + return Release(Create(u.Hold), Create(u.Goal)); + case LtlRelease r: + return Until(Create(r.Release_), Create(r.Hold)); + case LtlProp _: + return new LtlNot(inner); + default: + throw new InvalidOperationException( + $"Unhandled LtlFormula kind in Not: {inner.GetType().Name}"); + } + } + + // After NNF, Inner is always an LtlProp, whose derivative is True or + // False, so Not(True) = False / Not(False) = True via Create above. + public override LtlFormula Derivative(IState state) + => Not(Inner.Derivative(state)); + + public override IEnumerable GetUntilSubformulas() + => Inner.GetUntilSubformulas(); + + public override bool Equals(LtlFormula other) + => other is LtlNot not && Inner.Equals(not.Inner); + + public override int GetHashCode() + => GetCachedHashCode(() => HashHelper.Combine(typeof(LtlNot), Inner)); + + public override string ToString() => $"¬{Inner}"; + } + + /// Conjunction: φ ∧ ψ - stored in canonical (sorted) form. + /// + /// Conjunction: φ ∧ ψ — stored in canonical ACI-normal form. Children are + /// flattened (associativity), deduplicated (idempotency), and sorted by + /// (commutativity). ⊤ operands + /// are dropped and ⊥ short-circuits to . + /// + public sealed class LtlAnd : LtlFormula + { + // Children stored in canonical (structurally-sorted) order + public IReadOnlyList Children { get; } + + private LtlAnd(IReadOnlyList children) + { + Children = children; + } + + internal override int Kind => 5; + + public static LtlFormula Create(LtlFormula left, LtlFormula right) + { + // Collect all children, flattening nested Ands (associativity) + var children = new List(); + CollectAndChildren(left, children); + CollectAndChildren(right, children); + + // Track polarity of literal operands (post-NNF, a literal is either + // an LtlProp or an LtlNot(LtlProp)). If both p and ¬p appear in the + // same conjunction the whole thing is ⊥ (complementary-literal + // elimination). Predicate identity is by reference (matches + // LtlProp.Equals which uses ReferenceEquals on the predicate). + var positives = new HashSet>( + PredicateRefComparer.Instance); + var negatives = new HashSet>( + PredicateRefComparer.Instance); + + // Remove duplicates (idempotency: a ∧ a = a) + var unique = new HashSet(); + var filtered = new List(); + foreach (var child in children) + { + // Simplification: false ∧ φ = false + if (child.IsFalse) return False; + // Simplification: true ∧ φ = φ (skip true) + if (child.IsTrue) continue; + + // Complementary-literal elimination. + if (child is LtlProp p) + { + if (negatives.Contains(p.Predicate)) return False; + positives.Add(p.Predicate); + } + else if (child is LtlNot not && not.Inner is LtlProp np) + { + if (positives.Contains(np.Predicate)) return False; + negatives.Add(np.Predicate); + } + + if (unique.Add(child)) + filtered.Add(child); + } + + if (filtered.Count == 0) return True; + if (filtered.Count == 1) return filtered[0]; + + // Deep absorption (context-conditioning). + // A ∧ (Y ∨ A) = A (Or short-circuits to ⊤) + // A ∧ (Y ∨ (A ∧ X)) = A ∧ (Y ∨ X) (drop A inside the inner And) + // Rationale: in this conjunction every sibling-conjunct is required, + // so whenever an Or-child has a disjunct that requires only siblings + // of the outer And, that disjunct is already implied; whenever an + // Or-child has an And-disjunct containing a sibling-conjunct, that + // sibling factor is redundant inside the And. This is the + // single simplification that prevents the alternating ∧/∨ chain + // produced by nested Release derivatives from growing without + // bound (see Depth3 blowup probe). + var siblingSet = new HashSet(filtered); + bool changedAbs = true; + while (changedAbs) + { + changedAbs = false; + for (int i = 0; i < filtered.Count; i++) + { + if (!(filtered[i] is LtlOr orChild)) continue; + var newDisjuncts = new List(orChild.Children.Count); + bool orPruned = false; + bool orIsTrue = false; + foreach (var d in orChild.Children) + { + if (siblingSet.Contains(d)) { orIsTrue = true; break; } + if (d is LtlAnd dAnd) + { + var kept = new List(dAnd.Children.Count); + bool prunedInner = false; + foreach (var c in dAnd.Children) + { + if (siblingSet.Contains(c)) prunedInner = true; + else kept.Add(c); + } + if (prunedInner) + { + orPruned = true; + if (kept.Count == 0) { orIsTrue = true; break; } + LtlFormula rebuilt = True; + foreach (var c in kept) rebuilt = And(rebuilt, c); + newDisjuncts.Add(rebuilt); + } + else newDisjuncts.Add(d); + } + else newDisjuncts.Add(d); + } + if (orIsTrue) + { + siblingSet.Remove(orChild); + filtered.RemoveAt(i); + i--; + changedAbs = true; + continue; + } + if (orPruned) + { + LtlFormula newOr = False; + foreach (var d in newDisjuncts) newOr = Or(newOr, d); + siblingSet.Remove(orChild); + if (newOr.IsFalse) return False; + if (newOr.IsTrue) + { + filtered.RemoveAt(i); + i--; + } + else + { + filtered[i] = newOr; + siblingSet.Add(newOr); + } + changedAbs = true; + } + } + } + + if (filtered.Count == 0) return True; + if (filtered.Count == 1) return filtered[0]; + + // Deterministic structural sort for canonical order (commutativity). + // Replaces the previous hash-code sort, which was non-canonical under + // hash collisions. + filtered.Sort(StructuralComparer.Instance); + + return new LtlAnd(filtered); + } + + private static void CollectAndChildren(LtlFormula formula, List children) + { + if (formula is LtlAnd and) + { + foreach (var child in and.Children) + children.Add(child); + } + else + { + children.Add(formula); + } + } + + public override LtlFormula Derivative(IState state) + { + LtlFormula result = True; + foreach (var child in Children) + result = And(result, child.Derivative(state)); + return result; + } + + public override IEnumerable GetUntilSubformulas() + => Children.SelectMany(c => c.GetUntilSubformulas()); + + public override bool Equals(LtlFormula other) + { + if (!(other is LtlAnd and) || Children.Count != and.Children.Count) + return false; + for (int i = 0; i < Children.Count; i++) + if (!Children[i].Equals(and.Children[i])) + return false; + return true; + } + + public override int GetHashCode() + => GetCachedHashCode(() => + { + unchecked + { + int hash = 17 * 31 + typeof(LtlAnd).GetHashCode(); + foreach (var child in Children) + hash = hash * 31 + child.GetHashCode(); + return hash; + } + }); + + public override string ToString() => $"({string.Join(" ∧ ", Children)})"; + } + + /// + /// Disjunction: φ ∨ ψ — stored in canonical ACI-normal form. Children are + /// flattened (associativity), deduplicated (idempotency), and sorted by + /// (commutativity). ⊥ operands + /// are dropped and ⊤ short-circuits to . + /// + public sealed class LtlOr : LtlFormula + { + // Children stored in canonical (structurally-sorted) order + public IReadOnlyList Children { get; } + + private LtlOr(IReadOnlyList children) + { + Children = children; + } + + internal override int Kind => 6; + + public static LtlFormula Create(LtlFormula left, LtlFormula right) + { + // Collect all children, flattening nested Ors (associativity) + var children = new List(); + CollectOrChildren(left, children); + CollectOrChildren(right, children); + + // Track polarity of literal operands (post-NNF, a literal is either + // an LtlProp or an LtlNot(LtlProp)). If both p and ¬p appear in the + // same disjunction the whole thing is ⊤ (complementary-literal + // elimination). + var positives = new HashSet>( + PredicateRefComparer.Instance); + var negatives = new HashSet>( + PredicateRefComparer.Instance); + + // Remove duplicates (idempotency: a ∨ a = a) + var unique = new HashSet(); + var filtered = new List(); + foreach (var child in children) + { + // Simplification: true ∨ φ = true + if (child.IsTrue) return True; + // Simplification: false ∨ φ = φ (skip false) + if (child.IsFalse) continue; + + // Complementary-literal elimination. + if (child is LtlProp p) + { + if (negatives.Contains(p.Predicate)) return True; + positives.Add(p.Predicate); + } + else if (child is LtlNot not && not.Inner is LtlProp np) + { + if (positives.Contains(np.Predicate)) return True; + negatives.Add(np.Predicate); + } + + if (unique.Add(child)) + filtered.Add(child); + } + + if (filtered.Count == 0) return False; + if (filtered.Count == 1) return filtered[0]; + + // Absorption + dual deep-absorption (Or side): + // A ∨ (A ∧ X) = A (simple absorption) + // A ∨ (B ∧ (A ∨ X)) = A ∨ (B ∧ X) (dual conditioning: in + // the case where this disjunct matters, sibling A failed, + // so the inner Or's A-branch can be pruned) + // A ∨ (B ∧ A) handled by simple absorption above + // Both are sound boolean equivalences and together with the + // dual rules in And.Create cap the formula-tree growth produced + // by alternating ∧/∨ derivative chains. + { + var siblingSet = new HashSet(filtered); + bool changedAbs = true; + while (changedAbs) + { + changedAbs = false; + for (int i = 0; i < filtered.Count; i++) + { + if (!(filtered[i] is LtlAnd andChild)) continue; + // Simple absorption: if any conjunct of this And equals + // some sibling Or-disjunct, the And is subsumed. + bool subsumed = false; + foreach (var c in andChild.Children) + { + if (siblingSet.Contains(c)) { subsumed = true; break; } + } + if (subsumed) + { + siblingSet.Remove(andChild); + filtered.RemoveAt(i); + i--; + changedAbs = true; + continue; + } + // Dual deep absorption: for each Or-grandchild of this + // And, prune any of its disjuncts that match outer + // siblings (since those disjuncts would already be + // true at the outer Or — but we're inside an And that + // is only relevant when ALL siblings failed, so we + // can drop those disjuncts as false). + var newConjuncts = new List(andChild.Children.Count); + bool andPruned = false; + bool andIsFalse = false; + foreach (var c in andChild.Children) + { + if (c is LtlOr cOr) + { + var kept = new List(cOr.Children.Count); + bool prunedInner = false; + foreach (var d in cOr.Children) + { + if (siblingSet.Contains(d)) prunedInner = true; + else kept.Add(d); + } + if (prunedInner) + { + andPruned = true; + if (kept.Count == 0) { andIsFalse = true; break; } + LtlFormula rebuilt = False; + foreach (var d in kept) rebuilt = Or(rebuilt, d); + newConjuncts.Add(rebuilt); + } + else newConjuncts.Add(c); + } + else newConjuncts.Add(c); + } + if (andIsFalse) + { + siblingSet.Remove(andChild); + filtered.RemoveAt(i); + i--; + changedAbs = true; + continue; + } + if (andPruned) + { + LtlFormula newAnd = True; + foreach (var c in newConjuncts) newAnd = And(newAnd, c); + siblingSet.Remove(andChild); + if (newAnd.IsTrue) return True; + if (newAnd.IsFalse) + { + filtered.RemoveAt(i); + i--; + } + else + { + filtered[i] = newAnd; + siblingSet.Add(newAnd); + } + changedAbs = true; + } + } + } + } + + if (filtered.Count == 0) return False; + if (filtered.Count == 1) return filtered[0]; + + // Deterministic structural sort for canonical order (commutativity). + filtered.Sort(StructuralComparer.Instance); + + return new LtlOr(filtered); + } + + private static void CollectOrChildren(LtlFormula formula, List children) + { + if (formula is LtlOr or) + { + foreach (var child in or.Children) + children.Add(child); + } + else + { + children.Add(formula); + } + } + + public override LtlFormula Derivative(IState state) + { + LtlFormula result = False; + foreach (var child in Children) + result = Or(result, child.Derivative(state)); + return result; + } + + public override IEnumerable GetUntilSubformulas() + => Children.SelectMany(c => c.GetUntilSubformulas()); + + public override bool Equals(LtlFormula other) + { + if (!(other is LtlOr or) || Children.Count != or.Children.Count) + return false; + for (int i = 0; i < Children.Count; i++) + if (!Children[i].Equals(or.Children[i])) + return false; + return true; + } + + public override int GetHashCode() + => GetCachedHashCode(() => + { + unchecked + { + int hash = 17 * 31 + typeof(LtlOr).GetHashCode(); + foreach (var child in Children) + hash = hash * 31 + child.GetHashCode(); + return hash; + } + }); + + public override string ToString() => $"({string.Join(" ∨ ", Children)})"; + } + + /// Next: Xφ — φ must hold in the next state. + public sealed class LtlNext : LtlFormula + { + public LtlFormula Inner { get; } + + public LtlNext(LtlFormula inner) => Inner = inner; + + internal override int Kind => 7; + + // δ(Xφ, s) = φ — after one step, we just need to satisfy φ + public override LtlFormula Derivative(IState state) => Inner; + + public override IEnumerable GetUntilSubformulas() + => Inner.GetUntilSubformulas(); + + public override bool Equals(LtlFormula other) + => other is LtlNext next && Inner.Equals(next.Inner); + + public override int GetHashCode() + => GetCachedHashCode(() => HashHelper.Combine(typeof(LtlNext), Inner)); + + public override string ToString() => $"X{Inner}"; + } + + /// + /// Until: φ U ψ — φ holds until ψ becomes true, and ψ must eventually hold. + /// This is the key temporal operator for liveness properties. + /// + public sealed class LtlUntil : LtlFormula + { + public LtlFormula Hold { get; } // φ — what must hold until goal + public LtlFormula Goal { get; } // ψ — what must eventually be achieved + + private LtlUntil(LtlFormula hold, LtlFormula goal) + { + Hold = hold; + Goal = goal; + } + + internal override int Kind => 8; + + public static LtlFormula Create(LtlFormula hold, LtlFormula goal) + { + // Simplification: φ U true = true, φ U false = false, false U ψ = ψ + if (goal.IsTrue) return True; + if (goal.IsFalse) return False; + if (hold.IsFalse) return goal; + return new LtlUntil(hold, goal); + } + + // δ(φ U ψ, s) = δ(ψ, s) ∨ (δ(φ, s) ∧ (φ U ψ)) + // Either ψ holds now, or φ holds now and we continue waiting + public override LtlFormula Derivative(IState state) + { + var goalDeriv = Goal.Derivative(state); + var holdDeriv = Hold.Derivative(state); + return Or(goalDeriv, And(holdDeriv, this)); + } + + public override IEnumerable GetUntilSubformulas() + { + yield return this; + foreach (var u in Hold.GetUntilSubformulas()) yield return u; + foreach (var u in Goal.GetUntilSubformulas()) yield return u; + } + + public override bool Equals(LtlFormula other) + => other is LtlUntil until && Hold.Equals(until.Hold) && Goal.Equals(until.Goal); + + public override int GetHashCode() + => GetCachedHashCode(() => HashHelper.Combine(typeof(LtlUntil), Hold, Goal)); + + public override string ToString() => $"({Hold} U {Goal})"; + } + + /// + /// Release: φ R ψ — ψ must hold until and including when φ becomes true. + /// If φ never becomes true, ψ must hold forever. + /// Dual of Until: φ R ψ ≡ ¬(¬φ U ¬ψ) + /// + public sealed class LtlRelease : LtlFormula + { + public LtlFormula Release_ { get; } // φ — releases the obligation + public LtlFormula Hold { get; } // ψ — must hold until released + + private LtlRelease(LtlFormula release, LtlFormula hold) + { + Release_ = release; + Hold = hold; + } + + internal override int Kind => 9; + + public static LtlFormula Create(LtlFormula release, LtlFormula hold) + { + // Simplification: φ R true = true, φ R false = false, true R ψ = ψ + if (hold.IsTrue) return True; + if (hold.IsFalse) return False; + if (release.IsTrue) return hold; + return new LtlRelease(release, hold); + } + + // δ(φ R ψ, s) = δ(ψ, s) ∧ (δ(φ, s) ∨ (φ R ψ)) + // ψ must hold now, and either φ releases us or we continue + public override LtlFormula Derivative(IState state) + { + var holdDeriv = Hold.Derivative(state); + var releaseDeriv = Release_.Derivative(state); + return And(holdDeriv, Or(releaseDeriv, this)); + } + + public override IEnumerable GetUntilSubformulas() + { + // Release doesn't create Until obligations directly, + // but we need to track nested Untils + foreach (var u in Release_.GetUntilSubformulas()) yield return u; + foreach (var u in Hold.GetUntilSubformulas()) yield return u; + } + + public override bool Equals(LtlFormula other) + => other is LtlRelease rel && Release_.Equals(rel.Release_) && Hold.Equals(rel.Hold); + + public override int GetHashCode() + => GetCachedHashCode(() => HashHelper.Combine(typeof(LtlRelease), Release_, Hold)); + + public override string ToString() => $"({Release_} R {Hold})"; + } +} diff --git a/Accordant.ModelChecking/ModelCheckExtensions.cs b/Accordant.ModelChecking/ModelCheckExtensions.cs new file mode 100644 index 0000000..fbaae7c --- /dev/null +++ b/Accordant.ModelChecking/ModelCheckExtensions.cs @@ -0,0 +1,37 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// Extension methods for model checking temporal properties against + /// explored state graphs. + /// + public static class ModelCheckExtensions + { + /// + /// Check a temporal formula (RLTL property) against an explored + /// state graph. Returns a + /// indicating whether the property holds and, if not, a + /// counterexample trace. + /// + /// Root of the explored state graph. + /// The temporal property that should hold. + /// Maximum exploration depth (0 = unlimited). + /// Optional fairness constraint. Defaults to + /// . Use + /// for liveness properties that require weak fairness. + /// A result indicating validity, with a counterexample trace + /// on failure. + public static PropertyCheckingResult Check( + this StateGraphNode root, + TemporalFormula formula, + int maxDepth = 0, + Fairness fairness = null) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (formula == null) throw new ArgumentNullException(nameof(formula)); + return SymbolicRltlCheck.Check(root, formula.Core, maxDepth, fairness); + } + } +} diff --git a/Accordant.ModelChecking/Observation.cs b/Accordant.ModelChecking/Observation.cs new file mode 100644 index 0000000..f4af7d0 --- /dev/null +++ b/Accordant.ModelChecking/Observation.cs @@ -0,0 +1,176 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// An observation over a model state — an atomic proposition that can be + /// used in both temporal formulas and regex patterns. Created via + /// . + /// + public sealed class Observation + { + internal Rltl RltlCore { get; } + internal Ere EreCore { get; } + + internal Observation(StatePredAtom atom) + { + RltlCore = Rltl.Atom(atom); + EreCore = Ere.Atom(atom); + } + + /// Conjunction of two observations (usable in both temporal and regex contexts). + public static Observation operator &(Observation a, Observation b) + => new Observation(a.RltlCore, b.RltlCore, a.EreCore, b.EreCore, and: true); + + /// Disjunction of two observations. + public static Observation operator |(Observation a, Observation b) + => new Observation(a.RltlCore, b.RltlCore, a.EreCore, b.EreCore, and: false); + + /// Negation of an observation. + public static Observation operator !(Observation a) + { + var rltl = RltlAlgebra.Default.Not(a.RltlCore); + var ere = Ere.Complement(a.EreCore); + return new Observation(rltl, ere); + } + + // Internal constructors for compound observations + private Observation( + Rltl rltlA, Rltl rltlB, + Ere ereA, Ere ereB, + bool and) + { + if (and) + { + RltlCore = RltlAlgebra.Default.And(rltlA, rltlB); + EreCore = Ere.Intersect(ereA, ereB); + } + else + { + RltlCore = RltlAlgebra.Default.Or(rltlA, rltlB); + EreCore = Ere.Union(ereA, ereB); + } + } + + private Observation(Rltl rltl, Ere ere) + { + RltlCore = rltl; + EreCore = ere; + } + + /// Implicit conversion to a temporal formula. + public static implicit operator TemporalFormula(Observation obs) + => new TemporalFormula(obs.RltlCore); + + /// Implicit conversion to a regex pattern. + public static implicit operator RegexPattern(Observation obs) + => new RegexPattern(obs.EreCore); + + public override string ToString() => RltlCore.ToString(); + } + + /// + /// A temporal formula (RLTL) over model-program states. Constructed via + /// methods like , + /// , etc. + /// + public sealed class TemporalFormula + { + internal Rltl Core { get; } + + internal TemporalFormula(Rltl core) + { + Core = core ?? throw new ArgumentNullException(nameof(core)); + } + + /// Conjunction φ ∧ ψ. + public static TemporalFormula operator &(TemporalFormula a, TemporalFormula b) + => new TemporalFormula(RltlAlgebra.Default.And(a.Core, b.Core)); + + /// Disjunction φ ∨ ψ. + public static TemporalFormula operator |(TemporalFormula a, TemporalFormula b) + => new TemporalFormula(RltlAlgebra.Default.Or(a.Core, b.Core)); + + /// Negation ¬φ. + public static TemporalFormula operator !(TemporalFormula a) + => new TemporalFormula(RltlAlgebra.Default.Not(a.Core)); + + public override string ToString() => Core.ToString(); + } + + /// + /// An extended regular expression (ERE) over model-program states. Used as + /// the regex component of RLTL prefix operators. Constructed via + /// , , + /// implicit conversion, etc. + /// + public sealed class RegexPattern + { + internal Ere Core { get; } + + internal RegexPattern(Ere core) + { + Core = core ?? throw new ArgumentNullException(nameof(core)); + } + + #region Constants + + /// The empty language ∅ — matches no word. + public static RegexPattern Empty { get; } = new RegexPattern(Ere.Empty()); + + /// The language { ε } — matches only the empty word. + public static RegexPattern Epsilon { get; } = new RegexPattern(Ere.Epsilon()); + + /// Σ — matches any single letter (one state). + public static RegexPattern Sigma { get; } = new RegexPattern(Ere.Sigma()); + + #endregion + + #region Combinators + + /// Concatenation this · other. + public RegexPattern Then(RegexPattern other) + => new RegexPattern(Ere.Concat(Core, other.Core)); + + /// Kleene star this*. + public RegexPattern Star() + => new RegexPattern(Ere.Star(Core)); + + /// Kleene plus this+ = this · this*. + public RegexPattern Plus() + => new RegexPattern(Ere.Plus(Core)); + + /// Optional this? = this + ε. + public RegexPattern Optional() + => new RegexPattern(Ere.Optional(Core)); + + /// + /// Fusion this : other — the last letter of a this-match + /// coincides with the first letter of an other-match. + /// + public RegexPattern Fusion(RegexPattern other) + => new RegexPattern(Ere.Fusion(Core, other.Core)); + + #endregion + + #region Operator overloads + + /// Union a + b. + public static RegexPattern operator |(RegexPattern a, RegexPattern b) + => new RegexPattern(Ere.Union(a.Core, b.Core)); + + /// Intersection a ∩ b. + public static RegexPattern operator &(RegexPattern a, RegexPattern b) + => new RegexPattern(Ere.Intersect(a.Core, b.Core)); + + /// Complement ~a. + public static RegexPattern operator !(RegexPattern a) + => new RegexPattern(Ere.Complement(a.Core)); + + #endregion + + public override string ToString() => Core.ToString(); + } +} diff --git a/Accordant.ModelChecking/Properties.cs b/Accordant.ModelChecking/Properties.cs new file mode 100644 index 0000000..9b22a1c --- /dev/null +++ b/Accordant.ModelChecking/Properties.cs @@ -0,0 +1,152 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// Typed factory for defining state observations and building temporal + /// formulas over a model state type . + /// + /// + /// is declared once at construction; + /// all calls infer it automatically, avoiding + /// generic-type repetition at every call site. + /// + /// + /// + /// + /// var p = new Properties<PetersonState>(); + /// var crit0 = p.Observe(s => s.InCS[0], "Crit0"); + /// var mutex = p.Always(!(crit0 & crit1)); + /// graph.Check(mutex); + /// + /// + /// + public sealed class Properties where TState : State + { + #region Observation factory + + /// + /// Define an atomic observation (proposition) over the model state. + /// The returned can be used in both temporal + /// formulas and regex patterns. + /// + /// State predicate — evaluated against concrete states + /// during model checking. + /// Display name for diagnostics and counterexample traces. + public Observation Observe(Func predicate, string name) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (name == null) throw new ArgumentNullException(nameof(name)); + var prop = new StateProp(name, state => predicate((TState)state)); + return new Observation(new StatePredAtom(prop)); + } + + #endregion + + #region Constants + + /// True constant — satisfied by every infinite word. + public TemporalFormula True => new TemporalFormula(Rltl.True()); + + /// False constant — satisfied by no infinite word. + public TemporalFormula False => new TemporalFormula(Rltl.False()); + + #endregion + + #region Boolean operators + + /// Negation ¬φ. + public TemporalFormula Not(TemporalFormula inner) + => new TemporalFormula(RltlAlgebra.Default.Not(inner.Core)); + + /// Conjunction φ ∧ ψ. + public TemporalFormula And(TemporalFormula left, TemporalFormula right) + => new TemporalFormula(RltlAlgebra.Default.And(left.Core, right.Core)); + + /// Conjunction of multiple formulas. + public TemporalFormula And(params TemporalFormula[] formulas) + => formulas.Aggregate(True, And); + + /// Disjunction φ ∨ ψ. + public TemporalFormula Or(TemporalFormula left, TemporalFormula right) + => new TemporalFormula(RltlAlgebra.Default.Or(left.Core, right.Core)); + + /// Disjunction of multiple formulas. + public TemporalFormula Or(params TemporalFormula[] formulas) + => formulas.Aggregate(False, Or); + + /// Implication φ → ψ. + public TemporalFormula Implies(TemporalFormula antecedent, TemporalFormula consequent) + => new TemporalFormula(RltlAlgebra.Default.Implies(antecedent.Core, consequent.Core)); + + #endregion + + #region Temporal operators (LTL) + + /// Next: Xφ — φ holds in the next state. + public TemporalFormula Next(TemporalFormula inner) + => new TemporalFormula(Rltl.Next(inner.Core)); + + /// Until: φ U ψ — φ holds until ψ holds (ψ eventually holds). + public TemporalFormula Until(TemporalFormula hold, TemporalFormula goal) + => new TemporalFormula(Rltl.Until(hold.Core, goal.Core)); + + /// Release: φ R ψ — dual of Until. + public TemporalFormula Release(TemporalFormula release, TemporalFormula hold) + => new TemporalFormula(Rltl.Release(release.Core, hold.Core)); + + /// Eventually: ◇φ — φ holds at some future state. + public TemporalFormula Eventually(TemporalFormula inner) + => new TemporalFormula(Rltl.Eventually(inner.Core)); + + /// Always: □φ — φ holds at every future state. + public TemporalFormula Always(TemporalFormula inner) + => new TemporalFormula(Rltl.Globally(inner.Core)); + + /// Infinitely often: □◇φ — φ holds infinitely often. + public TemporalFormula InfinitelyOften(TemporalFormula inner) => Always(Eventually(inner)); + + /// Stabilizes: ◇□φ — φ eventually holds forever. + public TemporalFormula Stabilizes(TemporalFormula inner) => Eventually(Always(inner)); + + /// Leads-to: φ ~> ψ = □(φ → ◇ψ) — whenever φ holds, + /// ψ eventually follows. + public TemporalFormula LeadsTo(TemporalFormula trigger, TemporalFormula response) + => Always(Implies(trigger, Eventually(response))); + + #endregion + + #region Regex-prefix operators (RLTL) + + /// + /// Sequential prefix: R ; φ — there exists a prefix matching R, + /// after which φ holds. + /// + public TemporalFormula SeqPrefix(RegexPattern r, TemporalFormula phi) + => new TemporalFormula(Rltl.SeqPrefix(r.Core, phi.Core)); + + /// + /// Overlapping prefix: R : φ — like SeqPrefix but the last + /// letter of the match overlaps with the first letter of the suffix. + /// + public TemporalFormula OvlPrefix(RegexPattern r, TemporalFormula phi) + => new TemporalFormula(Rltl.OvlPrefix(r.Core, phi.Core)); + + /// + /// Trigger: R ⊳ φ — for every prefix matching R, the suffix + /// satisfies φ. The universal (safety) dual of SeqPrefix. + /// + public TemporalFormula Trigger(RegexPattern r, TemporalFormula phi) + => new TemporalFormula(Rltl.Trigger(r.Core, phi.Core)); + + /// + /// Match: R ⊳⊳ φ — overlapping universal variant of Trigger. + /// + public TemporalFormula Match(RegexPattern r, TemporalFormula phi) + => new TemporalFormula(Rltl.Match(r.Core, phi.Core)); + + #endregion + } +} diff --git a/Accordant.ModelChecking/PropertyCheckingResult.cs b/Accordant.ModelChecking/PropertyCheckingResult.cs new file mode 100644 index 0000000..7b29493 --- /dev/null +++ b/Accordant.ModelChecking/PropertyCheckingResult.cs @@ -0,0 +1,173 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// The result of checking a property over a state graph. + /// + public class PropertyCheckingResult + { + /// + /// Creates a successful result (property holds). + /// + public static PropertyCheckingResult Success() => new PropertyCheckingResult { Valid = true }; + + /// + /// Creates a failure result with a counterexample trace. + /// + public static PropertyCheckingResult Failure(List trace, StronglyConnectedComponent badCycle = null) + { + return new PropertyCheckingResult + { + Valid = false, + Trace = trace, + BadCycle = badCycle + }; + } + + /// + /// Indicates whether the property holds. + /// + public bool Valid { get; private set; } + + /// + /// The counterexample trace if the property doesn't hold. + /// For liveness properties, this is the path to the bad cycle. + /// + public List Trace { get; private set; } + + /// + /// For liveness failures, the bad cycle (SCC) where the property is violated. + /// + public StronglyConnectedComponent BadCycle { get; private set; } + + /// + /// Returns a human-readable representation of the counterexample. + /// + public string GetTraceString() + { + if (Valid || Trace == null) + { + return "Property holds - no counterexample."; + } + + var sb = new StringBuilder(); + sb.AppendLine("Counterexample trace:"); + + bool inCycleSection = false; + foreach (var item in Trace) + { + if (item.IsInCycle && !inCycleSection) + { + sb.AppendLine("--- Cycle begins ---"); + inCycleSection = true; + } + + var action = item.StepFunction == null ? "Start" : FormatStep(item.StepFunction); + sb.AppendLine($" --{action}--> {item.StateGraphNode.State}{FormatValuation(item.Valuation)}"); + } + + if (inCycleSection) + { + sb.AppendLine("--- Cycle repeats ---"); + } + + if (BadCycle != null) + { + sb.AppendLine(); + sb.AppendLine($"Bad cycle contains {BadCycle.Nodes.Count} state(s)."); + + // Show which step functions were enabled but not taken (fairness hint) + var enabledNotTaken = GetEnabledButNotTakenSteps(BadCycle); + if (enabledNotTaken.Any()) + { + sb.AppendLine(); + sb.AppendLine("Hint: The following actions were enabled but never taken in this cycle:"); + foreach (var sfId in enabledNotTaken) + { + sb.AppendLine($" - {sfId}"); + } + sb.AppendLine("Consider adding fairness constraints: fair: Fairness.WeakFair(...)"); + } + } + + return sb.ToString(); + } + + /// + /// Renders a step function for trace display. Returns the + /// when non-empty + /// (sample step classes typically use the type name or a + /// disambiguated variant like "PassToken_1") and falls + /// back to the runtime type name otherwise. + /// + internal static string FormatStep(IStepFunction sf) + { + var id = sf.StepFunctionId; + if (!string.IsNullOrEmpty(id)) + return id; + return sf.GetType().Name; + } + + /// + /// Renders a concrete predicate valuation as + /// [p=true, q=false]. Returns the empty string when no + /// valuation is attached (explicit-checker traces, or traces over + /// an NBW with an empty condition registry). + /// + internal static string FormatValuation(IReadOnlyDictionary valuation) + { + if (valuation == null || valuation.Count == 0) return string.Empty; + var sb = new StringBuilder(); + sb.Append(" ["); + bool first = true; + foreach (var kv in valuation.OrderBy(kv => kv.Key.ToString(), StringComparer.Ordinal)) + { + if (!first) sb.Append(", "); + first = false; + sb.Append(kv.Key); + sb.Append('='); + sb.Append(kv.Value ? "true" : "false"); + } + sb.Append(']'); + return sb.ToString(); + } + + private static List GetEnabledButNotTakenSteps(StronglyConnectedComponent scc) + { + // Delegates to the shared CycleFairness helper so the + // diagnostic stays consistent with the fairness decision + // procedure. The hint returns human-readable step labels + // (via FormatStep) rather than raw ids; the id-keyed + // Enabled / Taken sets are translated through StepById + // at the end. + var nodesInSCC = new HashSet(scc.Nodes.Select(n => n.GetNodeFingerprint())); + + IEnumerable EnabledAt(StateGraphNode n) + => n.Edges.Select(e => e.StepFunction); + + IEnumerable Taken() + { + foreach (var n in scc.Nodes) + foreach (var e in n.Edges) + if (nodesInSCC.Contains(e.Target.GetNodeFingerprint())) + yield return e.StepFunction; + } + + var analysis = CycleFairness.Compute(scc.Nodes, EnabledAt, Taken()); + + var labels = new HashSet(); + foreach (var id in analysis.Enabled) + { + if (analysis.Taken.Contains(id)) continue; + if (analysis.StepById.TryGetValue(id, out var sf)) + labels.Add(FormatStep(sf)); + } + return labels.OrderBy(x => x).ToList(); + } + } +} diff --git a/Accordant.ModelChecking/Rltl/Regex.cs b/Accordant.ModelChecking/Rltl/Regex.cs new file mode 100644 index 0000000..91c5a22 --- /dev/null +++ b/Accordant.ModelChecking/Rltl/Regex.cs @@ -0,0 +1,111 @@ +using Microsoft.Accordant; + +namespace Microsoft.Accordant.ModelChecking.Rltl +{ + using System; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// User-facing extended-regular-expression (ERE) over model-program states. + /// A thin facade over with + /// as the predicate type, providing friendly + /// factories and operator overloads. + /// + /// Used as the regex component of prefix + /// operators (, + /// , , + /// ). + /// + /// Operator conventions: + /// + /// a | b — union (a + b) + /// a & b — intersection (a ∩ b) + /// !a — complement (~a) + /// a.Then(b) — concatenation (a · b) + /// + /// Use // for + /// repetition. + /// + public sealed class Regex + { + internal Ere Core { get; } + + internal Regex(Ere core) + { + Core = core ?? throw new ArgumentNullException(nameof(core)); + } + + #region Factories + + /// The empty language ∅ — matches no word. + public static Regex Empty { get; } = new Regex(Ere.Empty()); + + /// The language { ε } — matches only the empty word. + public static Regex Epsilon { get; } = new Regex(Ere.Epsilon()); + + /// Σ* — the universal language, matches every finite word. + public static Regex Sigma { get; } = new Regex(Ere.Sigma()); + + /// + /// Single-letter language: matches one state satisfying + /// . + /// + public static Regex Prop(Func predicate, string name = null) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + var atom = new StatePredAtom(new StateProp(name ?? "prop", predicate)); + return new Regex(Ere.Atom(atom)); + } + + /// Concatenation a · b. + public Regex Then(Regex other) => Concat(this, other); + + /// Concatenation a · b. + public static Regex Concat(Regex a, Regex b) + => new Regex(Ere.Concat(a.Core, b.Core)); + + /// Union a + b. + public static Regex Union(Regex a, Regex b) + => new Regex(Ere.Union(a.Core, b.Core)); + + /// Intersection a ∩ b. + public static Regex Intersect(Regex a, Regex b) + => new Regex(Ere.Intersect(a.Core, b.Core)); + + /// Complement ~a. + public static Regex Complement(Regex a) + => new Regex(Ere.Complement(a.Core)); + + /// Kleene star a*. + public static Regex Star(Regex a) + => new Regex(Ere.Star(a.Core)); + + /// + /// Fusion a : b (Section 7.3, JACM extension): the last letter of an + /// a-match coincides with the first letter of a b-match. + /// L(a : b) = { v | ∃ i < |v| : v[..i] ∈ L(a) ∧ v[i..] ∈ L(b) }. + /// + public static Regex Fusion(Regex a, Regex b) + => new Regex(Ere.Fusion(a.Core, b.Core)); + + /// Kleene plus a+ = a · a*. + public static Regex Plus(Regex a) + => new Regex(Ere.Plus(a.Core)); + + /// Optional a? = a + ε. + public static Regex Optional(Regex a) + => new Regex(Ere.Optional(a.Core)); + + #endregion + + #region Operator overloads + + public static Regex operator |(Regex a, Regex b) => Union(a, b); + public static Regex operator &(Regex a, Regex b) => Intersect(a, b); + public static Regex operator !(Regex a) => Complement(a); + + #endregion + + public override string ToString() => Core.ToString(); + } +} diff --git a/Accordant.ModelChecking/Rltl/RltlCheck.cs b/Accordant.ModelChecking/Rltl/RltlCheck.cs new file mode 100644 index 0000000..77af936 --- /dev/null +++ b/Accordant.ModelChecking/Rltl/RltlCheck.cs @@ -0,0 +1,33 @@ +namespace Microsoft.Accordant.ModelChecking.Rltl +{ + using System; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// Top-level entry point for RLTL model checking. Thin wrapper that + /// unwraps and delegates to + /// . + /// + public static class RltlCheck + { + /// + /// Check an against a model program's + /// state graph. + /// + /// Root of the state graph (model program). + /// The RLTL property that should hold. + /// Maximum exploration depth (0 = unlimited). + /// Optional fairness constraint. Defaults to + /// . Use + /// for parity with the legacy LtlCheck default semantics. + public static PropertyCheckingResult Check( + StateGraphNode root, + RltlFormula formula, + int maxDepth = 0, + Fairness fairness = null) + { + if (formula == null) throw new ArgumentNullException(nameof(formula)); + return SymbolicRltlCheck.Check(root, formula.Core, maxDepth, fairness); + } + } +} diff --git a/Accordant.ModelChecking/Rltl/RltlFormula.cs b/Accordant.ModelChecking/Rltl/RltlFormula.cs new file mode 100644 index 0000000..7f58768 --- /dev/null +++ b/Accordant.ModelChecking/Rltl/RltlFormula.cs @@ -0,0 +1,158 @@ +using Microsoft.Accordant; + +namespace Microsoft.Accordant.ModelChecking.Rltl +{ + using System; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// User-facing RLTL (Regular LTL) formula over model-program states. A + /// thin facade over with + /// as the predicate type. + /// + /// RLTL extends LTL with four regex-prefix operators (Section 7 of the + /// POPL'25 paper): + /// + /// : R ; φ — ∃k≥0. w[0..k] ∈ L(R) ∧ w[k..] ⊨ φ + /// : R : φ — overlapping variant: w[0..k+1] ∈ L(R) ∧ w[k..] ⊨ φ + /// : R ⊳ φ — ∀k≥0. w[0..k] ∈ L(R) → w[k..] ⊨ φ + /// : R ⊳⊳ φ — overlapping universal variant + /// + /// All pure-LTL operators are also available with the standard semantics + /// (, , , …). + /// + public sealed class RltlFormula + { + internal Rltl Core { get; } + + internal RltlFormula(Rltl core) + { + Core = core ?? throw new ArgumentNullException(nameof(core)); + } + + #region Constants and atoms + + /// True constant — satisfied by every word. + public static RltlFormula True { get; } = new RltlFormula(Rltl.True()); + + /// False constant — satisfied by no word. + public static RltlFormula False { get; } = new RltlFormula(Rltl.False()); + + /// Atomic proposition over a state predicate. + public static RltlFormula Prop(Func predicate, string name = null) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + var atom = new StatePredAtom(new StateProp(name ?? "prop", predicate)); + return new RltlFormula(Rltl.Atom(atom)); + } + + #endregion + + #region Boolean operators + + /// Negation ¬φ. Internally pushed to NNF via the EBA. + public static RltlFormula Not(RltlFormula inner) + => new RltlFormula(RltlAlgebra.Default.Not(inner.Core)); + + /// Conjunction φ ∧ ψ. + public static RltlFormula And(RltlFormula left, RltlFormula right) + => new RltlFormula(RltlAlgebra.Default.And(left.Core, right.Core)); + + /// Conjunction of multiple formulas. + public static RltlFormula And(params RltlFormula[] formulas) + => formulas.Aggregate(True, And); + + /// Disjunction φ ∨ ψ. + public static RltlFormula Or(RltlFormula left, RltlFormula right) + => new RltlFormula(RltlAlgebra.Default.Or(left.Core, right.Core)); + + /// Disjunction of multiple formulas. + public static RltlFormula Or(params RltlFormula[] formulas) + => formulas.Aggregate(False, Or); + + /// Implication φ → ψ. + public static RltlFormula Implies(RltlFormula antecedent, RltlFormula consequent) + => new RltlFormula(RltlAlgebra.Default.Implies(antecedent.Core, consequent.Core)); + + #endregion + + #region Temporal operators (pure LTL) + + /// Next: Xφ. + public static RltlFormula Next(RltlFormula inner) + => new RltlFormula(Rltl.Next(inner.Core)); + + /// Until: φ U ψ. + public static RltlFormula Until(RltlFormula hold, RltlFormula goal) + => new RltlFormula(Rltl.Until(hold.Core, goal.Core)); + + /// Release: φ R ψ. + public static RltlFormula Release(RltlFormula release, RltlFormula hold) + => new RltlFormula(Rltl.Release(release.Core, hold.Core)); + + /// Eventually: ◇φ (= true U φ). + public static RltlFormula Eventually(RltlFormula inner) + => new RltlFormula(Rltl.Eventually(inner.Core)); + + /// Always: □φ (= false R φ). + public static RltlFormula Always(RltlFormula inner) + => new RltlFormula(Rltl.Globally(inner.Core)); + + /// Infinitely often: □◇φ. + public static RltlFormula InfinitelyOften(RltlFormula inner) => Always(Eventually(inner)); + + /// Stabilizes: ◇□φ. + public static RltlFormula Stabilizes(RltlFormula inner) => Eventually(Always(inner)); + + /// Leads-to: φ ~> ψ = □(φ → ◇ψ). + public static RltlFormula LeadsTo(RltlFormula trigger, RltlFormula response) + => Always(Implies(trigger, Eventually(response))); + + #endregion + + #region Regex-prefix operators (RLTL-specific) + + /// + /// Sequential prefix: R ; φ — there exists k≥0 such that the + /// prefix w[0..k] is in L(R) and the suffix + /// w[k..] satisfies φ. + /// + public static RltlFormula SeqPrefix(Regex r, RltlFormula phi) + => new RltlFormula(Rltl.SeqPrefix(r.Core, phi.Core)); + + /// + /// Overlapping prefix: R : φ — like + /// but the match consumes one extra letter (w[0..k+1] ∈ L(R)). + /// + public static RltlFormula OvlPrefix(Regex r, RltlFormula phi) + => new RltlFormula(Rltl.OvlPrefix(r.Core, phi.Core)); + + /// + /// Trigger: R ⊳ φ — for every k≥0 with w[0..k] ∈ L(R), + /// the suffix w[k..] satisfies φ. The universal + /// (safety) dual of . + /// + public static RltlFormula Trigger(Regex r, RltlFormula phi) + => new RltlFormula(Rltl.Trigger(r.Core, phi.Core)); + + /// + /// Overlapping trigger / "match": R ⊳⊳ φ — universal dual of + /// . + /// + public static RltlFormula Match(Regex r, RltlFormula phi) + => new RltlFormula(Rltl.Match(r.Core, phi.Core)); + + #endregion + + #region Operator overloads + + public static RltlFormula operator &(RltlFormula left, RltlFormula right) => And(left, right); + public static RltlFormula operator |(RltlFormula left, RltlFormula right) => Or(left, right); + public static RltlFormula operator !(RltlFormula inner) => Not(inner); + + #endregion + + public override string ToString() => Core.ToString(); + } +} diff --git a/Accordant.ModelChecking/Symbolic/AlternationElimination.cs b/Accordant.ModelChecking/Symbolic/AlternationElimination.cs new file mode 100644 index 0000000..3d98444 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/AlternationElimination.cs @@ -0,0 +1,465 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// The Æ alternation elimination algorithm (Section 5 of the paper). + /// Converts a symbolic alternating Büchi automaton (ABW_A) into a + /// symbolic nondeterministic Büchi automaton (NBW_A) using the + /// Miyano-Hayashi breakpoint construction. + /// + /// The NBW states are pairs (S, O) + /// where S is a macrostate (set of ABW states) and O ⊆ S tracks + /// acceptance obligations. + /// + public static class AlternationElimination + { + /// + /// Eliminates alternation from an ABW, producing an equivalent NBW. + /// Uses the Miyano-Hayashi breakpoint construction for acceptance. + /// + /// The NBW states are pairs (S, O). + /// States are discovered incrementally (on-the-fly). + /// + public static SymbolicNBW> + Eliminate( + SymbolicABW abw, + bool eagerAntimirov = false) + { + if (abw == null) throw new ArgumentNullException(nameof(abw)); + + var stateComparer = abw.DnfAlgebra.StateComparer; + var termAlgebra = abw.GetTermAlgebra(); + var bpComparerForCollapse = BreakpointState.GetComparer(stateComparer); + var bpEqualityForCollapse = BreakpointState.GetEqualityComparer(); + + // Cache for macrostate combined transitions: + // S → ∧_{q∈S} δ(q), which is TTerm⟨A, Dnf⟩ + var macroTransitionCache = new Dictionary, TransitionTerm>>(); + + // Compute combined transition for a macrostate + TransitionTerm> GetMacroTransition(StateSet macrostate) + { + if (macroTransitionCache.TryGetValue(macrostate, out var cached)) + return cached; + + TransitionTerm> combined = termAlgebra.Top; + foreach (var q in macrostate) + { + var delta_q = abw.GetTransition(q); + combined = termAlgebra.And(combined, delta_q); + } + + macroTransitionCache[macrostate] = combined; + return combined; + } + + // Build the NBW transition for a breakpoint state (S, O) + // Returns transitions in Antimirov form: list of TTerm⟨A, StateSet> + IReadOnlyList>>> + ComputeBreakpointTransition(BreakpointState bpState) + { + var S = bpState.Macrostate; + var O = bpState.Obligation; + var result = new List>>>(); + var bpComparer = BreakpointState.GetComparer(stateComparer); + + TransitionTerm>> dnfTerm; + if (O.IsEmpty) + { + // Breakpoint reached: O was empty, so reset. + var delta_S = GetMacroTransition(S); + dnfTerm = termAlgebra.MapUnary(delta_S, dnfS => + BuildResetDnf(dnfS, stateComparer, abw.IsAccepting, bpComparer)); + } + else + { + var sMinusO = S.Except(O); + + if (sMinusO.IsEmpty) + { + var delta_O = GetMacroTransition(O); + dnfTerm = termAlgebra.MapUnary(delta_O, dnfO => + BuildOOnlyDnf(dnfO, stateComparer, abw.IsAccepting, bpComparer)); + } + else + { + var delta_SminusO = GetMacroTransition(sMinusO); + var delta_O = GetMacroTransition(O); + + dnfTerm = termAlgebra.ApplyCross( + delta_SminusO, delta_O, + (dnfSO, dnfO) => BuildCrossDnf( + dnfSO, dnfO, stateComparer, abw.IsAccepting, bpComparer), + abw.Eba.Top); + } + } + + if (eagerAntimirov) + { + FlattenDnfToAntimirov(dnfTerm, result, bpComparer); + } + else + { + result.Add(CollapseDnfToStateSet( + dnfTerm, bpComparerForCollapse, bpEqualityForCollapse)); + } + + return result; + } + + // Initial states: one breakpoint (Sⱼ, ∅) per disjunct Sⱼ of the + // initial positive Boolean formula φ₀ = ⋁ⱼ Sⱼ ∈ B⁺(Q). + // Obligation starts empty (first breakpoint). + var emptyObligation = StateSet.Empty(stateComparer); + var initialBPs = new List>(abw.InitialState.ClauseCount); + foreach (var clause in abw.InitialState.Clauses) + initialBPs.Add(new BreakpointState(clause, emptyObligation)); + + return new SymbolicNBW>( + abw.Eba, + abw.Registry, + initialBPs, + bp => bp.Obligation.IsEmpty, // Accepting iff obligation is empty (breakpoint) + ComputeBreakpointTransition, + BreakpointState.GetEqualityComparer()); + } + + /// + /// Builds successor breakpoint states for the O=∅ (reset) case. + /// For each clause C in dnfS: S'=C, O'=C\F (reset obligation to all non-accepting). + /// + private static Dnf> BuildResetDnf( + Dnf dnfS, + IComparer stateComparer, + Func isAccepting, + IComparer> bpComparer) + { + if (dnfS.IsFalse) + return new Dnf>( + Array.Empty>>()); + + var clauses = new List>>(); + foreach (var clauseS in dnfS.Clauses) + { + // O' = S' \ F (all non-accepting states become obligations) + var nonAccepting = new List(); + foreach (var q in clauseS) + if (!isAccepting(q)) + nonAccepting.Add(q); + var oPrime = nonAccepting.Count > 0 + ? new StateSet(nonAccepting, stateComparer) + : StateSet.Empty(stateComparer); + + var successor = new BreakpointState(clauseS, oPrime); + clauses.Add(StateSet>.Singleton(successor, bpComparer)); + } + + return new Dnf>(clauses.ToArray()); + } + + /// + /// Builds successor breakpoint states when O = S (all states are obligation states). + /// For each clause C_O in dnfO: S'=C_O, O'=C_O\F. + /// + private static Dnf> BuildOOnlyDnf( + Dnf dnfO, + IComparer stateComparer, + Func isAccepting, + IComparer> bpComparer) + { + if (dnfO.IsFalse) + return new Dnf>( + Array.Empty>>()); + + var clauses = new List>>(); + foreach (var clauseO in dnfO.Clauses) + { + // S' = C_O, O' = C_O \ F + var nonAccepting = new List(); + foreach (var q in clauseO) + if (!isAccepting(q)) + nonAccepting.Add(q); + var oPrime = nonAccepting.Count > 0 + ? new StateSet(nonAccepting, stateComparer) + : StateSet.Empty(stateComparer); + + var successor = new BreakpointState(clauseO, oPrime); + clauses.Add(StateSet>.Singleton(successor, bpComparer)); + } + + return new Dnf>(clauses.ToArray()); + } + + /// + /// Builds successor breakpoint states for the general case S\O ≠ ∅, O ≠ ∅. + /// Cross-products delta_{S\O} with delta_O: + /// For each (C_{S\O}, C_O): S' = C_{S\O} ∪ C_O, O' = C_O \ F. + /// This ensures consistency: O-states' choices are determined by their clause. + /// + private static Dnf> BuildCrossDnf( + Dnf dnfSO, + Dnf dnfO, + IComparer stateComparer, + Func isAccepting, + IComparer> bpComparer) + { + if (dnfSO.IsFalse || dnfO.IsFalse) + return new Dnf>( + Array.Empty>>()); + + var clauses = new List>>(); + foreach (var clauseSO in dnfSO.Clauses) + { + foreach (var clauseO in dnfO.Clauses) + { + // S' = C_{S\O} ∪ C_O + var sPrime = clauseSO.Union(clauseO); + + // O' = C_O \ F (obligation successor is just the O-contribution minus accepting) + var nonAccepting = new List(); + foreach (var q in clauseO) + if (!isAccepting(q)) + nonAccepting.Add(q); + var oPrime = nonAccepting.Count > 0 + ? new StateSet(nonAccepting, stateComparer) + : StateSet.Empty(stateComparer); + + var successor = new BreakpointState(sPrime, oPrime); + clauses.Add(StateSet>.Singleton(successor, bpComparer)); + } + } + + return new Dnf>(clauses.ToArray()); + } + + /// + /// Flattens a transition term with Dnf leaves into Antimirov form. + /// Each clause in a Dnf leaf becomes a separate transition term disjunct. + /// + private static void FlattenDnfToAntimirov( + TransitionTerm> term, + List>> result, + IComparer stateComparer) + { + if (term is TransitionTermLeaf> leaf) + { + foreach (var clause in leaf.Value.Clauses) + { + result.Add(TransitionTerm>.Leaf(clause)); + } + return; + } + + var ite = (TransitionTermIte>)term; + var hiTerms = new List>>(); + var loTerms = new List>>(); + FlattenDnfToAntimirov(ite.Hi, hiTerms, stateComparer); + FlattenDnfToAntimirov(ite.Lo, loTerms, stateComparer); + + // Distribute ITE over disjunction: (α ? f₁∨f₂ : g₁∨g₂) + // = (α ? f₁ : g₁) ∨ (α ? f₁ : g₂) ∨ (α ? f₂ : g₁) ∨ (α ? f₂ : g₂) + // When one branch is ⊥ (empty), use empty StateSet as dead-end leaf. + var emptyLeaf = TransitionTerm>.Leaf( + StateSet.Empty(stateComparer)); + + if (hiTerms.Count == 0 && loTerms.Count == 0) + { + // Both branches dead — no transitions + return; + } + else if (loTerms.Count == 0) + { + // Under ¬α: dead end (empty successor set) + foreach (var h in hiTerms) + result.Add(TransitionTerm>.Ite( + ite.ConditionIndex, h, emptyLeaf)); + } + else if (hiTerms.Count == 0) + { + // Under α: dead end (empty successor set) + foreach (var l in loTerms) + result.Add(TransitionTerm>.Ite( + ite.ConditionIndex, emptyLeaf, l)); + } + else + { + foreach (var h in hiTerms) + { + foreach (var l in loTerms) + { + result.Add(TransitionTerm>.Ite( + ite.ConditionIndex, h, l)); + } + } + } + } + + /// + /// DnfLeaves form: collapses a transition term with Dnf leaves into + /// a single transition term whose leaves carry the *union* of the + /// DNF clauses as one . This avoids + /// the multiplicative blowup of + /// (which distributes DNF clauses through the BDD structure with a + /// Cartesian-product factor at every internal node). Semantically + /// equivalent for NBW consumers that treat outer lists as disjunction + /// and leaf state-sets as nondeterministic choice over successors. + /// + private static TransitionTerm> CollapseDnfToStateSet( + TransitionTerm> term, + IComparer stateComparer, + IEqualityComparer stateEquality) + { + if (term is TransitionTermLeaf> leaf) + { + var dnf = leaf.Value; + if (dnf.IsFalse || dnf.Clauses.Count == 0) + return TransitionTerm>.Leaf( + StateSet.Empty(stateComparer)); + if (dnf.Clauses.Count == 1) + return TransitionTerm>.Leaf(dnf.Clauses[0]); + var seen = new HashSet(stateEquality); + var union = new List(); + foreach (var clause in dnf.Clauses) + foreach (var s in clause) + if (seen.Add(s)) + union.Add(s); + return TransitionTerm>.Leaf( + new StateSet(union, stateComparer)); + } + var ite = (TransitionTermIte>)term; + return TransitionTerm>.Ite( + ite.ConditionIndex, + CollapseDnfToStateSet(ite.Hi, stateComparer, stateEquality), + CollapseDnfToStateSet(ite.Lo, stateComparer, stateEquality)); + } + + /// + /// Explores the NBW produced by alternation elimination, + /// discovering all reachable states up to a given bound. + /// Returns the number of states discovered. + /// + /// The NBW to explore. + /// Maximum number of states to discover (0 = unlimited). + /// The set of discovered states. + public static IReadOnlyCollection Explore( + SymbolicNBW nbw, + int maxStates = 0) + { + var visited = new HashSet(); + var worklist = new Queue(); + + foreach (var init in nbw.InitialStates) + { + if (visited.Add(init)) + worklist.Enqueue(init); + } + + while (worklist.Count > 0) + { + if (maxStates > 0 && visited.Count >= maxStates) + break; + + var state = worklist.Dequeue(); + var transitions = nbw.GetTransition(state); + + foreach (var term in transitions) + { + foreach (var leaf in term.GetDistinctLeaves()) + { + foreach (var s in leaf) + { + if (visited.Add(s)) + { + worklist.Enqueue(s); + } + } + } + } + } + + return visited; + } + } + + /// + /// A breakpoint state (S, O) for the Miyano-Hayashi construction. + /// S is the macrostate (set of ABW states) and O ⊆ S is the + /// obligation set tracking acceptance requirements. + /// + /// The NBW accepts (reaches a breakpoint) when O = ∅. + /// + public sealed class BreakpointState : IEquatable> + { + /// The macrostate: set of ABW states simultaneously active. + public StateSet Macrostate { get; } + + /// + /// The obligation set: ABW states in the macrostate that still need + /// to visit an accepting state before the next breakpoint. + /// When empty, a breakpoint is reached (NBW accepting state). + /// + public StateSet Obligation { get; } + + private int? _hash; + + public BreakpointState(StateSet macrostate, StateSet obligation) + { + Macrostate = macrostate ?? throw new ArgumentNullException(nameof(macrostate)); + Obligation = obligation ?? throw new ArgumentNullException(nameof(obligation)); + } + + public bool Equals(BreakpointState other) + { + if (other == null) return false; + if (ReferenceEquals(this, other)) return true; + return Macrostate.Equals(other.Macrostate) && Obligation.Equals(other.Obligation); + } + + public override bool Equals(object obj) => Equals(obj as BreakpointState); + + public override int GetHashCode() + { + if (_hash == null) + { + unchecked + { + _hash = Macrostate.GetHashCode() * 31 + Obligation.GetHashCode(); + } + } + return _hash.Value; + } + + public override string ToString() + => $"({Macrostate}, {Obligation})"; + + /// + /// Gets a comparer for breakpoint states based on the underlying state comparer. + /// Orders by macrostate first, then by obligation. + /// + public static IComparer> GetComparer( + IComparer stateComparer) + { + return System.Collections.Generic.Comparer>.Create( + (a, b) => + { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + int cmp = a.Macrostate.CompareTo(b.Macrostate); + if (cmp != 0) return cmp; + return a.Obligation.CompareTo(b.Obligation); + }); + } + + /// + /// Gets an equality comparer for breakpoint states. + /// + public static IEqualityComparer> GetEqualityComparer() + { + return EqualityComparer>.Default; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/BpWeakEquivalenceMinimizer.cs b/Accordant.ModelChecking/Symbolic/BpWeakEquivalenceMinimizer.cs new file mode 100644 index 0000000..0c71ee9 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/BpWeakEquivalenceMinimizer.cs @@ -0,0 +1,586 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Bisimulation-style minimiser implementing the JACM state-reduction + /// lemma (Lemma 5.x, used in the Example 5.1 walk-through) on the + /// breakpoint-NBW produced by . + /// + /// Two NBW states s, s' are weakly equivalent iff + /// IsAccepting(s) = IsAccepting(s') AND, modulo the equivalence, + /// their transition terms σ(s), σ(s') are structurally equal. + /// For breakpoint states, the acceptance condition O=∅ coincides + /// with the paper's "U=∅ ⟺ U'=∅" colour split, so a single + /// IsAccepting-based initial partition suffices. + /// + /// This is the standard NBW partition-refinement (Hopcroft-style + /// without the splitter optimisation): cost is + /// O(|S|² · |TT|) in the worst case but typically near-linear + /// on the breakpoint graphs we see. Crucially it operates on the + /// already-constructed NBW graph, so it does not invoke per-pair NBW + /// emptiness checks the way the language-equivalence-based + /// does — making + /// it tractable on inputs like GFa ∧ GFb ∧ GFc where the + /// language-level merger times out. + /// + public static class BpWeakEquivalenceMinimizer + { + /// + /// Builds an eager NBW whose states are equivalence classes of + /// the input NBW under structural bisimulation respecting + /// acceptance. Each class is represented by an arbitrary member. + /// + public static SymbolicNBW Minimize( + SymbolicNBW nbw, + IEqualityComparer eqCmp, + IComparer ordCmp, + int hardCap = 100_000) + { + if (nbw == null) throw new ArgumentNullException(nameof(nbw)); + if (eqCmp == null) throw new ArgumentNullException(nameof(eqCmp)); + if (ordCmp == null) throw new ArgumentNullException(nameof(ordCmp)); + + // 1. Force lazy expansion: discover all reachable states. + var reachable = new List(); + var seen = new HashSet(eqCmp); + var work = new Queue(); + foreach (var s in nbw.InitialStates) + if (seen.Add(s)) { reachable.Add(s); work.Enqueue(s); } + while (work.Count > 0) + { + if (reachable.Count > hardCap) return nbw; // bail out + var s = work.Dequeue(); + foreach (var tt in nbw.GetTransition(s)) + foreach (var leaf in tt.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) { reachable.Add(succ); work.Enqueue(succ); } + } + + // 2. Initial partition by acceptance. + var classOf = new Dictionary(eqCmp); + foreach (var s in reachable) + classOf[s] = nbw.IsAccepting(s) ? 1 : 0; + + // Per-state cached transitions (avoid recomputation per refine + // iteration; transitions don't change as classes refine). + var trans = new Dictionary>>>(eqCmp); + foreach (var s in reachable) + trans[s] = nbw.GetTransition(s); + + // 3. Partition refinement to fixpoint. + bool changed = true; + while (changed) + { + changed = false; + int nextId = 0; + foreach (var c in classOf.Values) if (c >= nextId) nextId = c + 1; + + var groups = new Dictionary>(); + foreach (var s in reachable) + { + if (!groups.TryGetValue(classOf[s], out var g)) + groups[classOf[s]] = g = new List(); + g.Add(s); + } + + var nextClassOf = new Dictionary(eqCmp); + foreach (var kv in groups) + { + var members = kv.Value; + if (members.Count == 1) { nextClassOf[members[0]] = kv.Key; continue; } + + // Sub-group by mapped-transition signature. + var bySig = new Dictionary>(); + foreach (var s in members) + { + var sig = SignatureOf(trans[s], classOf); + if (!bySig.TryGetValue(sig, out var lst)) + bySig[sig] = lst = new List(); + lst.Add(s); + } + + if (bySig.Count == 1) + { + foreach (var s in members) nextClassOf[s] = kv.Key; + } + else + { + changed = true; + bool first = true; + foreach (var subKv in bySig) + { + int id = first ? kv.Key : nextId++; + first = false; + foreach (var s in subKv.Value) nextClassOf[s] = id; + } + } + } + classOf = nextClassOf; + } + + // 4. Build the quotient. + var repOf = new Dictionary(); + foreach (var s in reachable) + if (!repOf.ContainsKey(classOf[s])) repOf[classOf[s]] = s; + + // Comparer used by the existing StateSets so we can rebuild + // rep-mapped leaves with the same identity. + IComparer leafCmp = ordCmp; + foreach (var s in reachable) + { + foreach (var tt in trans[s]) + { + foreach (var leaf in tt.GetDistinctLeaves()) + { + if (leaf != null) { leafCmp = leaf.Comparer; goto found; } + } + } + } + found: + + var newInitials = new List(); + var initialSeen = new HashSet(eqCmp); + foreach (var s in nbw.InitialStates) + { + var rep = repOf[classOf[s]]; + if (initialSeen.Add(rep)) newInitials.Add(rep); + } + + var newTransitions = new Dictionary>>>(eqCmp); + foreach (var rep in repOf.Values) + { + var src = trans[rep]; + var mapped = new List>>(src.Count); + foreach (var tt in src) + mapped.Add(MapLeaves(tt, classOf, repOf, leafCmp)); + newTransitions[rep] = mapped; + } + + return new SymbolicNBW( + nbw.Eba, nbw.Registry, newInitials, nbw.IsAccepting, newTransitions, eqCmp); + } + + /// + /// Fused construction + bisimulation minimisation: only expand + /// canonical class representatives, with online partition + /// refinement after every expansion. This implements the JACM + /// AElim algorithm with the state-reduction lemma applied + /// in-place (Section 5), avoiding the introduction of breakpoint + /// states that would later be eliminated by post-construction + /// minimisation. + /// + /// Algorithm sketch: + /// + /// Each discovered BP is initially placed in a coarse + /// "colour class" (accepting / non-accepting). + /// The current partition is recomputed by hashing + /// (colour, sig) where sig is the + /// class-id-rewritten transition list (only defined for + /// BPs whose σ has been computed). Iterate to fixpoint. + /// Pick an unexplored BP that is the first member of its + /// class (canonical rep). Compute its σ; discover its + /// successors; mark explored. + /// Repeat until no unexplored reps remain. + /// + /// + /// An unexplored BP that is bisim-equivalent to an already + /// explored rep is never itself expanded — its σ is taken to be + /// the rep's σ. This is the desired "fusion": the algorithm pays + /// for at most one σ-computation per equivalence class. + /// + public static SymbolicNBW MinimizeFused( + SymbolicNBW nbw, + IEqualityComparer eqCmp, + IComparer ordCmp, + int hardCap = 100_000) + { + if (nbw == null) throw new ArgumentNullException(nameof(nbw)); + if (eqCmp == null) throw new ArgumentNullException(nameof(eqCmp)); + if (ordCmp == null) throw new ArgumentNullException(nameof(ordCmp)); + + // All discovered BPs, in discovery order (first member of a + // class in this order is its canonical rep). + var discovered = new List(); + var discoveredSet = new HashSet(eqCmp); + // Currently assigned class id per BP. + var classOf = new Dictionary(eqCmp); + // σ of every BP whose transitions have been computed. + var trans = new Dictionary>>>(eqCmp); + var explored = new HashSet(eqCmp); + + // Colour classes 0 / 1 are reserved for not-yet-explored BPs + // (non-accepting / accepting). Explored BPs receive fresh + // class IDs ≥ 2 from RecomputePartition. + void Discover(TState s) + { + if (!discoveredSet.Add(s)) return; + discovered.Add(s); + // Unexplored BPs sit in colour class -1 (non-accepting) or + // -2 (accepting); these IDs cannot collide with the + // discovery-index-based IDs assigned by RecomputePartition + // to explored BPs (which are ≥ 2). + classOf[s] = nbw.IsAccepting(s) ? -2 : -1; + } + + foreach (var s in nbw.InitialStates) Discover(s); + + int safetyIterations = 0; + int safetyCap = (hardCap + 16) * 16; + while (true) + { + if (discovered.Count > hardCap) return nbw; + if (++safetyIterations > safetyCap) + throw new InvalidOperationException( + "BpWeakEquivalenceMinimizer.MinimizeFused did not converge."); + + // (1) Re-partition to local fixpoint. + bool partitionChanged; + do { partitionChanged = RecomputePartition(discovered, trans, explored, classOf, nbw); } + while (partitionChanged); + + // (2) Pick the first unexplored canonical rep, if any. + TState toExpand = default; + bool found = false; + var seenClass = new HashSet(); + foreach (var bp in discovered) + { + int cid = classOf[bp]; + if (!seenClass.Add(cid)) continue; // bp not rep of its class + if (explored.Contains(bp)) continue; // rep already explored + toExpand = bp; found = true; break; + } + if (!found) break; + + // (3) Expand it: compute σ, discover successors. + var σ = nbw.GetTransition(toExpand); + trans[toExpand] = σ; + explored.Add(toExpand); + foreach (var tt in σ) + foreach (var leaf in tt.GetDistinctLeaves()) + foreach (var succ in leaf) + Discover(succ); + } + + // Build the quotient. Reps = first-encountered per class. + var repOf = new Dictionary(); + foreach (var s in discovered) + if (!repOf.ContainsKey(classOf[s])) repOf[classOf[s]] = s; + + // Recover the leaf comparer used by the original transitions. + IComparer leafCmp = ordCmp; + foreach (var s in discovered) + { + if (!trans.TryGetValue(s, out var σ)) continue; + foreach (var tt in σ) + foreach (var leaf in tt.GetDistinctLeaves()) + if (leaf != null) { leafCmp = leaf.Comparer; goto found; } + } + found: + + var newInitials = new List(); + var initialSeen = new HashSet(eqCmp); + foreach (var s in nbw.InitialStates) + { + var rep = repOf[classOf[s]]; + if (initialSeen.Add(rep)) newInitials.Add(rep); + } + + var newTransitions = new Dictionary>>>(eqCmp); + foreach (var rep in repOf.Values) + { + if (!trans.TryGetValue(rep, out var src)) + { + // Should not happen: every rep is explored before loop exit. + src = nbw.GetTransition(rep); + } + var mapped = new List>>(src.Count); + foreach (var tt in src) + mapped.Add(MapLeaves(tt, classOf, repOf, leafCmp)); + newTransitions[rep] = mapped; + } + + return new SymbolicNBW( + nbw.Eba, nbw.Registry, newInitials, nbw.IsAccepting, newTransitions, eqCmp); + } + + /// + /// Recompute based on (colour, sig) + /// hash-consing of explored states. Unexplored states stay in + /// their colour class. Class IDs are the discovery-index of the + /// canonical representative — they are stable across calls as long + /// as the partition itself does not change, so change-detection is + /// reliable. Returns true iff any class assignment changed. + /// + private static bool RecomputePartition( + List discovered, + Dictionary>>> trans, + HashSet explored, + Dictionary classOf, + SymbolicNBW nbw) + { + var sigIndex = new Dictionary<(int colour, TransitionListSignature sig), int>(); + // Snapshot: compute everything using the OLD classOf, commit at the end. + var newClassOf = new int[discovered.Count]; + for (int i = 0; i < discovered.Count; i++) + { + var bp = discovered[i]; + int colour = nbw.IsAccepting(bp) ? 1 : 0; + int newCid; + if (explored.Contains(bp)) + { + var sig = SignatureOf(trans[bp], classOf); + var key = (colour, sig); + if (!sigIndex.TryGetValue(key, out newCid)) + { + // Stable ID: discovery index of the first (canonical) member. + // Shifted by +2 to leave room for the two reserved + // unexplored colour IDs (-1, -2). + newCid = i + 2; + sigIndex[key] = newCid; + } + } + else + { + // Unexplored: stay in colour class. Negative IDs keep + // them disjoint from any explored class index. + newCid = -1 - colour; + } + newClassOf[i] = newCid; + } + + bool changed = false; + for (int i = 0; i < discovered.Count; i++) + { + var bp = discovered[i]; + if (classOf[bp] != newClassOf[i]) + { + classOf[bp] = newClassOf[i]; + changed = true; + } + } + return changed; + } + + /// + /// A canonical (hashable) signature of a transition list under the + /// current class map. Two states with equal signatures and equal + /// initial colour are bisimulation-equivalent at the current iterate. + /// + private readonly struct TransitionListSignature : IEquatable + { + private readonly TransitionTerm>[] _terms; + private readonly int _hash; + + public TransitionListSignature(TransitionTerm>[] sortedTerms) + { + _terms = sortedTerms; + int h = 17; + foreach (var t in sortedTerms) h = unchecked(h * 31 + t.GetHashCode()); + _hash = h; + } + + public bool Equals(TransitionListSignature other) + { + if (_terms.Length != other._terms.Length) return false; + for (int i = 0; i < _terms.Length; i++) + if (!_terms[i].Equals(other._terms[i])) return false; + return true; + } + public override bool Equals(object obj) + => obj is TransitionListSignature s && Equals(s); + public override int GetHashCode() => _hash; + } + + /// + /// Lightweight on-the-fly leaf dedup. NOT a minimisation: just checks + /// whether a newly discovered state has a transition term structurally + /// identical (modulo current canonicalisation of successors) to some + /// already-canonical state of the same colour (accepting/non-accepting). + /// If so, the new state aliases to the existing one. Otherwise it + /// becomes a fresh canonical state. + /// + /// No partition fixpoint, no per-pair language checks. The + /// algorithm visits each successor exactly once (lazy DFS) and pays + /// at most one signature comparison per discovered state. It will + /// miss equivalences that require cyclic structural alignment, but + /// catches all tree-shaped duplications. + /// + public static SymbolicNBW DedupOnTheFly( + SymbolicNBW nbw, + IEqualityComparer eqCmp, + IComparer ordCmp) + { + if (nbw == null) throw new ArgumentNullException(nameof(nbw)); + if (eqCmp == null) throw new ArgumentNullException(nameof(eqCmp)); + if (ordCmp == null) throw new ArgumentNullException(nameof(ordCmp)); + + var canonical = new Dictionary(eqCmp); + var transOfCanon = new Dictionary>>>(eqCmp); + var indexOf = new Dictionary(eqCmp); + var sigIndex = new Dictionary<(bool acc, TransitionListSignature sig), TState>(); + int nextIndex = 0; + + TState Canonicalize(TState s) + { + if (canonical.TryGetValue(s, out var existingCanon)) + return existingCanon; + + // Tentative: claim s as its own canonical with a fresh index. + int myIdx = nextIndex++; + indexOf[s] = myIdx; + canonical[s] = s; + + var raw = nbw.GetTransition(s); + + // Recurse into successor leaves, canonicalising each state. + var rewritten = new List>>(raw.Count); + foreach (var tt in raw) + rewritten.Add(MapLeavesCanon(tt, Canonicalize, ordCmp)); + + // Build signature over int indices of canonical successors. + var sig = SignatureOfDirect(rewritten, indexOf); + bool acc = nbw.IsAccepting(s); + var key = (acc, sig); + + if (sigIndex.TryGetValue(key, out var existing) && !eqCmp.Equals(existing, s)) + { + // s collapses to the pre-existing canonical state. + canonical[s] = existing; + indexOf.Remove(s); + return existing; + } + + sigIndex[key] = s; + transOfCanon[s] = rewritten; + return s; + } + + var initial = new List(); + var initialSeen = new HashSet(eqCmp); + foreach (var s in nbw.InitialStates) + { + var c = Canonicalize(s); + if (initialSeen.Add(c)) initial.Add(c); + } + + return new SymbolicNBW( + nbw.Eba, nbw.Registry, initial, + s => nbw.IsAccepting(Canonicalize(s)), + s => transOfCanon[Canonicalize(s)], + eqCmp); + } + + private static TransitionTerm> MapLeavesCanon( + TransitionTerm> tt, + Func canonicalize, + IComparer ordCmp) + { + if (tt is TransitionTermLeaf> leaf) + { + if (leaf.Value.IsEmpty) return tt; + var unique = new HashSet(EqualityComparer.Default); + var sorted = new List(); + foreach (var s in leaf.Value) + { + var c = canonicalize(s); + if (unique.Add(c)) sorted.Add(c); + } + sorted.Sort(ordCmp); + return TransitionTerm>.Leaf( + new StateSet(sorted, ordCmp)); + } + var ite = (TransitionTermIte>)tt; + var hi = MapLeavesCanon(ite.Hi, canonicalize, ordCmp); + var lo = MapLeavesCanon(ite.Lo, canonicalize, ordCmp); + return TransitionTerm>.Ite(ite.ConditionIndex, hi, lo); + } + + private static TransitionListSignature SignatureOfDirect( + IReadOnlyList>> terms, + Dictionary indexOf) + { + var mapped = new TransitionTerm>[terms.Count]; + for (int i = 0; i < terms.Count; i++) + mapped[i] = MapToClasses(terms[i], indexOf); + Array.Sort(mapped, (a, b) => + { + int hc = a.GetHashCode().CompareTo(b.GetHashCode()); + if (hc != 0) return hc; + return a.Equals(b) ? 0 : string.CompareOrdinal(a.ToString(), b.ToString()); + }); + return new TransitionListSignature(mapped); + } + + private static readonly IComparer IntComparer = Comparer.Default; + + private static TransitionListSignature SignatureOf( + IReadOnlyList>> terms, + Dictionary classOf) + { + var mapped = new TransitionTerm>[terms.Count]; + for (int i = 0; i < terms.Count; i++) + mapped[i] = MapToClasses(terms[i], classOf); + // Sort by structural hash so list order doesn't matter. + Array.Sort(mapped, (a, b) => + { + int hc = a.GetHashCode().CompareTo(b.GetHashCode()); + if (hc != 0) return hc; + return a.Equals(b) ? 0 : string.CompareOrdinal(a.ToString(), b.ToString()); + }); + return new TransitionListSignature(mapped); + } + + private static TransitionTerm> MapToClasses( + TransitionTerm> tt, + Dictionary classOf) + { + if (tt is TransitionTermLeaf> leaf) + { + var ids = new SortedSet(); + foreach (var s in leaf.Value) ids.Add(classOf[s]); + var arr = new int[ids.Count]; + ids.CopyTo(arr); + return TransitionTerm>.Leaf( + new StateSet(arr, IntComparer)); + } + var ite = (TransitionTermIte>)tt; + var hi = MapToClasses(ite.Hi, classOf); + var lo = MapToClasses(ite.Lo, classOf); + return TransitionTerm>.Ite(ite.ConditionIndex, hi, lo); + } + + private static TransitionTerm> MapLeaves( + TransitionTerm> tt, + Dictionary classOf, + Dictionary repOf, + IComparer leafCmp) + { + if (tt is TransitionTermLeaf> leaf) + { + if (leaf.Value.IsEmpty) + return TransitionTerm>.Leaf(leaf.Value); + var reps = new HashSet(EqualityComparer.Default); + // Use the leaf's own comparer (assumed consistent with eqCmp + // identity from the same algebra) via a sorted set instead. + var sorted = new List(); + var seen = new HashSet(); + foreach (var s in leaf.Value) + { + int cid = classOf[s]; + if (seen.Add(cid)) sorted.Add(repOf[cid]); + } + sorted.Sort(leafCmp); + return TransitionTerm>.Leaf( + new StateSet(sorted, leafCmp)); + } + var ite = (TransitionTermIte>)tt; + var hi = MapLeaves(ite.Hi, classOf, repOf, leafCmp); + var lo = MapLeaves(ite.Lo, classOf, repOf, leafCmp); + return TransitionTerm>.Ite(ite.ConditionIndex, hi, lo); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/ConditionRegistry.cs b/Accordant.ModelChecking/Symbolic/ConditionRegistry.cs new file mode 100644 index 0000000..16856d8 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/ConditionRegistry.cs @@ -0,0 +1,228 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Manages an ordered set of conditions from an EBA. + /// Each condition is assigned a unique integer index that defines the + /// ordering invariant for transition terms (ADDs). + /// + /// In the ITE structure (α ? f : g), inner ITEs must have strictly + /// larger condition indices than outer ITEs, creating a canonical + /// representation similar to algebraic decision diagrams. + /// + /// The type of predicates in the EBA. + public class ConditionRegistry + { + /// + /// Maximum number of propositions supported by the current + /// metadata representation (a 64-bit free-props bitset on + /// Ere<TPred>). See EREQ Phase-0 design lock-in + /// (D2) in the session plan. + /// + public const int MaxPropositions = 64; + + private readonly List _predicates = new List(); + private readonly Dictionary _indices; + private readonly IPredicateAlgebra _algebra; + private int _solverAliasCount; + + // Proposition support (EREQ). Indices are allocated as + // -1, -2, -3, ... so that they sort outermost under our + // inner-larger ITE ordering. + private readonly List _propositions = new List(); + private readonly Dictionary _propositionIndices = new Dictionary(StringComparer.Ordinal); + + /// + /// Creates a new condition registry. + /// + /// + /// Equality comparer for predicates, used to detect duplicate registrations. + /// If null, the default comparer is used. + /// + public ConditionRegistry(IEqualityComparer predicateComparer = null) + : this(predicateComparer, null) + { + } + + /// + /// Creates a new condition registry with optional solver-aware + /// predicate aliasing. When is non-null, + /// falls back from structural lookup to + /// + /// against existing entries; semantically equivalent predicates are + /// aliased to a single index. This is the predicate-level analogue + /// of the regex-level union-find performed by + /// . + /// + /// The aliasing is correct under any : + /// the default reduces to + /// !IsSatisfiable(Xor(a,b)), which a conservative + /// IsSatisfiable=true EBA simply never accepts. Precision + /// improves with the EBA — an SMT-backed + /// can decide it precisely and + /// collapse many condition indices. + /// + /// + public ConditionRegistry( + IEqualityComparer predicateComparer, + IPredicateAlgebra algebra) + { + _indices = new Dictionary(predicateComparer ?? EqualityComparer.Default); + _algebra = algebra; + } + + /// + /// The number of registered conditions. + /// + public int Count => _predicates.Count; + + /// + /// Number of solver-aware aliases recorded so far — i.e. the count + /// of calls that found a structurally novel + /// predicate but aliased it to an existing index via + /// . Zero when no + /// algebra was provided. + /// + public int SolverAliasCount => _solverAliasCount; + + /// + /// Registers a condition and returns its index. + /// If the condition is already registered, returns the existing index. + /// + public int Register(TPredicate predicate) + { + if (predicate == null) + throw new ArgumentNullException(nameof(predicate)); + + if (_indices.TryGetValue(predicate, out var existing)) + return existing; + + if (_algebra != null) + { + for (int i = 0; i < _predicates.Count; i++) + { + if (_algebra.AreEquivalent(predicate, _predicates[i])) + { + _indices[predicate] = i; + _solverAliasCount++; + return i; + } + } + } + + var index = _predicates.Count; + _predicates.Add(predicate); + _indices[predicate] = index; + return index; + } + + /// + /// Gets the predicate for a given condition index. + /// + public TPredicate GetPredicate(int index) + { + if (index < 0 || index >= _predicates.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + return _predicates[index]; + } + + /// + /// Gets the index of a previously registered predicate. + /// Returns -1 if not found. + /// + public int IndexOf(TPredicate predicate) + { + return _indices.TryGetValue(predicate, out var index) ? index : -1; + } + + /// + /// Returns all registered predicates in order. + /// + public IReadOnlyList Predicates => _predicates; + + // --------------------------------------------------------------- + // Proposition support (EREQ Phase 1, design lock-in D1). + // + // Propositions are a separate condition kind from predicates and + // occupy negative indices (-1, -2, -3, ...). Their separation + // from means the EBA never sees them + // as predicates, while their negative index value places them + // outermost in transition terms under the existing + // inner-larger ITE ordering invariant. + // --------------------------------------------------------------- + + /// + /// The number of registered propositions. + /// + public int PropositionCount => _propositions.Count; + + /// + /// All registered proposition names, in registration order + /// (so Propositions[i] has index -(i+1)). + /// + public IReadOnlyList Propositions => _propositions; + + /// + /// Returns true iff denotes a + /// proposition (i.e. is strictly negative). + /// + public static bool IsProposition(int index) => index < 0; + + /// + /// Registers a proposition by name and returns its index. If a + /// proposition with the same name was already registered, the + /// existing index is returned. New propositions receive + /// monotonically decreasing negative indices starting at -1. + /// + /// + /// Thrown when registering would exceed + /// . + /// + public int RegisterProposition(string name) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + if (_propositionIndices.TryGetValue(name, out var existing)) + return existing; + + if (_propositions.Count >= MaxPropositions) + throw new InvalidOperationException( + $"ConditionRegistry supports at most {MaxPropositions} propositions."); + + var index = -(_propositions.Count + 1); + _propositions.Add(name); + _propositionIndices[name] = index; + return index; + } + + /// + /// Returns the name of a previously registered proposition. + /// + public string GetPropositionName(int index) + { + if (!IsProposition(index)) + throw new ArgumentOutOfRangeException(nameof(index), + "Index is not a proposition (must be strictly negative)."); + var pos = -index - 1; + if (pos >= _propositions.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + return _propositions[pos]; + } + + /// + /// Returns the index of a previously registered proposition, + /// or 0 if none is registered under that name. (Zero is + /// safe as a not-found sentinel because all valid proposition + /// indices are strictly negative.) + /// + public int IndexOfProposition(string name) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + return _propositionIndices.TryGetValue(name, out var idx) ? idx : 0; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/Dnf.cs b/Accordant.ModelChecking/Symbolic/Dnf.cs new file mode 100644 index 0000000..9619fbf --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/Dnf.cs @@ -0,0 +1,254 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// A positive Boolean formula B⁺(Q) in disjunctive normal form (DNF). + /// (Section 4.1 of the paper) + /// + /// Represents a disjunction of conjunctions of states: + /// φ = (q₁ ∧ q₂ ∧ ...) ∨ (q₃ ∧ q₄ ∧ ...) ∨ ... + /// + /// Each conjunction is a (states that must ALL + /// hold simultaneously), and the formula is satisfied if ANY conjunction is satisfied. + /// + /// Maintained in canonical form: clauses form a minimal antichain + /// (no clause is a subset of another), sorted lexicographically. + /// This ensures structural equality ⟺ semantic equivalence. + /// + /// + /// ⊤ = { {} } — one empty conjunction (always true) + /// ⊥ = { } — no conjunctions (always false) + /// atom(q) = { {q} } — state q must hold + /// φ ∨ ψ = union of clauses, then minimize + /// φ ∧ ψ = { c₁ ∪ c₂ | c₁ ∈ φ, c₂ ∈ ψ }, then minimize + /// + /// + public sealed class Dnf : IEquatable> + { + private readonly StateSet[] _clauses; // sorted, minimal antichain + private int? _hash; + + internal Dnf(StateSet[] clauses) + { + _clauses = clauses; + } + + /// The clauses (conjunctions) of this DNF formula. + public IReadOnlyList> Clauses => _clauses; + + /// True if this is ⊤ = { {} } (one empty conjunction). + public bool IsTrue => _clauses.Length == 1 && _clauses[0].IsEmpty; + + /// True if this is ⊥ = { } (no conjunctions). + public bool IsFalse => _clauses.Length == 0; + + /// Number of clauses (disjuncts). + public int ClauseCount => _clauses.Length; + + /// + /// Returns all states mentioned in any clause. + /// + public IEnumerable GetAllStates() + { + var seen = new HashSet(); + foreach (var clause in _clauses) + foreach (var state in clause) + if (seen.Add(state)) + yield return state; + } + + #region Equality + + public bool Equals(Dnf other) + { + if (other == null) return false; + if (ReferenceEquals(this, other)) return true; + if (_clauses.Length != other._clauses.Length) return false; + for (int i = 0; i < _clauses.Length; i++) + if (!_clauses[i].Equals(other._clauses[i])) + return false; + return true; + } + + public override bool Equals(object obj) => Equals(obj as Dnf); + + public override int GetHashCode() + { + if (_hash == null) + { + unchecked + { + int h = 17; + foreach (var c in _clauses) + h = h * 31 + c.GetHashCode(); + _hash = h; + } + } + return _hash.Value; + } + + public static bool operator ==(Dnf left, Dnf right) + { + if (ReferenceEquals(left, right)) return true; + if (left is null || right is null) return false; + return left.Equals(right); + } + + public static bool operator !=(Dnf left, Dnf right) + => !(left == right); + + #endregion + + public override string ToString() + { + if (IsFalse) return "⊥"; + if (IsTrue) return "⊤"; + return string.Join(" ∨ ", _clauses.Select(c => + c.Count == 1 ? c.First().ToString() : + "(" + string.Join(" ∧ ", c) + ")")); + } + } + + /// + /// Leaf algebra for B⁺(Q) in DNF form. + /// Implements for . + /// + /// B⁺(Q) is a positive Boolean algebra (no negation). The + /// method throws . + /// + /// All operations maintain the minimal antichain invariant via subsumption + /// checking: if clause c₁ ⊆ c₂, then c₂ is removed (c₁ is more general). + /// + public class DnfAlgebra : ILeafAlgebra> + { + private readonly IComparer _stateComparer; + private readonly Dnf _top; + private readonly Dnf _bottom; + + public DnfAlgebra(IComparer stateComparer) + { + _stateComparer = stateComparer ?? throw new ArgumentNullException(nameof(stateComparer)); + _top = new Dnf(new[] { StateSet.Empty(stateComparer) }); + _bottom = new Dnf(Array.Empty>()); + } + + /// The state comparer used for canonical ordering. + public IComparer StateComparer => _stateComparer; + + /// ⊤ = { {} } — satisfied by any state assignment. + public Dnf Top => _top; + + /// ⊥ = { } — never satisfied. + public Dnf Bottom => _bottom; + + /// Creates an atom: { {q} } — state q must hold. + public Dnf Atom(TState state) + => new Dnf(new[] { StateSet.Singleton(state, _stateComparer) }); + + /// Creates a single conjunction clause from a set of states. + public Dnf Clause(IEnumerable states) + => new Dnf(new[] { new StateSet(states, _stateComparer) }); + + /// Creates a Dnf from multiple clauses, normalizing. + public Dnf FromClauses(IEnumerable> clauses) + => new Dnf(Minimize(new List>(clauses))); + + public bool IsTop(Dnf a) => a != null && a.IsTrue; + public bool IsBottom(Dnf a) => a != null && a.IsFalse; + + /// + /// Disjunction: φ ∨ ψ = union of clause sets, then minimize. + /// + public Dnf Or(Dnf a, Dnf b) + { + if (a.IsFalse) return b; + if (b.IsFalse) return a; + if (a.IsTrue || b.IsTrue) return _top; + + var all = new List>(a.ClauseCount + b.ClauseCount); + foreach (var c in a.Clauses) all.Add(c); + foreach (var c in b.Clauses) all.Add(c); + return new Dnf(Minimize(all)); + } + + /// + /// Conjunction: φ ∧ ψ = cross-product { c₁ ∪ c₂ | c₁ ∈ φ, c₂ ∈ ψ }, + /// then minimize. + /// + public Dnf And(Dnf a, Dnf b) + { + if (a.IsFalse || b.IsFalse) return _bottom; + if (a.IsTrue) return b; + if (b.IsTrue) return a; + + var result = new List>(a.ClauseCount * b.ClauseCount); + foreach (var c1 in a.Clauses) + foreach (var c2 in b.Clauses) + result.Add(c1.Union(c2)); + return new Dnf(Minimize(result)); + } + + /// + /// B⁺(Q) does not support negation. Always throws. + /// + public Dnf Not(Dnf a) + => throw new NotSupportedException( + "Positive Boolean formulas (B⁺) do not support negation."); + + public Dnf Xor(Dnf a, Dnf b) + => throw new NotSupportedException( + "Positive Boolean formulas (B⁺) do not support XOR (requires negation)."); + + public IEqualityComparer> Comparer + => EqualityComparer>.Default; + + /// + /// Minimizes a set of clauses to a canonical minimal antichain: + /// removes subsumed clauses (c₁ ⊆ c₂ means c₂ is redundant) and + /// sorts lexicographically. + /// + internal StateSet[] Minimize(List> clauses) + { + if (clauses.Count == 0) return Array.Empty>(); + if (clauses.Count == 1) return clauses.ToArray(); + + // Sort by size for efficient subsumption: smaller clauses first + clauses.Sort((a, b) => a.Count.CompareTo(b.Count)); + + var minimal = new List>(); + foreach (var c in clauses) + { + // Is c subsumed by some existing minimal clause? + // (m ⊆ c means m is more general → c is redundant) + bool subsumed = false; + foreach (var m in minimal) + { + if (m.IsSubsetOf(c)) + { + subsumed = true; + break; + } + } + if (!subsumed) + { + // Since we process by ascending size, c cannot subsume + // any existing clause of strictly smaller size. + // But equal-size clauses may need removal. + for (int i = minimal.Count - 1; i >= 0; i--) + { + if (c.IsSubsetOf(minimal[i])) + minimal.RemoveAt(i); + } + minimal.Add(c); + } + } + + // Canonical ordering: lexicographic + minimal.Sort((a, b) => a.CompareTo(b)); + return minimal.ToArray(); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EbaExtensions.cs b/Accordant.ModelChecking/Symbolic/EbaExtensions.cs new file mode 100644 index 0000000..f6d0211 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EbaExtensions.cs @@ -0,0 +1,80 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// Capability-probing helpers for / + /// . Algorithms that wish + /// to take advantage of a precise decision procedure when one is + /// available — but stay correct when it is not — call into these + /// extension methods rather than the interface members directly. + /// + /// Each method first checks for the corresponding + /// …Ex interface ( or + /// ); if found, it + /// delegates. Otherwise it falls back to a sound default expressed + /// in terms of . + /// + /// Defaults: + /// + /// AreEquivalent(a, b) := !IsSatisfiable((a ∧ ¬b) ∨ (¬a ∧ b)) + /// Implies(a, b) := !IsSatisfiable(a ∧ ¬b) + /// TryGetModel(p) := (false, default) — no model. + /// + /// + /// The fallback for AreEquivalent / Implies is + /// conservative-correct under the standing EBA contract that + /// may be a + /// conservative-true approximation (it never reports false for a + /// genuinely satisfiable predicate). When IsSatisfiable is + /// only conservative-true the fallbacks may return false for + /// equivalent inputs — i.e. they are sound but incomplete. SMT-backed + /// EBAs override and get precise results in one solver call. + /// + public static class EbaExtensions + { + /// + /// Decide whether two predicates denote the same set of elements. + /// Uses when + /// the algebra implements it; otherwise !IsSatisfiable(a ⊕ b). + /// + public static bool AreEquivalent(this IPredicateAlgebra algebra, T a, T b) + { + if (algebra == null) throw new System.ArgumentNullException(nameof(algebra)); + if (algebra is IPredicateAlgebraEx ex) return ex.AreEquivalent(a, b); + // Fallback: a ⊕ b ≡ (a ∧ ¬b) ∨ (¬a ∧ b). + var notA = algebra.Not(a); + var notB = algebra.Not(b); + var xor = algebra.Or(algebra.And(a, notB), algebra.And(notA, b)); + return !algebra.IsSatisfiable(xor); + } + + /// + /// Decide whether implies . + /// Uses when available; + /// otherwise !IsSatisfiable(a ∧ ¬b). + /// + public static bool Implies(this IPredicateAlgebra algebra, T a, T b) + { + if (algebra == null) throw new System.ArgumentNullException(nameof(algebra)); + if (algebra is IPredicateAlgebraEx ex) return ex.Implies(a, b); + return !algebra.IsSatisfiable(algebra.And(a, algebra.Not(b))); + } + + /// + /// Try to extract a concrete element satisfying + /// . Uses + /// when + /// available; otherwise returns false. + /// + public static bool TryGetModel( + this IEffectiveBooleanAlgebra algebra, + TPredicate predicate, + out TElement element) + { + if (algebra == null) throw new System.ArgumentNullException(nameof(algebra)); + if (algebra is IEffectiveBooleanAlgebraEx ex) + return ex.TryGetModel(predicate, out element); + element = default; + return false; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EndToEndCheck.cs b/Accordant.ModelChecking/Symbolic/EndToEndCheck.cs new file mode 100644 index 0000000..8e1f79a --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EndToEndCheck.cs @@ -0,0 +1,254 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Text; + + + /// + /// End-to-end model-program × RLTL benchmark driver. Composes a model + /// program's state graph () with the + /// alternation-eliminated NBW for the negation of an RLTL property and + /// runs nested DFS / SCC emptiness, returning a verdict together with + /// instrumentation suitable for comparing + /// (the default + /// path, where NBW states are only materialised as the product + /// emptiness check requests them) against + /// (force the full reachable NBW first, + /// then run the same emptiness check). + /// + /// This is the driver that backs paper §6.2 end-to-end + /// measurements — the missing complement to the standalone NBW + /// microbenchmarks in NbwProductBenchmarkTests. + /// + public static class EndToEndCheck + { + /// NBW construction strategy. + public enum Mode + { + /// Default path: lazy NBW expansion driven by product + /// emptiness exploration (matches ). + /// The NBW footprint after the check reflects exactly the + /// fragment touched by the product search. + Lazy, + + /// Force the complete reachable NBW to be materialised + /// before the product search. Measures the cost of building the + /// property automaton in isolation, regardless of whether the + /// model program ever reaches a state where it matters. + Eager, + } + + /// Per-run report. + public sealed class Report + { + /// Mode that produced this report. + public Mode Mode { get; internal set; } + /// True iff the property holds (no counterexample). + public bool Valid { get; internal set; } + /// Length of the counterexample lasso (prefix+cycle), or 0. + public int CounterexampleLength { get; internal set; } + + /// Total reachable states in the model program's state + /// graph (BFS from root over + /// Edges). + public int ModelStates { get; internal set; } + /// Initial NBW states. + public int NbwInitialStates { get; internal set; } + /// NBW states discovered by end of the run. In + /// , this is the product-exploration + /// footprint; in , this is the full + /// reachable NBW. + public int NbwStatesDiscovered { get; internal set; } + /// NBW transitions cached by end of the run. + public int NbwTransitionsCached { get; internal set; } + /// NBW states reachable in total (only set for + /// , otherwise null). + public int? NbwStatesReachableTotal { get; internal set; } + + /// Wall-clock duration of the construction + check. + public TimeSpan Elapsed { get; internal set; } + /// Time spent in the eager NBW closure phase (Eager only). + public TimeSpan EagerClosureElapsed { get; internal set; } + + /// Human-readable single-line summary. + public string OneLine() + { + var verdict = Valid ? "OK " : "VIOL "; + var lazyTag = NbwStatesReachableTotal.HasValue + ? string.Format( + "{0,5}/{1,-5}", + NbwStatesDiscovered, + NbwStatesReachableTotal.Value) + : string.Format("{0,5} ", NbwStatesDiscovered); + return string.Format( + "{0,-6} {1,-5} M={2,5} NBW(disc/tot)={3} trans={4,5} t={5,7:0.0}ms", + Mode, verdict, ModelStates, + lazyTag, NbwTransitionsCached, Elapsed.TotalMilliseconds); + } + } + + /// + /// Run the end-to-end check. + /// + /// Root of the (already explored) model state graph. + /// RLTL property φ that should hold (the + /// driver checks emptiness of model × NBW(¬φ)). + /// Lazy or Eager NBW expansion strategy. + /// Optional bound on product exploration + /// depth (0 = unlimited). + /// Optional fairness; when non-null, SCC + /// emptiness is used instead of nested DFS. + /// Forwarded to + /// . + /// Default false uses the DnfLeaves form (union of clauses + /// at each BDD leaf), which is the cheaper general-purpose + /// normalisation and is dramatically faster on conjunctive + /// recurrence properties (paper §6.2). Pass true for the + /// legacy eager Antimirov form (ablation studies). + public static Report Run( + StateGraphNode root, + Rltl property, + Mode mode = Mode.Lazy, + int maxDepth = 0, + Fairness fairness = null, + bool eagerAntimirov = false) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (property == null) throw new ArgumentNullException(nameof(property)); + + var report = new Report { Mode = mode }; + report.ModelStates = CountReachableModelStates(root); + + var eba = StatePropEbaProvider.Default; + var registry = new ConditionRegistry( + EqualityComparer.Default); + + var algebra = RltlAlgebra.Default; + + // Build NBW(¬φ) — same construction as SymbolicRltlCheck with + // default knobs (no dedup / minimisation), so reported numbers + // are baseline. Knob-on variants are a follow-up benchmark. + var sw = Stopwatch.StartNew(); + var negPhi = algebra.Not(property); + var derivative = new RltlDerivative( + eba, registry, null, null); + var abw = derivative.ToABW(negPhi); + var incAE = new IncrementalAE>( + abw, null, null, eagerAntimirov); + var nbw = incAE.ToNBW(); + + report.NbwInitialStates = nbw.InitialStates.Count; + + if (mode == Mode.Eager) + { + var swEager = Stopwatch.StartNew(); + ForceMaterialise(nbw); + swEager.Stop(); + report.EagerClosureElapsed = swEager.Elapsed; + report.NbwStatesReachableTotal = nbw.States.Count; + } + + var bpComparer = BreakpointState>.GetEqualityComparer(); + bool useSCC = fairness != null && !ReferenceEquals(fairness, Fairness.None); + var result = useSCC + ? SccProductCheck.Check(root, nbw, maxDepth, bpComparer, fairness) + : NestedDfsCheck.Check(root, nbw, maxDepth, bpComparer); + sw.Stop(); + + report.Elapsed = sw.Elapsed; + report.NbwStatesDiscovered = nbw.States.Count; + report.NbwTransitionsCached = nbw.CachedTransitions.Count; + report.Valid = result.Valid; + report.CounterexampleLength = result.Trace == null + ? 0 + : result.Trace.Count; + return report; + } + + /// + /// BFS over from a root, counting + /// distinct reachable nodes by reference. + /// + private static int CountReachableModelStates(StateGraphNode root) + { + // StateGraphNode does not override Equals, so the default + // comparer is reference equality — which is what we want. + var seen = new HashSet(); + var stack = new Stack(); + stack.Push(root); seen.Add(root); + while (stack.Count > 0) + { + var n = stack.Pop(); + if (n.Edges == null) continue; + foreach (var e in n.Edges) + { + if (e.Target != null && seen.Add(e.Target)) + stack.Push(e.Target); + } + } + return seen.Count; + } + + /// + /// Force the lazy NBW to materialise every reachable state by BFS + /// over . After this + /// call, contains the full + /// reachable set. + /// + private static void ForceMaterialise( + SymbolicNBW>> nbw) + { + var stack = new Stack>>(); + var seen = new HashSet>>( + BreakpointState>.GetEqualityComparer()); + foreach (var s in nbw.InitialStates) + { + if (seen.Add(s)) stack.Push(s); + } + while (stack.Count > 0) + { + var s = stack.Pop(); + var trans = nbw.GetTransition(s); + foreach (var term in trans) + { + foreach (var leaf in term.GetDistinctLeaves()) + { + foreach (var succ in leaf) + { + if (seen.Add(succ)) stack.Push(succ); + } + } + } + } + } + + /// Format a table of reports with a header row. + public static string Tabulate(IEnumerable<(string label, Report report)> rows) + { + var sb = new StringBuilder(); + sb.AppendLine("scenario mode verdict model NBW(disc/tot) trans time(ms)"); + sb.AppendLine("------------------------------------- ------ -------- -------- --------------- --------- ---------"); + foreach (var (label, r) in rows) + { + var nbwCol = r.NbwStatesReachableTotal.HasValue + ? string.Format("{0,5}/{1,-5}", r.NbwStatesDiscovered, r.NbwStatesReachableTotal.Value) + : string.Format("{0,5} ", r.NbwStatesDiscovered); + sb.AppendFormat( + "{0,-37} {1,-6} {2,-8} {3,8} {4,15} {5,9} {6,9:0.0}\n", + Truncate(label, 37), + r.Mode, + r.Valid ? "VALID" : "VIOL", + r.ModelStates, + nbwCol, + r.NbwTransitionsCached, + r.Elapsed.TotalMilliseconds); + } + return sb.ToString(); + } + + private static string Truncate(string s, int n) + => s == null ? string.Empty : (s.Length <= n ? s : s.Substring(0, n)); + } +} diff --git a/Accordant.ModelChecking/Symbolic/Ere.cs b/Accordant.ModelChecking/Symbolic/Ere.cs new file mode 100644 index 0000000..afb075c --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/Ere.cs @@ -0,0 +1,1514 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Extended regular expression (ERE) over a predicate algebra A, + /// generic in the predicate type . + /// EREs are the regex component of RLTL formulas (Section 7 of the + /// POPL'25 paper). + /// + /// Forms: + /// + /// : ∅ (empty language) + /// : ε (language containing only the empty word) + /// : p (single letter satisfying predicate p) + /// : R · S (concatenation) + /// : R + S (ACI-normalized) + /// : R & S (ACI-normalized) + /// : ~R (Σ* \ L(R)) + /// : R* (Kleene star) + /// : R ⊕ S (symmetric difference, AC + self-inverse) + /// + /// + /// Factory methods apply the standard simplifications so structural + /// equality coincides with semantic equivalence on the algebraic core. + /// + public abstract class Ere : IEquatable>, IComparable> + { + private int? _hash; + private int _id = -1; + internal Ere() { } + + /// + /// Unique non-negative identifier within the + /// . Assigned by + /// on first canonicalisation. + /// Id 0 == Bottom (∅), Id 1 == Epsilon (ε). + /// + public int Id => _id; + + /// True once this term has been interned and assigned an Id. + internal bool HasId => _id >= 0; + + internal void AssignId(int id) { _id = id; } + + /// + /// Per- default term builder. All static + /// factories on this type route through it so every returned ERE is + /// canonical (reference-equal to any structurally equivalent term). + /// + public static EreBuilder DefaultBuilder => BuilderHolder.Instance; + + private static class BuilderHolder + { + internal static readonly EreBuilder Instance = new EreBuilder(); + } + + /// Structural kind index for total ordering. + internal abstract int Kind { get; } + + /// True iff the language contains the empty word. + public abstract bool Nullable { get; } + + /// + /// True iff this term contains an + /// node anywhere in its syntax tree. Propagated eagerly at + /// construction; constant-time after caching. Mirrors the Rust + /// EREQ CONTAINS_COMPL meta flag (lib.rs:524–558). Used by + /// and to gate the more expensive + /// rewrite/simplification rules that only matter in the presence + /// of complement. + /// + public virtual bool ContainsCompl => false; + + /// + /// True iff this term contains an + /// node anywhere in its syntax tree. Propagated eagerly at + /// construction. Mirrors the Rust EREQ CONTAINS_INTER meta + /// flag. Combined with to identify the + /// "standard fragment" (regexes built only from atoms, ε, ∅, + /// concat, union, and star), which is the alive-by-construction + /// fragment. + /// + public virtual bool ContainsInter => false; + + /// + /// True iff this term contains an + /// node anywhere in its syntax tree. Distinct from + /// , which tracks proposition occurrences + /// (free or bound). Mirrors the Rust EREQ CONTAINS_EXISTS + /// meta flag. + /// + public virtual bool ContainsExists => false; + + /// + /// Structural size of this term — a coarse complexity proxy. + /// Leaves cost 1; compound nodes cost Σ children.Cost + 1. + /// Mirrors the Rust EREQ Metadata.cost field (lib.rs:573–578) + /// and is intended to gate expensive simplification rules (subsumption, + /// brute-force equivalence) that the Rust runs only when + /// cost < threshold. + /// + public abstract int Cost { get; } + + /// + /// Conservative lower bound on the length of any word in this + /// language. Mirrors Rust EREQ get_min_max_len (lib.rs:999–1038). + /// + public virtual int MinLen => 0; + + /// + /// Conservative upper bound on the length of any word in this + /// language. represents +∞. + /// Mirrors Rust EREQ get_min_max_len. + /// + public virtual int MaxLen => int.MaxValue; + + /// Saturating addition; +∞ + k = +∞. + internal static int SatAdd(int a, int b) + { + if (a == int.MaxValue || b == int.MaxValue) return int.MaxValue; + long sum = (long)a + b; + return sum > int.MaxValue ? int.MaxValue : (int)sum; + } + + /// Saturating subtraction (floored at 0). + internal static int SatSub(int a, int b) + { + if (a == int.MaxValue) return int.MaxValue; + long d = (long)a - b; + return d < 0 ? 0 : (int)d; + } + + /// + /// Conservative "this regex denotes a non-empty language" check. + /// Returns true exactly when this term lies in the standard + /// fragment (no complement, no intersection) and is not the literal + /// empty language. For such terms aliveness is guaranteed by + /// construction. Returning false means "unknown" — the term + /// may still be alive, but settling the question requires the more + /// expensive emptiness check. Useful as a fast-path in weak closure + /// and similar contexts where the unknown branch is costly. + /// + public bool IsDefinitelyAlive + => !(this is EreEmpty) && !ContainsCompl && !ContainsInter; + + /// + /// Bitset of free propositions referenced anywhere inside this + /// term. Bit i corresponds to proposition with registry + /// index -(i+1). EREQ Phase-1 D2: capped at 64 distinct + /// propositions; out-of-range usage throws at the construction + /// site. The default value is 0UL (no free propositions) + /// — composite subclasses override to OR over their children; + /// EreExists (Phase 2) clears the bit for its bound + /// proposition. + /// + public virtual ulong FreeProps => 0UL; + + /// + /// Returns the bit corresponding to proposition index + /// (which must be strictly negative + /// and within ). + /// + public static ulong BitForProp(int propIdx) + { + if (propIdx >= 0) + throw new ArgumentOutOfRangeException(nameof(propIdx), + "Proposition indices must be strictly negative."); + int slot = -propIdx - 1; + if (slot >= 64) + throw new ArgumentOutOfRangeException(nameof(propIdx), + $"Proposition index {propIdx} exceeds the 64-proposition cap."); + return 1UL << slot; + } + + #region Factories + + public static Ere Empty() => DefaultBuilder.Bottom; + public static Ere Epsilon() => DefaultBuilder.Epsilon; + public static Ere Atom(TPred predicate) + => DefaultBuilder.Intern(new EreAtom(predicate)); + + /// + /// Single-letter atom constrained by a proposition variable + /// (EREQ Phase 2). must be a + /// strictly-negative index obtained from + /// . + /// L(p) = { letter w | proposition p holds at w }. + /// =false denotes ¬p. + /// + public static Ere PropositionAtom(int propIdx, bool polarity = true) + => DefaultBuilder.Intern(new EreProposition(propIdx, polarity)); + + /// + /// Existential projection over a proposition (EREQ Phase 2). + /// L(∃p. R) = { w | ∃ assignment of p along w that puts w in L(R) }. + /// + /// Eager rewrite cascade (paper §3.4 / Rust prototype + /// mk_exists; Phase-0 decision D3): + /// + /// ∃p. ∅ = ∅, ∃p. ε = ε, ∃p. Σ* = Σ* + /// p ∉ Free(R) ⇒ ∃p. R = R (pass-through, D2) + /// ∃p. (R · S) = (∃p. R) · (∃p. S) + /// ∃p. (R + S) = (∃p. R) + (∃p. S) — mandatory (D3) + /// ∃p. (R*) = (∃p. R)* + /// ∃p. (R : S) = (∃p. R) : (∃p. S) + /// ∃p. (A ∩ B) = A ∩ ∃p. B if p ∉ Free(A) + /// (leveraged O(1) rewrite using FreeProps; partitions + /// conjuncts). + /// ∃p. (R ⊕ S) = ∃p.((R ∧ ¬S) + (¬R ∧ S)) — XOR + /// expanded, then the above rules apply. + /// + /// Cases without a structural rule fall through to a residual + /// node (e.g. ∃p over + /// , , + /// or a nested ): the derivative + /// algorithm (ereq-p2-derivative) handles those at run + /// time. + /// + /// + public static Ere Exists(int propIdx, Ere body) + { + if (body == null) throw new ArgumentNullException(nameof(body)); + if (propIdx >= 0) + throw new ArgumentOutOfRangeException(nameof(propIdx), + "Proposition indices must be strictly negative."); + var bit = BitForProp(propIdx); // also validates the 64-cap + if ((body.FreeProps & bit) == 0UL) return body; // p not free → pass through + + // Structural distributions (paper §3.4). All recursive calls + // re-enter this factory so the FreeProps pass-through fires + // wherever propIdx fails to reach. + switch (body) + { + case EreEmpty: // ∃p. ∅ = ∅ + case EreEpsilon: // ∃p. ε = ε + return body; + + case EreConcat cc: // ∃p. (R·S) = ∃p.R · ∃p.S + return Concat(Exists(propIdx, cc.Left), Exists(propIdx, cc.Right)); + + case EreUnion uu: // ∃p. (R+S) = ∃p.R + ∃p.S — D3 + { + Ere acc = Empty(); + foreach (var op in uu.Operands) + acc = Union(acc, Exists(propIdx, op)); + return acc; + } + + case EreStar st: // ∃p. R* = (∃p.R)* + return Star(Exists(propIdx, st.Inner)); + + case EreFusion fu: // ∃p. (R:S) = ∃p.R : ∃p.S + return Fusion(Exists(propIdx, fu.Left), Exists(propIdx, fu.Right)); + + case EreIntersect ii: // ∃p. (A ∩ B) = A ∩ ∃p.B if p ∉ Free(A) + { + Ere extracted = Sigma(); // intersection identity + Ere kept = Sigma(); + foreach (var op in ii.Operands) + { + if ((op.FreeProps & bit) == 0UL) + extracted = Intersect(extracted, op); + else + kept = Intersect(kept, op); + } + if (!IsSigma(extracted)) + { + // recurse on the kept block: still contains p, + // but may admit further internal simplification + // through factory-canonicalised forms. + var inner = IsSigma(kept) + ? kept + : DefaultBuilder.Intern(new EreExists(propIdx, kept)); + return Intersect(extracted, inner); + } + // Nothing to extract — fall through to residual node. + break; + } + + case EreXor xx: // expand XOR then re-enter + { + Ere expanded = ExpandXor(xx); + return Exists(propIdx, expanded); + } + } + + // Σ* (= ~∅) pass-through: handled implicitly because FreeProps(Σ*)=0. + return DefaultBuilder.Intern(new EreExists(propIdx, body)); + } + + // ExpandXor: (R ⊕ S ⊕ T …) [⊙ if Negated] + // binary → (R ∧ ¬S) + (¬R ∧ S), and ⊙ adds outer complement + // n-ary → fold left as (((R₁ ⊕ R₂) ⊕ R₃) ⊕ …) and expand each step + private static Ere ExpandXor(EreXor x) + { + var ops = x.Operands; + Ere acc = ops[0]; + for (int i = 1; i < ops.Count; i++) + { + var r = acc; + var s = ops[i]; + acc = Union( + Intersect(r, Complement(s)), + Intersect(Complement(r), s)); + } + return x.Negated ? Complement(acc) : acc; + } + + /// Σ* — the universal language (= ~∅). + public static Ere Sigma() => Complement(Empty()); + + public static Ere Concat(Ere a, Ere b) + { + if (a is EreEmpty || b is EreEmpty) return Empty(); + if (a is EreEpsilon) return b; + if (b is EreEpsilon) return a; + // Distribute Concat over Union on the LEFT only (right-propagating): + // (R₁ + R₂) · S = R₁·S + R₂·S + // Exposes top-level DNF — feeds the Phase 7 driver splitting in + // EreEmptinessChecker and the ∃-over-+ rule in Exists. Hash-cons + // identity in the resulting Union dedups equivalent disjuncts. + // Right-side Union is *not* distributed to avoid the quadratic + // blow-up of (a+b)·(c+d) = ac+ad+bc+bd. + if (a is EreUnion ua) + { + Ere acc = Empty(); + foreach (var op in ua.Operands) acc = Union(acc, Concat(op, b)); + return acc; + } + // Right-associate: (a·b)·c → a·(b·c) for canonical form. + if (a is EreConcat ac) + return Concat(ac.Left, Concat(ac.Right, b)); + // R* · R* ≡ R* (with same R). Handle both b = Star(R) and + // b = Concat(Star(R), rest) — the latter arises after right-association. + if (a is EreStar sa) + { + if (b is EreStar sb && sa.Inner.Equals(sb.Inner)) return a; + if (b is EreConcat bc + && bc.Left is EreStar sb2 + && sa.Inner.Equals(sb2.Inner)) + return Concat(a, bc.Right); + } + return DefaultBuilder.Intern(new EreConcat(a, b)); + } + + public static Ere Union(Ere a, Ere b) + { + if (a is EreEmpty) return b; + if (b is EreEmpty) return a; + if (IsSigma(a) || IsSigma(b)) return Sigma(); + + var ops = new SortedSet>(EreComparer.Instance); + CollectUnion(a, ops); + CollectUnion(b, ops); + + // Complementary-language elimination: R + ~R ≡ Σ*. + foreach (var op in ops) + { + if (op is EreComplement c && ops.Contains(c.Inner)) return Sigma(); + } + + // R + R* ≡ R*: for every Star(R) operand, drop other operands that are + // already in L(R*) — namely R itself, ε (since R* is nullable), and R·R*. + var starInners = ops.OfType>().Select(s => s.Inner).ToList(); + if (starInners.Count > 0) + { + ops.RemoveWhere(x => + { + if (x is EreStar) return false; + if (x is EreEpsilon) return true; // ε ⊆ R* for any R + foreach (var inner in starInners) + { + if (x.Equals(inner)) return true; // R ⊆ R* + if (x is EreConcat cc // R·R* ⊆ R* + && cc.Left.Equals(inner) + && cc.Right is EreStar rs + && rs.Inner.Equals(inner)) return true; + } + return false; + }); + } + + // R⁺ = R · R* collapse rules under Union (Rust SU at lib.rs:3549–3559): + // ε | R⁺ → R* + // R* | R⁺ → R* (same R) + // Detected by walking ops for EreConcat(L, EreStar(L')) with + // ReferenceEquals(L, L') (interned canonical form), then checking + // for ε or Star(L) sibling. The replacement (Star(body)) re-enters + // hash-consing via Star() so we keep canonical reference identity. + var plusEntries = new List<(Ere entry, Ere body)>(); + foreach (var op in ops) + { + if (op is EreConcat pc + && pc.Right is EreStar prs + && ReferenceEquals(pc.Left, prs.Inner)) + { + plusEntries.Add((op, pc.Left)); + } + } + if (plusEntries.Count > 0) + { + bool hasEps = false; + foreach (var op in ops) { if (op is EreEpsilon) { hasEps = true; break; } } + foreach (var (entry, body) in plusEntries) + { + var starOfBody = Star(body); // interned canonical + bool hasStar = ops.Contains(starOfBody); + if (hasEps || hasStar) + { + ops.Remove(entry); + if (!hasStar) ops.Add(starOfBody); + if (hasEps && !ReferenceEquals(starOfBody, Epsilon())) + { + // ε is now subsumed by Star(body) — drop it. + ops.RemoveWhere(o => o is EreEpsilon); + hasEps = false; // only drop once + } + } + } + } + + + // Σ*-prefix absorption: Σ* · T absorbs any other operand whose + // syntactic right-suffix chain reaches Σ* · T. Soundness: any + // word in L(R · Σ* · T) decomposes as r · w · t with + // r ∈ L(R), w ∈ Σ*, t ∈ L(T); reassociate as (r · w) · t to + // see it lies in Σ* · L(T) = L(Σ* · T). Hash-consing makes + // the right-chain walk an O(depth) reference-equality check. + // This is the rule that collapses regex-concat fairness + // derivatives such as Σ*·p₁·Σ*·…·Σ*·pₙ | Σ*·pₖ·…·Σ*·pₙ + // (the union of "progress levels" generated by symbolic + // differentiation) down to the single deepest progress level. + var sigmaStarPrefixed = ops + .Where(o => o is EreConcat c && IsSigmaStar(c.Left)) + .ToList(); + if (sigmaStarPrefixed.Count > 0) + { + ops.RemoveWhere(small => + { + foreach (var big in sigmaStarPrefixed) + { + if (ReferenceEquals(small, big)) continue; + if (HasRightSuffix(small, big)) return true; + } + return false; + }); + } + + // P2.3 Contains-pattern merge (Rust SU-5 at lib.rs:2081–2086): + // Σ*·R·Σ* + Σ*·S·Σ* ≡ Σ*·(R+S)·Σ* + // Right-associated parse: Concat(Σ*, Concat(body, Σ*)). Bodies + // are merged under recursive Union (which may further simplify), + // then re-wrapped in a single contains-pattern. Runs before + // head-factoring so the tightest containing form wins. + List<(Ere entry, Ere body)> containsBodies = null; + foreach (var op in ops) + { + if (op is EreConcat outer + && IsSigmaStar(outer.Left) + && outer.Right is EreConcat inner + && IsSigmaStar(inner.Right)) + { + if (containsBodies == null) + containsBodies = new List<(Ere, Ere)>(); + containsBodies.Add((op, inner.Left)); + } + } + if (containsBodies != null && containsBodies.Count >= 2) + { + Ere mergedBody = Empty(); + foreach (var (_, body) in containsBodies) + mergedBody = Union(mergedBody, body); + foreach (var (entry, _) in containsBodies) ops.Remove(entry); + var sStar = Star(Sigma()); + ops.Add(Concat(sStar, Concat(mergedBody, sStar))); + } + + // P3.3 predicate-star union (Rust SU-4, lib.rs:2057–2061): + // [p]* | [q]* ≡ (p ⊔ q)* + // Requires the predicate algebra plumbed via P1; silently + // skipped when no algebra has been registered. + if (DefaultBuilder.Algebra is IPredicateAlgebra palg) + { + var predStars = ops + .OfType>() + .Where(s => s.Inner is EreAtom) + .ToList(); + if (predStars.Count >= 2) + { + TPred merged = ((EreAtom)predStars[0].Inner).Predicate; + for (int i = 1; i < predStars.Count; i++) + merged = palg.Or(merged, ((EreAtom)predStars[i].Inner).Predicate); + foreach (var s in predStars) ops.Remove(s); + ops.Add(Star(Atom(merged))); + } + } + + // P2.5b Σ*-tail structural subsumption (Rust lib.rs:2104-2114): + // Σ*·t1 + Σ*·t2 ≡ Σ*·t1 when t2 = …·t1 structurally. + // If t2 has t1 as a structural concat-suffix then any word in + // Σ*·t2 also ends in t1, so Σ*·t2 ⊆ Σ*·t1; drop the longer-tailed + // operand. Runs before head/tail factoring so that the simpler + // surviving shape participates in factoring. + { + var sigmaStarOps = ops + .OfType>() + .Where(c => IsSigmaStar(c.Left)) + .ToList(); + var toDrop = new List>(); + for (int i = 0; i < sigmaStarOps.Count; i++) + { + var ci = sigmaStarOps[i]; + if (toDrop.Contains(ci)) continue; + for (int j = 0; j < sigmaStarOps.Count; j++) + { + if (i == j) continue; + var cj = sigmaStarOps[j]; + if (toDrop.Contains(cj)) continue; + // ci.Right is suffix of cj.Right ⇒ drop cj. + if (!ReferenceEquals(ci.Right, cj.Right) + && HasConcatTail(cj.Right, ci.Right)) + toDrop.Add(cj); + } + } + foreach (var d in toDrop) ops.Remove(d); + } + + // P2.1 Head/tail factoring (Rust SU-7 at lib.rs:2118–2121): + // H·T₁ + H·T₂ + … + H·Tₙ ≡ H·(T₁+T₂+…+Tₙ) + // when n ≥ 2 EreConcat operands share the same Left child by + // hash-cons reference. Particularly valuable on derivative + // classes generated by the same head predicate. Concat's own + // left-distribution would re-distribute if H were itself a + // Union, but Concat factory eagerly distributes left-Union + // away, so H is never an EreUnion here. + Dictionary, List>> byHead = null; + foreach (var op in ops) + { + if (op is EreConcat cc) + { + if (byHead == null) + byHead = new Dictionary, List>>(); + if (!byHead.TryGetValue(cc.Left, out var list)) + byHead[cc.Left] = list = new List>(); + list.Add(cc); + } + } + if (byHead != null) + { + foreach (var kv in byHead) + { + if (kv.Value.Count < 2) continue; + var head = kv.Key; + Ere mergedTail = Empty(); + foreach (var c in kv.Value) + mergedTail = Union(mergedTail, c.Right); + foreach (var c in kv.Value) ops.Remove(c); + ops.Add(Concat(head, mergedTail)); + } + } + + // P2.5a tail-factoring (R₁·T + R₂·T → (R₁+R₂)·T), the dual of + // P2.1 head-factoring, is intentionally NOT applied: the Concat + // factory eagerly distributes left-Union (Concat(Union, T) ⇒ + // Union(Concat·,Concat·)), which would immediately undo any + // tail-factoring and produce infinite Union↔Concat recursion. + // The Σ*-tail subsumption above (P2.5b) captures the most + // impactful subset of the same idea without that conflict. + + if (ops.Count == 1) return ops.First(); + return DefaultBuilder.Intern(new EreUnion(ops.ToArray())); + } + + /// + /// True iff is a structural right-spine + /// suffix of in right-associated concat + /// form. Used by the Σ*-tail subsumption rewrite (P2.5b) to detect + /// when one ‘ends-with’ pattern subsumes another. O(spine length) + /// thanks to hash-consing reference identity on subterms. + /// + private static bool HasConcatTail(Ere whole, Ere suffix) + { + var cur = whole; + while (true) + { + if (ReferenceEquals(cur, suffix)) return true; + if (cur is EreConcat c) { cur = c.Right; continue; } + return false; + } + } + + public static Ere Intersect(Ere a, Ere b) + { + if (a is EreEmpty || b is EreEmpty) return Empty(); + if (IsSigma(a)) return b; + if (IsSigma(b)) return a; + + // R ∩ ε = ε if R nullable, ∅ otherwise. + if (a is EreEpsilon) return b.Nullable ? Epsilon() : Empty(); + if (b is EreEpsilon) return a.Nullable ? Epsilon() : Empty(); + + var ops = new SortedSet>(EreComparer.Instance); + CollectIntersect(a, ops); + CollectIntersect(b, ops); + + // Complementary-language elimination: R ∩ ~R ≡ ∅. + foreach (var op in ops) + { + if (op is EreComplement c && ops.Contains(c.Inner)) return Empty(); + } + + // R ∩ R* ≡ R: when both appear, the star is redundant (R ⊆ R*). + // Dual of the union star absorption. + var starsToRemove = new List>(); + foreach (var s in ops.OfType>()) + { + if (ops.Contains(s.Inner)) starsToRemove.Add(s); + } + foreach (var s in starsToRemove) ops.Remove(s); + + // P2.2: length-bound disjointness check. If the operands' length + // intervals do not overlap then no word can lie in the intersection. + // Mirrors Rust EREQ get_min_max_len + Intersect-unsat pruning + // (lib.rs:999–1038). + { + int lo = 0, hi = int.MaxValue; + foreach (var op in ops) + { + if (op.MinLen > lo) lo = op.MinLen; + if (op.MaxLen < hi) hi = op.MaxLen; + } + if (lo > hi) return Empty(); + } + + // P3.2 predicate-star × concat distribution (Rust I-P4, lib.rs:2604–2612): + // [p]* ∩ R·S ≡ ([p]* ∩ R) · ([p]* ∩ S) + // Sound because a word w = u·v lies in L([p]*) iff every + // character of u and v satisfies p iff u, v ∈ L([p]*). The + // distributed form lets later derivative passes prune each + // factor independently. The predicate-star operand is left + // in place — it is redundant w.r.t. the new concat but may + // still be needed for any further intersection operands. + { + EreStar predStar = null; + foreach (var op in ops) + { + if (op is EreStar s && s.Inner is EreAtom) + { + predStar = s; + break; + } + } + if (predStar != null) + { + var toDistribute = ops.OfType>().ToList(); + if (toDistribute.Count > 0) + { + foreach (var cc in toDistribute) + { + ops.Remove(cc); + var left = Intersect(predStar, cc.Left); + var right = Intersect(predStar, cc.Right); + var distributed = Concat(left, right); + if (distributed is EreEmpty) return Empty(); + CollectIntersect(distributed, ops); + } + // Each distributed factor already conjoins with + // predStar, so the outer copy is redundant. + ops.Remove(predStar); + } + } + } + + if (ops.Count == 1) return ops.First(); + return DefaultBuilder.Intern(new EreIntersect(ops.ToArray())); + } + + public static Ere Complement(Ere a) + { + if (a is EreComplement c) return c.Inner; // ~~R = R + // ~(R ⊕ S) absorbed by flipping the XOR node's Negated flag. + if (a is EreXor x) + return DefaultBuilder.Intern( + new EreXor(x.Operands.ToArray(), !x.Negated)); + if (a is EreEmpty) + return DefaultBuilder.Intern(new EreComplement(a)); // ~∅ = Σ* + // De Morgan: push complement inward over union/intersect so that + // complements appear only on Empty / Epsilon / Atom / Star / Concat + // / Fusion (the "atomic" forms wrt boolean connectives). This is the + // ERE analogue of LTL NNF: it canonicalises complement nodes and + // exposes the underlying operands to further rewrites in the + // resulting Union/Intersect. + if (a is EreUnion u) + { + // ~(R + S + …) = ~R ∩ ~S ∩ … + Ere acc = Sigma(); + foreach (var op in u.Operands) acc = Intersect(acc, Complement(op)); + return acc; + } + if (a is EreIntersect i) + { + // ~(R ∩ S ∩ …) = ~R + ~S + … + Ere acc = Empty(); + foreach (var op in i.Operands) acc = Union(acc, Complement(op)); + return acc; + } + // P2.4: complement push-through on canonical predicate shapes. + // Mirrors Rust EREQ rules COMPL-12 and COMPL-13 and requires a + // predicate algebra to negate atom predicates; silently skipped + // when no algebra has been registered with the default builder. + if (DefaultBuilder.Algebra is IPredicateAlgebra alg + && a is EreConcat cn) + { + // ~([p] · Σ*) = ε | [¬p] · Σ* + if (cn.Left is EreAtom at1 && IsSigmaStar(cn.Right)) + { + var negAt = Atom(alg.Not(at1.Predicate)); + return Union(Epsilon(), Concat(negAt, cn.Right)); + } + // ~(Σ* · [p] · Σ*) = [¬p]* + if (IsSigmaStar(cn.Left) + && cn.Right is EreConcat inner + && inner.Left is EreAtom at2 + && IsSigmaStar(inner.Right)) + { + var negAt = Atom(alg.Not(at2.Predicate)); + return Star(negAt); + } + } + return DefaultBuilder.Intern(new EreComplement(a)); + } + + public static Ere Star(Ere a) + { + if (a is EreEmpty) return Epsilon(); // ∅* = ε + if (a is EreEpsilon) return Epsilon(); // ε* = ε + if (a is EreStar) return a; // (R*)* = R* + // (R + ε)* ≡ R*: ε contributes nothing under star. + if (a is EreUnion u && u.Operands.Any(o => o is EreEpsilon)) + { + Ere rest = Empty(); + foreach (var op in u.Operands) + if (!(op is EreEpsilon)) rest = Union(rest, op); + return Star(rest); + } + return DefaultBuilder.Intern(new EreStar(a)); + } + + /// + /// Fusion R : S (Section 7.3 of the JACM extension). + /// + /// L(R : S) = { v ∈ Σ∞ | ∃ i < |v| : v[..i] ∈ L(R) ∧ v[i..] ∈ L(S) } + /// + /// where v[..i] includes position i (length i+1, hence + /// nonempty) and v[i..] starts at position i: the last letter of + /// the regex match coincides with the first letter of the suffix match. + /// + public static Ere Fusion(Ere a, Ere b) + { + // Fusion requires both sides to contribute at least one letter at the + // shared position; ∅ or ε on either side yields ∅. + if (a is EreEmpty || b is EreEmpty) return Empty(); + if (a is EreEpsilon || b is EreEpsilon) return Empty(); + // Distribute Fusion over Union on the LEFT only (right-propagating): + // (R₁ + R₂) : S = (R₁:S) + (R₂:S) + // Same DNF-exposure rationale as the Concat-over-Union rule above; + // right-side Union is not distributed to avoid quadratic blow-up. + if (a is EreUnion ua) + { + Ere acc = Empty(); + foreach (var op in ua.Operands) acc = Union(acc, Fusion(op, b)); + return acc; + } + return DefaultBuilder.Intern(new EreFusion(a, b)); + } + + /// R⁺ = R · R* + public static Ere Plus(Ere a) => Concat(a, Star(a)); + + /// R? = R + ε + public static Ere Optional(Ere a) => Union(a, Epsilon()); + + /// + /// Symmetric difference (XOR) R ⊕ S and its negation + /// (XNOR) R ⊙ S, with + /// L(R ⊕ S) = L(R) △ L(S). + /// + /// Canonicalisation (paper §6 "Implementation"): + /// + /// Associative, commutative, self-inverse: nested XORs are + /// flattened, operands sorted, identical pairs cancel. + /// R ⊕ R ≡ ⊥ + /// R ⊕ ⊥ ≡ R + /// ~R ⊕ ~S ≡ R ⊕ S + /// R ⊕ ~S ≡ ~(R ⊕ S) = R ⊙ S + /// (complement is absorbed into the XOR node as + /// = true; + /// no wrapper is needed). + /// Σ* ⊕ R ≡ ~R (falls out of the lift) + /// + /// + /// Nullable iff Nullable(R) ≠ Nullable(S) + /// (XNOR inverts). + /// + /// Used as the primitive operator of the bisimulation-based + /// equivalence algorithm: Eq(p,q) ⇔ L(p ⊕ q) = ∅ + /// (see EreEquivalenceChecker). + /// + public static Ere Xor(Ere a, Ere b) + { + // Collect operands modulo complement parity. + var ops = new List>(); + bool negated = false; + CollectXor(a, ops, ref negated); + CollectXor(b, ops, ref negated); + + // Sort by structural order and pair-cancel duplicates. + ops.Sort(EreComparer.Instance); + var canon = new List>(ops.Count); + for (int i = 0; i < ops.Count;) + { + if (i + 1 < ops.Count && ops[i].Equals(ops[i + 1])) + { + // r ⊕ r ≡ ⊥: drop the pair (no parity change). + i += 2; + } + else + { + canon.Add(ops[i]); + i++; + } + } + + if (canon.Count == 0) + { + // 0 operands → identity ⊥; XNOR form gives ~⊥ = Σ*. + return negated ? Sigma() : Empty(); + } + if (canon.Count == 1) + { + // Single operand → r or ~r (resolved via Complement factory). + return negated ? Complement(canon[0]) : canon[0]; + } + return DefaultBuilder.Intern(new EreXor(canon.ToArray(), negated)); + } + + /// XNOR: R ⊙ S ≡ ~(R ⊕ S). + public static Ere Xnor(Ere a, Ere b) => Complement(Xor(a, b)); + + private static void CollectXor(Ere e, List> ops, ref bool negated) + { + // Strip ⊥ (identity for ⊕). + if (e is EreEmpty) return; + + // Lift complement out: ~r contributes r and flips the outer + // parity. Handles Σ* (= ~∅) for free (it contributes nothing + // but toggles parity). + if (e is EreComplement c) + { + negated = !negated; + CollectXor(c.Inner, ops, ref negated); + return; + } + + // Flatten nested XOR; the inner node's own Negated flag merges + // into the running parity. + if (e is EreXor x) + { + if (x.Negated) negated = !negated; + foreach (var op in x.Operands) CollectXor(op, ops, ref negated); + return; + } + + ops.Add(e); + } + + private static bool IsSigma(Ere e) + => e is EreComplement c && c.Inner is EreEmpty; + + private static bool IsSigmaStar(Ere e) + => e is EreStar s && IsSigma(s.Inner); + + // Walk the right-spine of a (right-associated) concat chain looking + // for a syntactic match of . Relies on + // hash-cons reference equality of canonical terms. + private static bool HasRightSuffix(Ere e, Ere tail) + { + while (true) + { + if (ReferenceEquals(e, tail)) return true; + if (e is EreConcat c) { e = c.Right; continue; } + return false; + } + } + + private static void CollectUnion(Ere e, SortedSet> ops) + { + if (e is EreUnion u) + foreach (var op in u.Operands) ops.Add(op); + else + ops.Add(e); + } + + private static void CollectIntersect(Ere e, SortedSet> ops) + { + if (e is EreIntersect i) + foreach (var op in i.Operands) ops.Add(op); + else + ops.Add(e); + } + + #endregion + + #region Equality / Comparison + + public abstract bool Equals(Ere other); + public override bool Equals(object obj) => Equals(obj as Ere); + + public override int GetHashCode() + { + if (_hash == null) _hash = ComputeHashCode(); + return _hash.Value; + } + + protected abstract int ComputeHashCode(); + + public int CompareTo(Ere other) + { + if (other == null) return 1; + if (ReferenceEquals(this, other)) return 0; + int c = Kind.CompareTo(other.Kind); + if (c != 0) return c; + return CompareToSameKind(other); + } + + protected abstract int CompareToSameKind(Ere other); + + public static bool operator ==(Ere a, Ere b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null) return false; + return a.Equals(b); + } + public static bool operator !=(Ere a, Ere b) => !(a == b); + + #endregion + } + + internal sealed class EreComparer : IComparer> + { + public static readonly EreComparer Instance = new EreComparer(); + public int Compare(Ere x, Ere y) => x.CompareTo(y); + } + + public sealed class EreEmpty : Ere + { + public static readonly EreEmpty Instance = new EreEmpty(); + private EreEmpty() { } + internal override int Kind => 0; + public override bool Nullable => false; + public override int Cost => 1; + public override bool Equals(Ere other) => other is EreEmpty; + protected override int ComputeHashCode() => 0x11111111; + protected override int CompareToSameKind(Ere other) => 0; + public override string ToString() => "∅"; + } + + public sealed class EreEpsilon : Ere + { + public static readonly EreEpsilon Instance = new EreEpsilon(); + private EreEpsilon() { } + internal override int Kind => 1; + public override bool Nullable => true; + public override int Cost => 1; + public override int MinLen => 0; + public override int MaxLen => 0; + public override bool Equals(Ere other) => other is EreEpsilon; + protected override int ComputeHashCode() => 0x22222222; + protected override int CompareToSameKind(Ere other) => 0; + public override string ToString() => "ε"; + } + + public sealed class EreAtom : Ere + { + public EreAtom(TPred predicate) { Predicate = predicate; } + public TPred Predicate { get; } + internal override int Kind => 2; + public override bool Nullable => false; + public override int Cost => 1; + public override int MinLen => 1; + public override int MaxLen => 1; + public override bool Equals(Ere other) + => other is EreAtom a + && EqualityComparer.Default.Equals(Predicate, a.Predicate); + protected override int ComputeHashCode() + => unchecked(EqualityComparer.Default.GetHashCode(Predicate) * (int)0x9E3779B1); + protected override int CompareToSameKind(Ere other) + { + var a = (EreAtom)other; + return PredCompare.Compare(Predicate, a.Predicate); + } + public override string ToString() => Predicate.ToString(); + } + + public sealed class EreConcat : Ere + { + public EreConcat(Ere left, Ere right) + { + Left = left ?? throw new ArgumentNullException(nameof(left)); + Right = right ?? throw new ArgumentNullException(nameof(right)); + _containsCompl = left.ContainsCompl || right.ContainsCompl; + _containsInter = left.ContainsInter || right.ContainsInter; + _containsExists = left.ContainsExists || right.ContainsExists; + _cost = left.Cost + right.Cost + 1; + } + private readonly bool _containsCompl; + private readonly bool _containsInter; + private readonly bool _containsExists; + private readonly int _cost; + public override bool ContainsCompl => _containsCompl; + public override bool ContainsInter => _containsInter; + public override bool ContainsExists => _containsExists; + public override int Cost => _cost; + public Ere Left { get; } + public Ere Right { get; } + internal override int Kind => 3; + public override bool Nullable => Left.Nullable && Right.Nullable; + public override int MinLen => SatAdd(Left.MinLen, Right.MinLen); + public override int MaxLen => SatAdd(Left.MaxLen, Right.MaxLen); + public override ulong FreeProps => Left.FreeProps | Right.FreeProps; + public override bool Equals(Ere other) + => other is EreConcat c && Left.Equals(c.Left) && Right.Equals(c.Right); + protected override int ComputeHashCode() + => unchecked(Left.GetHashCode() * 31 + Right.GetHashCode() + 3); + protected override int CompareToSameKind(Ere other) + { + var c = (EreConcat)other; + int r = Left.CompareTo(c.Left); + return r != 0 ? r : Right.CompareTo(c.Right); + } + public override string ToString() => $"({Left}·{Right})"; + } + + public sealed class EreUnion : Ere + { + internal EreUnion(Ere[] operands) + { + Operands = operands; + bool cc = false, ci = false, ce = false; + int cost = 1; + foreach (var op in operands) + { + cc |= op.ContainsCompl; + ci |= op.ContainsInter; + ce |= op.ContainsExists; + cost += op.Cost; + } + _containsCompl = cc; + _containsInter = ci; + _containsExists = ce; + _cost = cost; + } + private readonly bool _containsCompl; + private readonly bool _containsInter; + private readonly bool _containsExists; + private readonly int _cost; + public override bool ContainsCompl => _containsCompl; + public override bool ContainsInter => _containsInter; + public override bool ContainsExists => _containsExists; + public override int Cost => _cost; + public IReadOnlyList> Operands { get; } + internal override int Kind => 4; + public override bool Nullable => Operands.Any(o => o.Nullable); + public override int MinLen + { + get + { + int m = int.MaxValue; + foreach (var o in Operands) if (o.MinLen < m) m = o.MinLen; + return m; + } + } + public override int MaxLen + { + get + { + int m = 0; + foreach (var o in Operands) if (o.MaxLen > m) m = o.MaxLen; + return m; + } + } + public override ulong FreeProps + { + get { ulong b = 0UL; foreach (var o in Operands) b |= o.FreeProps; return b; } + } + public override bool Equals(Ere other) + { + if (!(other is EreUnion u)) return false; + if (Operands.Count != u.Operands.Count) return false; + for (int i = 0; i < Operands.Count; i++) + if (!Operands[i].Equals(u.Operands[i])) return false; + return true; + } + protected override int ComputeHashCode() + { + unchecked + { + int h = 4; + foreach (var op in Operands) h = h * 31 + op.GetHashCode(); + return h; + } + } + protected override int CompareToSameKind(Ere other) + { + var u = (EreUnion)other; + int c = Operands.Count.CompareTo(u.Operands.Count); + if (c != 0) return c; + for (int i = 0; i < Operands.Count; i++) + { + c = Operands[i].CompareTo(u.Operands[i]); + if (c != 0) return c; + } + return 0; + } + public override string ToString() => "(" + string.Join("+", Operands) + ")"; + } + + public sealed class EreIntersect : Ere + { + internal EreIntersect(Ere[] operands) + { + Operands = operands; + bool cc = false, ce = false; + int cost = 1; + foreach (var op in operands) + { + cc |= op.ContainsCompl; + ce |= op.ContainsExists; + cost += op.Cost; + } + _containsCompl = cc; + _containsExists = ce; + _cost = cost; + } + private readonly bool _containsCompl; + private readonly bool _containsExists; + private readonly int _cost; + public override bool ContainsCompl => _containsCompl; + public override bool ContainsInter => true; + public override bool ContainsExists => _containsExists; + public override int Cost => _cost; + public IReadOnlyList> Operands { get; } + internal override int Kind => 5; + public override bool Nullable => Operands.All(o => o.Nullable); + public override int MinLen + { + get + { + int m = 0; + foreach (var o in Operands) if (o.MinLen > m) m = o.MinLen; + return m; + } + } + public override int MaxLen + { + get + { + int m = int.MaxValue; + foreach (var o in Operands) if (o.MaxLen < m) m = o.MaxLen; + return m; + } + } + public override ulong FreeProps + { + get { ulong b = 0UL; foreach (var o in Operands) b |= o.FreeProps; return b; } + } + public override bool Equals(Ere other) + { + if (!(other is EreIntersect i)) return false; + if (Operands.Count != i.Operands.Count) return false; + for (int k = 0; k < Operands.Count; k++) + if (!Operands[k].Equals(i.Operands[k])) return false; + return true; + } + protected override int ComputeHashCode() + { + unchecked + { + int h = 5; + foreach (var op in Operands) h = h * 31 + op.GetHashCode(); + return h; + } + } + protected override int CompareToSameKind(Ere other) + { + var i = (EreIntersect)other; + int c = Operands.Count.CompareTo(i.Operands.Count); + if (c != 0) return c; + for (int k = 0; k < Operands.Count; k++) + { + c = Operands[k].CompareTo(i.Operands[k]); + if (c != 0) return c; + } + return 0; + } + public override string ToString() => "(" + string.Join("&", Operands) + ")"; + } + + public sealed class EreComplement : Ere + { + public EreComplement(Ere inner) + { + Inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + public Ere Inner { get; } + internal override int Kind => 6; + public override bool Nullable => !Inner.Nullable; + public override ulong FreeProps => Inner.FreeProps; + public override bool ContainsCompl => true; + public override bool ContainsInter => Inner.ContainsInter; + public override bool ContainsExists => Inner.ContainsExists; + public override int Cost => Inner.Cost + 1; + // ~R: word lengths complement L(R). 0 ∈ L(~R) iff !Inner.Nullable. + // Otherwise we have no tight upper bound; conservative bounds. + public override int MinLen => Inner.Nullable ? 1 : 0; + public override int MaxLen => int.MaxValue; + public override bool Equals(Ere other) + => other is EreComplement c && Inner.Equals(c.Inner); + protected override int ComputeHashCode() => unchecked(Inner.GetHashCode() * 7 + 6); + protected override int CompareToSameKind(Ere other) + => Inner.CompareTo(((EreComplement)other).Inner); + public override string ToString() + => Inner is EreEmpty ? "Σ*" : $"~{Inner}"; + } + + public sealed class EreStar : Ere + { + public EreStar(Ere inner) + { + Inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + public Ere Inner { get; } + internal override int Kind => 7; + public override bool Nullable => true; + public override ulong FreeProps => Inner.FreeProps; + public override bool ContainsCompl => Inner.ContainsCompl; + public override bool ContainsInter => Inner.ContainsInter; + public override bool ContainsExists => Inner.ContainsExists; + public override int Cost => Inner.Cost + 1; + public override int MinLen => 0; + public override int MaxLen => int.MaxValue; + public override bool Equals(Ere other) + => other is EreStar s && Inner.Equals(s.Inner); + protected override int ComputeHashCode() => unchecked(Inner.GetHashCode() * 11 + 7); + protected override int CompareToSameKind(Ere other) + => Inner.CompareTo(((EreStar)other).Inner); + public override string ToString() => $"({Inner})*"; + } + + /// + /// Fusion R : S — the prefix and the suffix share their boundary letter. + /// L(R : S) = { v | ∃ i < |v| : v[..i] ∈ L(R) ∧ v[i..] ∈ L(S) } + /// (Section 7.3 of the JACM extension; nullable(R:S) = false). + /// + public sealed class EreFusion : Ere + { + public EreFusion(Ere left, Ere right) + { + Left = left ?? throw new ArgumentNullException(nameof(left)); + Right = right ?? throw new ArgumentNullException(nameof(right)); + _containsCompl = left.ContainsCompl || right.ContainsCompl; + _containsInter = left.ContainsInter || right.ContainsInter; + _containsExists = left.ContainsExists || right.ContainsExists; + _cost = left.Cost + right.Cost + 1; + } + private readonly bool _containsCompl; + private readonly bool _containsInter; + private readonly bool _containsExists; + private readonly int _cost; + public override bool ContainsCompl => _containsCompl; + public override bool ContainsInter => _containsInter; + public override bool ContainsExists => _containsExists; + public override int Cost => _cost; + public Ere Left { get; } + public Ere Right { get; } + internal override int Kind => 8; + public override bool Nullable => false; + // Fusion: prefix and suffix share their boundary letter, so the + // length is len(L)+len(R)-1 with both ≥1. + public override int MinLen => SatSub(SatAdd(Left.MinLen, Right.MinLen), 1); + public override int MaxLen => SatSub(SatAdd(Left.MaxLen, Right.MaxLen), 1); + public override ulong FreeProps => Left.FreeProps | Right.FreeProps; + public override bool Equals(Ere other) + => other is EreFusion f && Left.Equals(f.Left) && Right.Equals(f.Right); + protected override int ComputeHashCode() + => unchecked(Left.GetHashCode() * 37 + Right.GetHashCode() + 8); + protected override int CompareToSameKind(Ere other) + { + var f = (EreFusion)other; + int c = Left.CompareTo(f.Left); + return c != 0 ? c : Right.CompareTo(f.Right); + } + public override string ToString() => $"({Left}:{Right})"; + } + + /// + /// Symmetric difference (XOR / XNOR) — primitive ERE operator (CAV'26). + /// If is false, denotes + /// R₁ ⊕ R₂ ⊕ … ⊕ Rₙ (XOR); + /// if true, denotes ~(R₁ ⊕ … ⊕ Rₙ) (XNOR for n = 2). + /// L(R₁ ⊕ … ⊕ Rₙ) = { v | parity of {i : v ∈ L(Rᵢ)} is odd }. + /// Operands are stored sorted by structural order and are + /// guaranteed (via the factory) to + /// satisfy: no duplicates, no inner complement, no nested XOR, + /// count ≥ 2. The flag absorbs any outer + /// complement, so an EreXor is never wrapped in + /// . + /// + public sealed class EreXor : Ere + { + internal EreXor(Ere[] operands, bool negated) + { + Operands = operands; + Negated = negated; + bool cc = false, ci = false, ce = false; + int cost = 1; + foreach (var op in operands) + { + cc |= op.ContainsCompl; + ci |= op.ContainsInter; + ce |= op.ContainsExists; + cost += op.Cost; + } + _containsCompl = cc; + _containsInter = ci; + _containsExists = ce; + _cost = cost; + } + private readonly bool _containsCompl; + private readonly bool _containsInter; + private readonly bool _containsExists; + private readonly int _cost; + public override bool ContainsCompl => _containsCompl; + public override bool ContainsInter => _containsInter; + public override bool ContainsExists => _containsExists; + public override int Cost => _cost; + public IReadOnlyList> Operands { get; } + + /// True ⇔ this node denotes the XNOR (negated parity). + public bool Negated { get; } + + internal override int Kind => 9; + public override ulong FreeProps + { + get { ulong b = 0UL; foreach (var o in Operands) b |= o.FreeProps; return b; } + } + public override bool Nullable + { + get + { + bool n = Negated; + foreach (var op in Operands) n ^= op.Nullable; + return n; + } + } + public override bool Equals(Ere other) + { + if (!(other is EreXor x)) return false; + if (Negated != x.Negated) return false; + if (Operands.Count != x.Operands.Count) return false; + for (int i = 0; i < Operands.Count; i++) + if (!Operands[i].Equals(x.Operands[i])) return false; + return true; + } + protected override int ComputeHashCode() + { + unchecked + { + int h = Negated ? 0x5A5A5A5A : 9; + foreach (var op in Operands) h = h * 31 + op.GetHashCode(); + return h; + } + } + protected override int CompareToSameKind(Ere other) + { + var x = (EreXor)other; + // XOR < XNOR (Negated=false < Negated=true). + int c = Negated.CompareTo(x.Negated); + if (c != 0) return c; + c = Operands.Count.CompareTo(x.Operands.Count); + if (c != 0) return c; + for (int i = 0; i < Operands.Count; i++) + { + c = Operands[i].CompareTo(x.Operands[i]); + if (c != 0) return c; + } + return 0; + } + public override string ToString() + { + string sep = Negated ? "⊙" : "⊕"; + return "(" + string.Join(sep, Operands) + ")"; + } + } + + /// + /// Single-letter atom constrained by a proposition variable + /// (EREQ Phase 2). Indexed by a strictly-negative + /// from the + /// . When + /// is false the atom denotes ¬p. + /// + public sealed class EreProposition : Ere + { + public EreProposition(int propositionIndex, bool polarity) + { + if (propositionIndex >= 0) + throw new ArgumentOutOfRangeException(nameof(propositionIndex), + "Proposition indices must be strictly negative."); + PropositionIndex = propositionIndex; + Polarity = polarity; + } + public int PropositionIndex { get; } + public bool Polarity { get; } + internal override int Kind => 10; + public override bool Nullable => false; + public override int Cost => 1; + public override int MinLen => 1; + public override int MaxLen => 1; + public override ulong FreeProps => BitForProp(PropositionIndex); + public override bool Equals(Ere other) + => other is EreProposition p + && p.PropositionIndex == PropositionIndex + && p.Polarity == Polarity; + protected override int ComputeHashCode() + => unchecked((int)((long)PropositionIndex * 0x27d4eb2dL) ^ (Polarity ? 1 : 0)); + protected override int CompareToSameKind(Ere other) + { + var p = (EreProposition)other; + int c = PropositionIndex.CompareTo(p.PropositionIndex); + return c != 0 ? c : Polarity.CompareTo(p.Polarity); + } + public override string ToString() + => (Polarity ? "p" : "¬p") + (-PropositionIndex - 1); + } + + /// + /// Existential projection over a proposition (EREQ Phase 2): + /// ∃p. R. is the bound + /// proposition's negative registry index; + /// is the regex under projection. + /// + public sealed class EreExists : Ere + { + public EreExists(int propositionIndex, Ere body) + { + if (propositionIndex >= 0) + throw new ArgumentOutOfRangeException(nameof(propositionIndex), + "Proposition indices must be strictly negative."); + Body = body ?? throw new ArgumentNullException(nameof(body)); + PropositionIndex = propositionIndex; + } + public int PropositionIndex { get; } + public Ere Body { get; } + internal override int Kind => 11; + // ∃p.R contains ε iff R does — projection does not affect the + // empty-word membership. + public override bool Nullable => Body.Nullable; + public override bool ContainsCompl => Body.ContainsCompl; + public override bool ContainsInter => Body.ContainsInter; + public override bool ContainsExists => true; + public override int Cost => Body.Cost + 1; + public override int MinLen => Body.MinLen; + public override int MaxLen => Body.MaxLen; + public override ulong FreeProps + => Body.FreeProps & ~BitForProp(PropositionIndex); + public override bool Equals(Ere other) + => other is EreExists e + && e.PropositionIndex == PropositionIndex + && Body.Equals(e.Body); + protected override int ComputeHashCode() + => unchecked((int)((long)PropositionIndex * 0x9E3779B1L) ^ (Body.GetHashCode() * 11)); + protected override int CompareToSameKind(Ere other) + { + var e = (EreExists)other; + int c = PropositionIndex.CompareTo(e.PropositionIndex); + return c != 0 ? c : Body.CompareTo(e.Body); + } + public override string ToString() + => $"(∃p{-PropositionIndex - 1}.{Body})"; + } + + /// + /// Leaf Boolean algebra over : ∨ = union, ∧ = intersection, + /// ¬ = complement, ⊥ = ∅, ⊤ = Σ*. Used as the leaf algebra for ERE + /// transition terms (TTerm⟨A, Ere⟩). + /// + public sealed class EreLeafAlgebra : ILeafAlgebra> + { + public Ere Top => Ere.Sigma(); + public Ere Bottom => Ere.Empty(); + public bool IsTop(Ere a) + => a is EreComplement c && c.Inner is EreEmpty; + public bool IsBottom(Ere a) => a is EreEmpty; + public Ere Or(Ere a, Ere b) => Ere.Union(a, b); + public Ere And(Ere a, Ere b) => Ere.Intersect(a, b); + public Ere Not(Ere a) => Ere.Complement(a); + public Ere Xor(Ere a, Ere b) => Ere.Xor(a, b); + public IEqualityComparer> Comparer => EqualityComparer>.Default; + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreBuilder.cs b/Accordant.ModelChecking/Symbolic/EreBuilder.cs new file mode 100644 index 0000000..9b6f6f9 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreBuilder.cs @@ -0,0 +1,104 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System.Collections.Generic; + + /// + /// Hash-cons builder for terms. + /// + /// Every constructed ERE is interned: structurally equal terms + /// become the same object (reference equality) and share a unique + /// non-negative integer . Id 0 is + /// reserved for ("Bottom"), Id 1 + /// for . + /// + /// Storage layout: + /// + /// _byId: dense array () of canonical + /// terms indexed directly by . Holders of + /// an Id resolve to the term in O(1) without hashing. + /// _intern: used only during + /// to dedup-on-construction via the existing + /// structural Equals/GetHashCode on . + /// + /// + /// One builder is shared per closed + /// type via ; all static + /// factories on route through it. Direct use + /// of the builder API is optional; the static factories give the same + /// canonicalisation guarantees. + /// + public sealed class EreBuilder + { + private readonly List> _byId = new List>(); + private readonly Dictionary, Ere> _intern = + new Dictionary, Ere>(); + private IPredicateAlgebra _algebra; + + public EreBuilder() + { + // Reserve canonical slots for the two structural singletons so + // their Ids are stable across builder instances (Bottom = 0). + Assign(EreEmpty.Instance); + Assign(EreEpsilon.Instance); + } + + /// + /// The optional predicate algebra for this builder. When set, smart + /// constructors may use it to combine predicates (e.g. [p]|[q] → + /// [p∨q], ~([p]·Σ*) → [¬p]·Σ* | ε). When null, all + /// algebra-dependent rewrites are skipped and the term is preserved + /// as-is — soundness is unaffected, only the canonicalisation degree + /// changes. Register via . + /// + public IPredicateAlgebra Algebra => _algebra; + + /// + /// Bind an instance to this + /// builder. Idempotent for the same instance; throws if a different + /// algebra is already bound. Typically called once per + /// at startup. + /// + public void RegisterAlgebra(IPredicateAlgebra algebra) + { + if (algebra == null) throw new System.ArgumentNullException(nameof(algebra)); + if (_algebra != null && !object.ReferenceEquals(_algebra, algebra)) + throw new System.InvalidOperationException( + "An IPredicateAlgebra is already registered on this builder."); + _algebra = algebra; + } + + /// The canonical Bottom term (∅), always at Id 0. + public Ere Bottom => _byId[0]; + + /// The canonical Epsilon term (ε), always at Id 1. + public Ere Epsilon => _byId[1]; + + /// Number of distinct canonical terms currently stored. + public int Count => _byId.Count; + + /// + /// Resolve a term by its Id. O(1) array indexing. + /// + public Ere Get(int id) => _byId[id]; + + /// + /// Return the canonical instance equal to , + /// allocating a fresh Id if this shape has not been seen yet. + /// + public Ere Intern(Ere candidate) + { + if (candidate == null) return null; + if (candidate.HasId) return _byId[candidate.Id]; + if (_intern.TryGetValue(candidate, out var existing)) return existing; + Assign(candidate); + return candidate; + } + + private void Assign(Ere term) + { + term.AssignId(_byId.Count); + _byId.Add(term); + _intern[term] = term; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreCanonicalizer.cs b/Accordant.ModelChecking/Symbolic/EreCanonicalizer.cs new file mode 100644 index 0000000..1a84d4a --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreCanonicalizer.cs @@ -0,0 +1,88 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Canonicalises an by language-equivalence: + /// two regexes whose recognised languages are equal map to a single + /// reference-equal representative. + /// + /// + /// Used by at construction time to + /// canonicalise the regex sub-formula of R;φ, R:φ, + /// R⊳φ, R⊳⊳φ, (R)^F, (R)^¬F, (R)^ω. + /// Combined with RLTL hash-consing this gives RLTL structural equality + /// modulo ERE equivalence: two RLTL formulas that differ only by + /// language-equivalent embedded regexes become reference-equal, which + /// is the cheap and effective form of equivalence-as-state-reduction + /// for tableau / closure dedup (G8-c). + /// + /// + /// + /// The element type -only surface lets + /// consume a canonicaliser without + /// having to also be parametric in the EBA's element universe. + /// + /// + public interface IEreCanonicalizer + { + /// + /// Returns a canonical representative of 's + /// equivalence class. The first regex submitted from a class wins + /// and is returned for all subsequent equivalent inputs. + /// + Ere Canonicalize(Ere r); + } + + /// + /// Equivalence-class-based canonicaliser for + /// backed by . + /// + /// + /// Maintains an input→representative cache so already-seen regexes are + /// returned in O(1). For unseen regexes a linear scan of existing + /// representatives is performed; representatives are kept compact by + /// hash-consing on Ere references and by short-circuiting on + /// Ere.Equals before invoking the (more expensive) bisim check. + /// + /// + public sealed class EreCanonicalizer : IEreCanonicalizer + { + private readonly EreEquivalenceChecker _checker; + private readonly List> _representatives; + private readonly Dictionary, Ere> _cache; + + public EreCanonicalizer(EreEquivalenceChecker checker) + { + _checker = checker ?? throw new ArgumentNullException(nameof(checker)); + _representatives = new List>(); + _cache = new Dictionary, Ere>(); + } + + /// The representatives discovered so far (one per equivalence class). + public IReadOnlyList> Representatives => _representatives; + + /// Number of distinct equivalence classes discovered. + public int ClassCount => _representatives.Count; + + public Ere Canonicalize(Ere r) + { + if (r == null) throw new ArgumentNullException(nameof(r)); + if (_cache.TryGetValue(r, out var cached)) return cached; + + foreach (var rep in _representatives) + { + if (ReferenceEquals(r, rep) || r.Equals(rep) || _checker.AreEquivalent(r, rep)) + { + _cache[r] = rep; + return rep; + } + } + + _representatives.Add(r); + _cache[r] = r; + return r; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreDerivative.cs b/Accordant.ModelChecking/Symbolic/EreDerivative.cs new file mode 100644 index 0000000..b8b66ae --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreDerivative.cs @@ -0,0 +1,418 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Symbolic Brzozowski derivative for : maps an + /// ERE to a transition term TTerm⟨A, Ere⟩ whose leaves give the + /// residual ERE for each path-condition class of letters + /// (Section 7.1 of the POPL'25 paper). + /// + /// Derivative rules: + /// + /// ∂(∅) = ⊥-leaf (= ∅) + /// ∂(ε) = ⊥-leaf + /// ∂(p) = ITE(p, leaf(ε), leaf(∅)) + /// ∂(R · S) = (∂(R) ·' S) ∨ (R.Nullable ? ∂(S) : ⊥) + /// where ·'S lifts concatenation onto leaves + /// ∂(R + S) = ∂(R) ∨ ∂(S) + /// ∂(R & S) = ∂(R) ∧ ∂(S) + /// ∂(~R) = complement-lift over leaves of ∂(R) + /// ∂(R*) = mapLeaves(R' → R' · R*)(∂(R)) + /// ∂(R : S) = (OneStep(R), ∂(S)) | (∂(R) : S) — eq. (32), §7.3 JACM ext. + /// + /// + public class EreDerivative + { + private readonly EreLeafAlgebra _leafAlgebra; + private readonly TransitionTermAlgebra> _termAlgebra; + private readonly IEffectiveBooleanAlgebra _eba; + + // Memoization keyed by Ere.Id (hash-consed unique identifier). + // _derivCache[id] is the cached derivative of the Ere with that id, or + // null if not yet computed. Grown by doubling when an Ere with a larger + // Id is encountered. This avoids recomputing the derivative of any + // sub-regex that recurs across a derivative tree (extremely common + // because of right-association in Concat and the way Star, Complement, + // and the recursive structure of derivative rules re-derive + // sub-regexes). + private TransitionTerm>[] _derivCache = new TransitionTerm>[64]; + + // Equivalence-by-behavior detection (the user's optimization). + // Two regexes R and R' with the same nullability AND the same derivative + // (as a hash-consed TransitionTerm — equal by Id) are language-equivalent + // by coinduction over the derivative state space, since transition-term + // leaves are themselves hash-consed Ere terms. + // + // We store the FIRST regex Id encountered with each behavior signature + // (Nullable, Derivative.Id). Subsequent regexes hitting the same + // signature alias their cached derivative to the representative's, so + // any future use that picks up the cache hit benefits from sharing. + // The signature also feeds (a sound + // approximation of semantic ERE equivalence). + private readonly Dictionary<(bool nullable, int derivId), int> _repByBehavior + = new Dictionary<(bool, int), int>(); + private readonly Dictionary _canonicalRep + = new Dictionary(); + + // Precise-equivalence oracle (lazy: only built when first requested). + // The checker uses *this* derivative instance, so its caches are shared + // — no double computation of δ. + private EreEquivalenceChecker _preciseChecker; + // Memo of precise (a ≡ b) decisions, keyed (min Id, max Id). + private readonly Dictionary<(int, int), bool> _preciseEquivCache + = new Dictionary<(int, int), bool>(); + + public EreDerivative( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry) + { + if (eba == null) throw new ArgumentNullException(nameof(eba)); + if (registry == null) throw new ArgumentNullException(nameof(registry)); + _eba = eba; + _leafAlgebra = new EreLeafAlgebra(); + _termAlgebra = new TransitionTermAlgebra>( + eba, registry, _leafAlgebra); + } + + /// The transition-term algebra for TTerm⟨A, Ere⟩. + public TransitionTermAlgebra> TermAlgebra => _termAlgebra; + + /// + /// OneStep(R): the predicate over letters a such that the + /// singleton word a is in L(R). Used by the fusion derivative + /// (eq. 32) to detect when R is fully consumed by the shared letter. + /// + public TPred OneStep(Ere r) + { + switch (r) + { + case EreEmpty _: return _eba.Bottom; + case EreEpsilon _: return _eba.Bottom; + case EreAtom atom: return atom.Predicate; + case EreConcat c: + { + // a ∈ L(R·S) iff a ∈ L(R) ∧ ε ∈ L(S) or ε ∈ L(R) ∧ a ∈ L(S) + TPred lhs = c.Right.Nullable ? OneStep(c.Left) : _eba.Bottom; + TPred rhs = c.Left.Nullable ? OneStep(c.Right) : _eba.Bottom; + return _eba.Or(lhs, rhs); + } + case EreUnion u: + { + TPred acc = _eba.Bottom; + foreach (var op in u.Operands) acc = _eba.Or(acc, OneStep(op)); + return acc; + } + case EreIntersect i: + { + TPred acc = _eba.Top; + foreach (var op in i.Operands) acc = _eba.And(acc, OneStep(op)); + return acc; + } + case EreComplement cmp: + // a ∈ L(~R) iff a ∉ L(R) + return _eba.Not(OneStep(cmp.Inner)); + case EreStar s: + // a ∈ L(R*) iff a ∈ L(R^k) for some k. Only k=1 can match a + // length-1 word, so OneStep(R*) = OneStep(R). + return OneStep(s.Inner); + case EreFusion f: + // a ∈ L(R:S) requires a ∈ L(R) ∧ a ∈ L(S) (both share the only letter). + return _eba.And(OneStep(f.Left), OneStep(f.Right)); + case EreProposition _: + case EreExists _: + // EREQ Phase 2: OneStep returns a TPred, but propositions + // constrain letters via a Boolean valuation that is not + // expressible in the underlying predicate algebra. Fusion + // combined with quantified atoms is out of Phase-2 scope; + // gate it with a clear error rather than silently + // over-approximating. + throw new NotSupportedException( + "OneStep over EreProposition/EreExists is not supported " + + "(EREQ Phase 2 does not integrate quantified atoms " + + "with the fusion derivative)."); + default: + throw new ArgumentException($"Unknown ERE: {r.GetType()}"); + } + } + + /// Computes ∂(R), memoised by . + public TransitionTerm> Derivative(Ere regex) + { + if (regex == null) throw new ArgumentNullException(nameof(regex)); + int id = regex.Id; + if (id >= 0) + { + if (id < _derivCache.Length) + { + var hit = _derivCache[id]; + if (hit != null) return hit; + } + else + { + int newLen = _derivCache.Length; + while (newLen <= id) newLen *= 2; + Array.Resize(ref _derivCache, newLen); + } + } + + var d = DerivativeUncached(regex); + + if (id >= 0) + { + // Behavior-signature equivalence: if some earlier regex R' was + // seen with the same (Nullable, Derivative.Id), record the + // alias. The transition term itself is already shared by + // hash-consing, so the cached derivative we store under R's Id + // is reference-equal to R's. The alias is exposed via + // for downstream + // canonicalisation passes. + var sig = (regex.Nullable, d.Id); + if (_repByBehavior.TryGetValue(sig, out var repId)) + { + if (repId != id) _canonicalRep[id] = repId; + } + else + { + _repByBehavior[sig] = id; + } + _derivCache[id] = d; + } + return d; + } + + /// + /// Returns the canonical-representative Ere.Id for + /// according to the (nullable, derivative-Id) equivalence detected so far, + /// or .Id if no equivalent regex has been seen. + /// Only meaningful after has been called for + /// both members of an equivalence class. + /// + public int CanonicalRepresentative(Ere ere) + { + if (ere == null) throw new ArgumentNullException(nameof(ere)); + return FindRep(ere.Id); + } + + // Walk the _canonicalRep chain to its root, with path-compression on + // the way back so repeated queries are amortised O(α(n)). + private int FindRep(int id) + { + if (!_canonicalRep.TryGetValue(id, out var parent)) return id; + int root = FindRep(parent); + if (root != parent) _canonicalRep[id] = root; + return root; + } + + /// + /// True when and have been + /// observed to share the same (nullable, derivative-Id) signature, which + /// — combined with leaf-level Ere hash-consing — implies language + /// equivalence. Sound but incomplete: returns false for equivalent + /// regexes whose derivatives have not (yet) been canonicalised to the + /// same transition term. + /// + public bool AreEquivalent(Ere a, Ere b) + { + if (a == null) throw new ArgumentNullException(nameof(a)); + if (b == null) throw new ArgumentNullException(nameof(b)); + if (ReferenceEquals(a, b) || a.Id == b.Id) return true; + // Ensure both are in the cache. + Derivative(a); + Derivative(b); + return FindRep(a.Id) == FindRep(b.Id); + } + + /// + /// Precise (complete) language-equivalence decision via the CAV'26 + /// bisimulation algorithm. Strictly stronger than the + /// signature-based : returns true for ALL + /// language-equivalent pairs, not only those whose derivatives have + /// already been canonicalised to the same transition term. + /// + /// Decisions are memoised by (min Id, max Id). On a positive + /// result, the canonical-rep chain is aliased so downstream + /// queries return the same rep + /// for both — giving the rest of the pipeline the benefit of + /// state-space collapse without re-running the bisim. + /// + /// Cost: a bisim run per fresh (a, b) pair on cache miss. + /// Memoised both ways via the symmetric key. Use this where the + /// caller cares about completeness (e.g. NBW state minimisation), + /// and elsewhere. + /// + public bool AreEquivalentPrecise(Ere a, Ere b) + { + if (a == null) throw new ArgumentNullException(nameof(a)); + if (b == null) throw new ArgumentNullException(nameof(b)); + if (ReferenceEquals(a, b) || a.Id == b.Id) return true; + + // Fast path 1: signature-based check already proves equivalence. + Derivative(a); + Derivative(b); + if (FindRep(a.Id) == FindRep(b.Id)) return true; + + // Cache: symmetric key. + int aId = a.Id, bId = b.Id; + var key = aId < bId ? (aId, bId) : (bId, aId); + if (_preciseEquivCache.TryGetValue(key, out var cached)) return cached; + + // Run the precise checker. + if (_preciseChecker == null) + _preciseChecker = new EreEquivalenceChecker(this); + bool result = _preciseChecker.AreEquivalent(a, b); + _preciseEquivCache[key] = result; + + // On equivalence: alias canonical reps so future queries (and any + // downstream consumer of CanonicalRepresentative) benefit. + if (result) UnionCanonicalRep(a.Id, b.Id); + return result; + } + + // Union two ids in the canonical-rep map. Keep the smaller Id as the + // representative (it predates the larger one by construction of + // the Ere intern table). + private void UnionCanonicalRep(int x, int y) + { + int rx = FindRep(x), ry = FindRep(y); + if (rx == ry) return; + int keep = rx < ry ? rx : ry; + int drop = rx < ry ? ry : rx; + _canonicalRep[drop] = keep; + } + + private TransitionTerm> DerivativeUncached(Ere regex) + { + switch (regex) + { + case EreEmpty _: + case EreEpsilon _: + return _termAlgebra.Bottom; + + case EreAtom atom: + { + int idx = _termAlgebra.Registry.Register(atom.Predicate); + return _termAlgebra.MkIte( + idx, + TransitionTerm>.Leaf(Ere.Epsilon()), + _termAlgebra.Bottom); + } + + case EreConcat concat: + { + var dR = Derivative(concat.Left); + var lifted = _termAlgebra.MapUnary( + dR, r => Ere.Concat(r, concat.Right)); + if (concat.Left.Nullable) + return _termAlgebra.Or(lifted, Derivative(concat.Right)); + return lifted; + } + + case EreUnion union: + { + var result = Derivative(union.Operands[0]); + for (int i = 1; i < union.Operands.Count; i++) + result = _termAlgebra.Or(result, Derivative(union.Operands[i])); + return result; + } + + case EreIntersect inter: + { + var result = Derivative(inter.Operands[0]); + for (int i = 1; i < inter.Operands.Count; i++) + result = _termAlgebra.And(result, Derivative(inter.Operands[i])); + return result; + } + + case EreComplement comp: + { + var dR = Derivative(comp.Inner); + return _termAlgebra.MapUnary(dR, Ere.Complement); + } + + case EreStar star: + { + var dR = Derivative(star.Inner); + return _termAlgebra.MapUnary( + dR, r => Ere.Concat(r, star)); + } + + case EreFusion fusion: + { + // δ(R:S) = (OneStep(R), δ(S)) | (δ(R):S) — eq. (32), §7.3 + // The guard (α, t) is encoded as the TTerm (α ? ⊤ : ⊥) ∧ t, which + // keeps the BDD ordering correct regardless of α's index relative + // to t's top condition. + var oneStepR = OneStep(fusion.Left); + int idx = _termAlgebra.Registry.Register(oneStepR); + var guard = _termAlgebra.MkIte( + idx, _termAlgebra.Top, _termAlgebra.Bottom); + var guarded = _termAlgebra.And(guard, Derivative(fusion.Right)); + + var dRfused = _termAlgebra.MapUnary( + Derivative(fusion.Left), + r => Ere.Fusion(r, fusion.Right)); + + return _termAlgebra.Or(guarded, dRfused); + } + + case EreXor xor: + { + // δ(R₁ ⊕ … ⊕ Rₙ) = δR₁ ⊕ … ⊕ δRₙ — XOR commutes with + // derivative because complement does and XOR is built + // from complement+union (CAV'26 §4). + // For XNOR (Negated): δ(~X) = ~δX → complement leaves. + var result = Derivative(xor.Operands[0]); + for (int i = 1; i < xor.Operands.Count; i++) + result = _termAlgebra.Xor(result, Derivative(xor.Operands[i])); + if (xor.Negated) + result = _termAlgebra.MapUnary(result, Ere.Complement); + return result; + } + + case EreProposition prop: + { + // EREQ Phase 2 / paper §7: ∂(p) is a single-letter atom + // gated by the proposition's truth value. Polarity=true + // accepts when p holds along the letter; polarity=false + // accepts when p does not hold. Encoded as an ITE split + // on the (negative) proposition index — TransitionTermAlgebra + // recognises the negative level and skips path tightening + // (D5), so both branches remain reachable. + var epsLeaf = TransitionTerm>.Leaf(Ere.Epsilon()); + return prop.Polarity + ? _termAlgebra.MkIte(prop.PropositionIndex, epsLeaf, _termAlgebra.Bottom) + : _termAlgebra.MkIte(prop.PropositionIndex, _termAlgebra.Bottom, epsLeaf); + } + + case EreExists ex: + { + // EREQ Phase 2 / paper §7 / Rust prototype lib.rs:1199: + // ∂(∃p. R) = ∃p. ∂(R) lifted onto TTerm leaves. + // Bit-elimination optimisation: if the top condition of + // ∂(R) is exactly the bound proposition, distribute ∃ over + // both branches and union — this strips the prop split out + // of the resulting TTerm, since + // ∃p. (p ? T₁ : T₀) ≡ ∃p.T₁ ∪ ∃p.T₀. + var bodyDer = Derivative(ex.Body); + int bound = ex.PropositionIndex; + if (bodyDer is TransitionTermIte> ite + && ite.ConditionIndex == bound) + { + var hi = _termAlgebra.MapUnary( + ite.Hi, r => Ere.Exists(bound, r)); + var lo = _termAlgebra.MapUnary( + ite.Lo, r => Ere.Exists(bound, r)); + return _termAlgebra.Or(hi, lo); + } + return _termAlgebra.MapUnary( + bodyDer, r => Ere.Exists(bound, r)); + } + + default: + throw new ArgumentException($"Unknown ERE: {regex.GetType()}"); + } + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreEmptinessChecker.cs b/Accordant.ModelChecking/Symbolic/EreEmptinessChecker.cs new file mode 100644 index 0000000..68b2309 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreEmptinessChecker.cs @@ -0,0 +1,420 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System.Collections.Generic; + + /// + /// Algorithmic emptiness ("dead") and non-emptiness ("alive") check for + /// . + /// + /// + /// A regex R is alive iff FLang(R) ≠ ∅, i.e. some + /// finite word is accepted; R is dead iff FLang(R) = ∅. + /// Aliveness is not a static structural property of the AST (e.g., + /// α & ¬α is dead but not syntactically ), + /// so we decide it by exploring the Brzozowski-derivative state space: + /// + /// + /// R is alive iff some reachable derivative state + /// ∂_u(R) is Nullable along a path whose accumulated + /// guard predicate is satisfiable in the underlying EBA. + /// The derivative state space is finite (Antimirov / Brzozowski + /// give bounded number of distinct residuals modulo similarity laws, + /// which the existing ERE factories enforce). + /// + /// Results are memoised per regex on this checker instance. + /// + /// + /// Limitation. The check is only as precise as the EBA's + /// . With a conservative + /// EBA (e.g. StatePropEba), some semantically-dead regexes will be + /// reported alive because their guarding predicate α is reported + /// satisfiable even though L(α) = ∅. Used in + /// this remains sound for + /// closure semantics because the derivative loop will eventually + /// structurally collapse such states to on + /// further unfolding. + /// + /// + public sealed class EreEmptinessChecker + { + // Aliveness state, indexed by Ere.Id. Grown by doubling. + // 0 = unknown, 1 = alive, 2 = dead. + private byte[] _aliveState = new byte[64]; + private readonly IPredicateAlgebra _predAlg; + private readonly EreDerivative _ereDeriv; + private readonly EreEmptinessCheckerOptions _options; + + /// The options the checker was constructed with. + public EreEmptinessCheckerOptions Options => _options; + + /// Cumulative count of times the DNF-leaf-splitting rule + /// has emitted more than one successor for a single transition-term + /// leaf since this checker was constructed (= number of leaves split, + /// not number of disjuncts produced). Reset on demand via + /// . Always 0 when + /// is false. + /// + public long DnfLeafSplits { get; private set; } + + /// Cumulative count of distinct residual states enqueued + /// for exploration across all / + /// calls since this checker was constructed (= sum of |seen| / |parent| + /// across calls). Useful as a state-space-size proxy when measuring + /// the impact of options like + /// . Reset via + /// . + public long StatesEnqueued { get; private set; } + + /// Reset performance / instrumentation counters to zero. + public void ResetCounters() { DnfLeafSplits = 0; StatesEnqueued = 0; } + + public EreEmptinessChecker(EreDerivative ereDeriv) + : this(ereDeriv, EreEmptinessCheckerOptions.Default) { } + + public EreEmptinessChecker( + EreDerivative ereDeriv, + EreEmptinessCheckerOptions options) + { + _ereDeriv = ereDeriv ?? throw new System.ArgumentNullException(nameof(ereDeriv)); + _predAlg = ereDeriv.TermAlgebra.Eba; + _options = options ?? EreEmptinessCheckerOptions.Default; + } + + /// True iff FLang(R) ≠ ∅. + public bool IsAlive(Ere r) => Decide(r); + + /// True iff FLang(R) = ∅. + public bool IsDead(Ere r) => !Decide(r); + + /// + /// Witness-producing emptiness check. Returns true if + /// FLang(r) ≠ ∅; in that case + /// is the path-condition list (backwards: head = most-recent / + /// last-emitted predicate) of a satisfiable accepting run, with + /// already at the tail. The caller + /// reverses the result to obtain the witness in forward order. + /// + /// + /// Per design: path-condition predicates are threaded as a + /// parameter, never attached to the regex AST — the t-term DAG + /// is preserved. Used by + /// when a non-EreXor leaf is encountered and a distinguishing word + /// is needed. + /// + /// + public bool NonEmpty(Ere r, ConsList prefixReverse, + out ConsList witnessReverse) + { + if (prefixReverse == null) prefixReverse = ConsList.Empty; + + // r itself accepts the empty word — witness is just the prefix. + if (r.Nullable) { witnessReverse = prefixReverse; return true; } + if (r is EreEmpty) { witnessReverse = null; return false; } + + // BFS with parent tracking so we can reconstruct a guarded path + // from r to the first nullable residual reached. + var parent = new Dictionary, (Ere p, TPred g)>(); + parent[r] = (null, default); + var queue = new Queue>(); + queue.Enqueue(r); + Ere found = null; + + while (queue.Count > 0) + { + var s = queue.Dequeue(); + var dR = _ereDeriv.Derivative(s); + foreach (var (residual, guard) in EnumerateLeavesSplit(dR, _predAlg.Top)) + { + if (residual is EreEmpty) continue; + if (!_predAlg.IsSatisfiable(guard)) continue; + if (parent.ContainsKey(residual)) continue; + parent[residual] = (s, guard); + StatesEnqueued++; + if (residual.Nullable) { found = residual; goto Done; } + queue.Enqueue(residual); + } + } + Done: + if (found == null) { witnessReverse = null; return false; } + + // Walk parents nullable → r, collecting guards in reverse order. + var guards = new List(); + var cur = found; + while (true) + { + var (p, g) = parent[cur]; + if (p == null) break; + guards.Add(g); + cur = p; + } + // guards = [g_k, g_{k-1}, ..., g_1] (last-to-first symbol). + // Push onto prefix in REVERSE so head ends up as g_k (most recent). + var w = prefixReverse; + for (int i = guards.Count - 1; i >= 0; i--) w = w.Push(guards[i]); + witnessReverse = w; + return true; + } + + /// + /// EREQ Phase-4 quantified witness variant of + /// : + /// each step carries both the per-letter predicate guard and the + /// proposition valuations chosen by the search (per Phase-2 D1, + /// negative-indexed propositions). Use this whenever the regex + /// may contain / + /// and the caller needs the propositional assignment along the + /// accepted word. + /// + public bool NonEmpty( + Ere r, + ConsList> prefixReverse, + out ConsList> witnessReverse) + { + if (prefixReverse == null) prefixReverse = ConsList>.Empty; + + if (r.Nullable) { witnessReverse = prefixReverse; return true; } + if (r is EreEmpty) { witnessReverse = null; return false; } + + // Same BFS topology as the predicate-only variant but the + // parent map records the full step (predicate + propositions). + var parent = new Dictionary, (Ere p, EreWitnessStep s)>(); + parent[r] = (null, null); + var queue = new Queue>(); + queue.Enqueue(r); + Ere found = null; + + var emptyProps = new Dictionary(0); + + while (queue.Count > 0) + { + var cur = queue.Dequeue(); + var dR = _ereDeriv.Derivative(cur); + foreach (var (residual, guard, propVals) in + EnumerateLeavesWithPropsSplit(dR, _predAlg.Top, emptyProps)) + { + if (residual is EreEmpty) continue; + if (!_predAlg.IsSatisfiable(guard)) continue; + if (parent.ContainsKey(residual)) continue; + parent[residual] = (cur, new EreWitnessStep(guard, propVals)); + StatesEnqueued++; + if (residual.Nullable) { found = residual; goto Done; } + queue.Enqueue(residual); + } + } + Done: + if (found == null) { witnessReverse = null; return false; } + + var steps = new List>(); + var c = found; + while (true) + { + var (p, s) = parent[c]; + if (p == null) break; + steps.Add(s); + c = p; + } + var w = prefixReverse; + for (int i = steps.Count - 1; i >= 0; i--) w = w.Push(steps[i]); + witnessReverse = w; + return true; + } + + private bool Decide(Ere root) + { + var rs = GetState(root); + if (rs == 1) return true; + if (rs == 2) return false; + + // BFS over residuals, but each transition is taken only if the + // accumulated path predicate is satisfiable. A residual state is + // associated with the disjunction of all path predicates by which + // it is reachable (we only need one satisfiable witness). + var seen = new HashSet> { root }; + var queue = new Queue>(); + queue.Enqueue(root); + + bool alive = false; + while (queue.Count > 0) + { + var s = queue.Dequeue(); + if (s.Nullable) { alive = true; break; } + if (s is EreEmpty) continue; + // Honour previously decided states: a known-dead residual + // contributes no new alive witnesses and need not be expanded; + // a known-alive residual immediately resolves the root as alive. + var sState = GetState(s); + if (sState == 2) continue; + if (sState == 1) { alive = true; break; } + + var dR = _ereDeriv.Derivative(s); + foreach (var (residual, guard) in EnumerateLeavesSplit(dR, _predAlg.Top)) + { + if (residual is EreEmpty) continue; + if (!_predAlg.IsSatisfiable(guard)) continue; + if (seen.Add(residual)) { StatesEnqueued++; queue.Enqueue(residual); } + } + } + + if (!alive) + { + foreach (var s in seen) SetState(s, 2); + } + else + { + SetState(root, 1); + } + return alive; + } + + private byte GetState(Ere r) + => r.Id >= 0 && r.Id < _aliveState.Length ? _aliveState[r.Id] : (byte)0; + + private void SetState(Ere r, byte state) + { + if (r.Id < 0) return; // un-interned (shouldn't happen via the factories) + if (r.Id >= _aliveState.Length) + { + int newLen = _aliveState.Length; + while (r.Id >= newLen) newLen *= 2; + System.Array.Resize(ref _aliveState, newLen); + } + _aliveState[r.Id] = state; + } + + /// + /// Enumerates leaves of a together + /// with the conjunction of guard predicates along the path leading to + /// each leaf. Walks the BDD-like ITE structure, tracking the positive + /// (hi) and negative (lo) branches via the underlying + /// . + /// + /// EREQ Phase-2 D5: proposition splits (negative condition + /// indices) do not tighten the path predicate — both branches are + /// always reachable and the predicate guard flows through unchanged. + /// This keeps the predicate-only API sound on EREQ regexes without + /// extending its return type. + /// + private IEnumerable<(Ere leaf, TPred guard)> EnumerateLeaves( + TransitionTerm> term, TPred pathGuard) + { + if (term is TransitionTermLeaf> leaf) + { + yield return (leaf.Value, pathGuard); + yield break; + } + var ite = (TransitionTermIte>)term; + if (ConditionRegistry.IsProposition(ite.ConditionIndex)) + { + foreach (var t in EnumerateLeaves(ite.Hi, pathGuard)) yield return t; + foreach (var t in EnumerateLeaves(ite.Lo, pathGuard)) yield return t; + yield break; + } + var pred = _ereDeriv.TermAlgebra.Registry.GetPredicate(ite.ConditionIndex); + var hiGuard = _predAlg.And(pathGuard, pred); + if (_predAlg.IsSatisfiable(hiGuard)) + foreach (var t in EnumerateLeaves(ite.Hi, hiGuard)) + yield return t; + var loGuard = _predAlg.And(pathGuard, _predAlg.Not(pred)); + if (_predAlg.IsSatisfiable(loGuard)) + foreach (var t in EnumerateLeaves(ite.Lo, loGuard)) + yield return t; + } + + /// + /// EREQ Phase-4 variant of : in addition + /// to the per-leaf path-condition guard, records the proposition + /// valuations along the branch (per + /// negative-index propositions). Each branch into the Hi/Lo of a + /// proposition split copies the running valuation and writes the + /// chosen Boolean. + /// + private IEnumerable<(Ere leaf, TPred guard, IReadOnlyDictionary propVals)> + EnumerateLeavesWithProps( + TransitionTerm> term, + TPred pathGuard, + IReadOnlyDictionary propVals) + { + if (term is TransitionTermLeaf> leaf) + { + yield return (leaf.Value, pathGuard, propVals); + yield break; + } + var ite = (TransitionTermIte>)term; + if (ConditionRegistry.IsProposition(ite.ConditionIndex)) + { + var hiVals = new Dictionary(propVals.Count + 1); + foreach (var kv in propVals) hiVals[kv.Key] = kv.Value; + hiVals[ite.ConditionIndex] = true; + foreach (var t in EnumerateLeavesWithProps(ite.Hi, pathGuard, hiVals)) + yield return t; + + var loVals = new Dictionary(propVals.Count + 1); + foreach (var kv in propVals) loVals[kv.Key] = kv.Value; + loVals[ite.ConditionIndex] = false; + foreach (var t in EnumerateLeavesWithProps(ite.Lo, pathGuard, loVals)) + yield return t; + yield break; + } + var pred = _ereDeriv.TermAlgebra.Registry.GetPredicate(ite.ConditionIndex); + var hiGuard = _predAlg.And(pathGuard, pred); + if (_predAlg.IsSatisfiable(hiGuard)) + foreach (var t in EnumerateLeavesWithProps(ite.Hi, hiGuard, propVals)) + yield return t; + var loGuard = _predAlg.And(pathGuard, _predAlg.Not(pred)); + if (_predAlg.IsSatisfiable(loGuard)) + foreach (var t in EnumerateLeavesWithProps(ite.Lo, loGuard, propVals)) + yield return t; + } + + /// + /// Phase 7: wrap and, if + /// is on, + /// expand any top-level leaf into + /// one (operand, guard) pair per disjunct. Increments + /// once per split leaf (not per + /// operand). Hash-cons identity ensures that equivalent operands + /// produced across different leaves dedup naturally through the + /// BFS parent map. + /// + private IEnumerable<(Ere leaf, TPred guard)> EnumerateLeavesSplit( + TransitionTerm> term, TPred pathGuard) + { + foreach (var pair in EnumerateLeaves(term, pathGuard)) + { + if (_options.SplitDnfLeaves && pair.leaf is EreUnion u) + { + DnfLeafSplits++; + foreach (var op in u.Operands) + yield return (op, pair.guard); + } + else + { + yield return pair; + } + } + } + + /// Phase 7 EREQ-aware variant of + /// ; see that method's remarks. + private IEnumerable<(Ere leaf, TPred guard, IReadOnlyDictionary propVals)> + EnumerateLeavesWithPropsSplit( + TransitionTerm> term, + TPred pathGuard, + IReadOnlyDictionary propVals) + { + foreach (var triple in EnumerateLeavesWithProps(term, pathGuard, propVals)) + { + if (_options.SplitDnfLeaves && triple.leaf is EreUnion u) + { + DnfLeafSplits++; + foreach (var op in u.Operands) + yield return (op, triple.guard, triple.propVals); + } + else + { + yield return triple; + } + } + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreEmptinessCheckerOptions.cs b/Accordant.ModelChecking/Symbolic/EreEmptinessCheckerOptions.cs new file mode 100644 index 0000000..d442526 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreEmptinessCheckerOptions.cs @@ -0,0 +1,40 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// Tunables for . + /// + /// Currently exposes a single switch for DNF leaf splitting + /// during BFS/state-graph exploration (paper-novel optimization, see + /// plan §"Phase 7"). The strategy is sound for all inputs; the + /// opt-out exists for differential and performance comparison and to + /// recover the legacy single-state-per-leaf behaviour. + /// + /// + public sealed class EreEmptinessCheckerOptions + { + /// + /// Default options (all optimizations enabled). + /// + public static EreEmptinessCheckerOptions Default { get; } + = new EreEmptinessCheckerOptions(); + + /// + /// When true, every transition-term leaf whose residual is a + /// top-level is split into one + /// successor per disjunct in the BFS frontier, rather than carried + /// forward as a single union-state. This converts a latent + /// subset-construction blow-up (e.g. Σ*·a·Σ^n — the + /// "distance_n" example) into a polynomial state-count graph: by + /// hash-cons identity each disjunct that recurs as the same regex + /// is dedup'd through the BFS parent map, so genuinely + /// equivalent successor states are visited at most once. + /// Sound by L(R1 ∪ R2) = L(R1) ∪ L(R2): emptiness of + /// the union equals emptiness of every disjunct, so splitting + /// into separate frontier nodes is a complete refactor of the + /// exploration. Witness reconstruction is preserved because each + /// successor edge keeps the same predecessor + guard. + /// Default: true. + /// + public bool SplitDnfLeaves { get; set; } = true; + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreEquivalenceChecker.cs b/Accordant.ModelChecking/Symbolic/EreEquivalenceChecker.cs new file mode 100644 index 0000000..0ff6f4e --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreEquivalenceChecker.cs @@ -0,0 +1,393 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Bisimulation-based language-equivalence checker for symbolic ERE + /// (CAV'26: Veanes et al., "Symbolic Extended Regular Expression + /// Equivalence", §4 algorithm + §6 implementation). + /// + /// The decision procedure is + /// + /// Eq(p, q) ⇔ Empty(p ⊕ q) + /// + /// where ⊕ is the primitive symmetric-difference operator + /// (see ) and Empty(r) is decided by a + /// union-find–based bisimulation over the symbolic-derivative state + /// space of r. + /// + /// Invariant (paper Inv(U,S)): regexes in the same + /// class are candidate bisimilar; if + /// the loop completes without finding a nullable XOR leaf or a + /// non-empty non-XOR leaf, the invariant is closed and + /// L(p) = L(q). + /// + /// Key §6 optimisations included: + /// + /// Leaves that are not XOR-shaped fall through to plain ERE + /// emptiness checking via + /// ("minutes vs. microseconds"). + /// Path-guard satisfiability pruning during leaf enumeration + /// (skips unreachable transitions in the symbolic alphabet). + /// Derivative caching via the shared + /// instance. + /// + /// + /// Soundness notes. + /// + /// The constructor canonicalisation of + /// already handles many shortcuts at the AST level + /// (r ⊕ r = ⊥, r ⊕ ~s = ~(r ⊕ s), etc.). After + /// canonicalisation, p ⊕ q is either a non-XOR ERE (which we + /// decide by direct emptiness) or an with + /// 2+ operands. + /// For an n-ary XOR leaf, the bisim invariant generalises + /// naturally: p1 ~U … ~U pn as a single class. Merge unions + /// them all; the "already merged" check asks whether all operands + /// share the same representative. + /// XNOR leaves ( = true) are + /// not bisim pairs in the same sense (they assert non-equivalence at + /// the surface). We treat them as non-XOR leaves and check their + /// emptiness directly — sound because L(~X) = ∅ ⇔ L(X) = Σ*. + /// + /// Like , soundness + /// is modulo the underlying 's + /// IsSatisfiable precision. + /// + /// + /// Not thread-safe. One checker instance owns one + /// union-find; instances are cheap, create one per top-level query + /// when needed. + /// + public sealed class EreEquivalenceChecker + { + private readonly EreDerivative _deriv; + private readonly EreEmptinessChecker _empt; + private readonly IPredicateAlgebra _predAlg; + private readonly EreEquivalenceCheckerOptions _options; + + /// Cumulative count of times the E-frame UF merge rule has + /// discharged a residual leaf since this checker was constructed. + /// Reset on demand by the user via . + /// Always 0 when + /// is false. + /// + /// Expected to be 0 on natural corpora. See the dormancy + /// note on + /// : the + /// factory + /// pushes ∃p inward across most ERE connectives, so the + /// XOR-of-∃p.body trigger pattern rarely survives down to + /// the bisim residual. A non-zero count is interesting and + /// indicates either deliberate UF seeding via + /// or an EREQ AST whose + /// existential wrappers were constructed outside the standard + /// smart-constructor path. + /// + /// + public long EFrameMergeFires { get; private set; } + + /// The options the checker was constructed with. + public EreEquivalenceCheckerOptions Options => _options; + + /// Reset performance / instrumentation counters to zero. + public void ResetCounters() { EFrameMergeFires = 0; } + + public EreEquivalenceChecker( + EreDerivative deriv, + EreEmptinessChecker emptinessChecker = null, + EreEquivalenceCheckerOptions options = null) + { + _deriv = deriv ?? throw new ArgumentNullException(nameof(deriv)); + _empt = emptinessChecker ?? new EreEmptinessChecker(deriv); + _predAlg = deriv.TermAlgebra.Eba; + _options = options ?? EreEquivalenceCheckerOptions.Default; + } + + /// + /// Returns true iff L(p) = L(q). Equivalent to + /// !IsAlive(p ⊕ q). + /// + public bool AreEquivalent(Ere p, Ere q) + { + if (p == null) throw new ArgumentNullException(nameof(p)); + if (q == null) throw new ArgumentNullException(nameof(q)); + if (ReferenceEquals(p, q) || p.Equals(q)) return true; + // Single-letter fast path: defer to the EBA's precise + // equivalence when both regexes are atoms. Saves an + // Xor-Empty bisim for the common leaf-level case and lets + // SMT-backed EBAs answer in one solver call. + if (p is EreAtom pa && q is EreAtom qa + && _predAlg.AreEquivalent(pa.Predicate, qa.Predicate)) + return true; + var xor = Ere.Xor(p, q); + return IsLanguageEmpty(xor); + } + + /// + /// Witness-producing inequivalence check. Returns true when + /// L(p) ≠ L(q), with a + /// distinguishing word in reversed path-condition form + /// (head = last symbol). The witness lies in + /// L(p) △ L(q) — i.e. it is accepted by exactly one of + /// p and q. Returns false when they are + /// equivalent ( is null). + /// + public bool AreInequivalent(Ere p, Ere q, + out ConsList witnessReverse) + { + if (p == null) throw new ArgumentNullException(nameof(p)); + if (q == null) throw new ArgumentNullException(nameof(q)); + if (ReferenceEquals(p, q) || p.Equals(q)) + { + witnessReverse = null; + return false; + } + var xor = Ere.Xor(p, q); + return NonEmpty(xor, out witnessReverse); + } + + /// + /// Decides L(r) = ∅. If is an + /// (un-negated) , runs the bisimulation + /// algorithm; otherwise defers to . + /// + public bool IsLanguageEmpty(Ere r) + { + if (r == null) throw new ArgumentNullException(nameof(r)); + + // Top-level differs on ε → trivially non-empty. + if (r.Nullable) return false; + + // Non-XOR (or XNOR) — defer to plain emptiness. + if (!(r is EreXor x) || x.Negated) + return _empt.IsDead(r); + + return Bisimulate(x); + } + + /// + /// Witness-producing non-emptiness. Returns true when + /// L(r) ≠ ∅; is then a + /// satisfying word as a reversed path-condition list (head = last + /// symbol). Returns false on emptiness with + /// = null. + /// + public bool NonEmpty(Ere r, out ConsList witnessReverse) + { + if (r == null) throw new ArgumentNullException(nameof(r)); + if (r.Nullable) { witnessReverse = ConsList.Empty; return true; } + if (!(r is EreXor x) || x.Negated) + return _empt.NonEmpty(r, ConsList.Empty, out witnessReverse); + return BisimulateWitness(x, out witnessReverse); + } + + // Paper Empty(r0), §4. + private bool Bisimulate(EreXor r0) + { + var U = new IntUnionFind(); + var S = new Stack>(); + + // MergeLeaf(r0): union r0's operands into one class. + // We've already ensured !r0.Nullable upstream. + MergeLeaf(U, r0); + S.Push(r0); + + while (S.Count > 0) + { + var r = S.Pop(); + var dr = _deriv.Derivative(r); + + foreach (var (leaf, guard) in EnumerateLeaves(dr, _predAlg.Top)) + { + if (!_predAlg.IsSatisfiable(guard)) continue; + if (leaf is EreEmpty) continue; // ⊥ leaf + + if (leaf is EreXor xleaf && !xleaf.Negated) + { + // XOR-shaped leaf: bisim step. + if (xleaf.Nullable) return false; + // EREQ E-frame merge rule (Phase 6): if every + // operand is ∃p.body_i with the same p, and all + // body_i are already in the same UF class, the + // pair is discharged by monotonicity of ∃p — no + // need to expand its derivative. The outer Ids + // are still merged to keep the partition + // consistent. + if (_options.UseEFrameMerge + && TryEFrameDischarge(U, xleaf)) + { + EFrameMergeFires++; + MergeLeaf(U, xleaf); + continue; + } + if (MergeLeaf(U, xleaf)) S.Push(xleaf); + } + else + { + // Non-XOR (or XNOR) leaf: fall through to plain + // emptiness. The transition is reachable (guard + // satisfiable), so if L(leaf) ≠ ∅ we have a + // distinguishing word. + if (_empt.IsAlive(leaf)) return false; + } + } + } + return true; + } + + // Witness-tracking variant: stack entries carry the reversed + // path-condition list W accumulated from r0 to the current node. + // On nullable XOR leaf: witness = W.Push(guard) + // On live non-XOR leaf: witness = NonEmpty(leaf, W.Push(guard)) + // Per design: W is threaded as a parameter — NEVER attached to the + // regex or transition-term nodes (preserves DAG sharing). + private bool BisimulateWitness(EreXor r0, + out ConsList witness) + { + var U = new IntUnionFind(); + var S = new Stack<(ConsList W, EreXor r)>(); + + MergeLeaf(U, r0); + S.Push((ConsList.Empty, r0)); + + while (S.Count > 0) + { + var (W, r) = S.Pop(); + var dr = _deriv.Derivative(r); + + foreach (var (leaf, guard) in EnumerateLeaves(dr, _predAlg.Top)) + { + if (!_predAlg.IsSatisfiable(guard)) continue; + if (leaf is EreEmpty) continue; + + if (leaf is EreXor xleaf && !xleaf.Negated) + { + if (xleaf.Nullable) + { + witness = W.Push(guard); + return true; + } + // EREQ E-frame merge (Phase 6): same rule as in + // Bisimulate. Safe in the witness loop: when the + // rule fires, the leaf's language equality is + // proven, so we will not be hiding a witness. + if (_options.UseEFrameMerge + && TryEFrameDischarge(U, xleaf)) + { + EFrameMergeFires++; + MergeLeaf(U, xleaf); + continue; + } + if (MergeLeaf(U, xleaf)) + S.Push((W.Push(guard), xleaf)); + } + else + { + if (_empt.NonEmpty(leaf, W.Push(guard), out witness)) + return true; + } + } + } + witness = null; + return false; + } + + /// + /// EREQ Phase 6: returns true iff every operand of + /// is an with the + /// same projector and every body is already in the same union-find + /// class. In that case the bisim invariant for this leaf is already + /// discharged by monotonicity of ∃p. + /// Exposed publicly so unit tests can verify the predicate + /// directly with a hand-seeded . + /// + /// Practical use. Inside the bisim driver the rule is + /// almost always inert (see the dormancy note on + /// ): the smart constructor for + /// ∃p has typically distributed past the leaf shape. The + /// helper is therefore most useful in two scenarios: + /// (1) deliberate test seeding — pre-merge bodies in the UF and + /// invoke TryEFrameDischarge directly on a hand-built + /// to assert the rule fires; and + /// (2) custom callers that build EREQ ASTs through a non- + /// canonicalising path and want to apply the discharge before + /// invoking the general bisim. + /// + /// + public static bool TryEFrameDischarge(IntUnionFind U, EreXor x) + { + var ops = x.Operands; + if (ops.Count < 2) return false; + if (!(ops[0] is EreExists e0)) return false; + int proj = e0.PropositionIndex; + int bodyRep = U.Find(e0.Body.Id); + for (int i = 1; i < ops.Count; i++) + { + if (!(ops[i] is EreExists ei)) return false; + if (ei.PropositionIndex != proj) return false; + if (U.Find(ei.Body.Id) != bodyRep) return false; + } + return true; + } + + /// + /// Unions all operand-Ids of into one class. + /// Returns true iff this changed the partition (i.e. the + /// operands were not already all in the same class). + /// + private static bool MergeLeaf(IntUnionFind U, EreXor x) + { + var ops = x.Operands; + int firstId = ops[0].Id; + int rep = U.Find(firstId); + bool changed = false; + for (int i = 1; i < ops.Count; i++) + { + int id = ops[i].Id; + if (U.Find(id) != rep) + { + U.Union(firstId, id); + rep = U.Find(firstId); + changed = true; + } + } + return changed; + } + + // Same enumeration shape as EreEmptinessChecker: walk the BDD-style + // ITE structure of the transition term, threading the conjunction of + // guard predicates from root to leaf. + private IEnumerable<(Ere leaf, TPred guard)> EnumerateLeaves( + TransitionTerm> term, TPred pathGuard) + { + if (term is TransitionTermLeaf> leaf) + { + yield return (leaf.Value, pathGuard); + yield break; + } + var ite = (TransitionTermIte>)term; + // EREQ proposition splits (negative indices) carry no associated + // predicate; both branches are always reachable, mirroring the + // EreEmptinessChecker pattern. + if (ConditionRegistry.IsProposition(ite.ConditionIndex)) + { + foreach (var t in EnumerateLeaves(ite.Hi, pathGuard)) yield return t; + foreach (var t in EnumerateLeaves(ite.Lo, pathGuard)) yield return t; + yield break; + } + var pred = _deriv.TermAlgebra.Registry.GetPredicate(ite.ConditionIndex); + + var hiGuard = _predAlg.And(pathGuard, pred); + if (_predAlg.IsSatisfiable(hiGuard)) + foreach (var t in EnumerateLeaves(ite.Hi, hiGuard)) + yield return t; + + var loGuard = _predAlg.And(pathGuard, _predAlg.Not(pred)); + if (_predAlg.IsSatisfiable(loGuard)) + foreach (var t in EnumerateLeaves(ite.Lo, loGuard)) + yield return t; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreEquivalenceCheckerOptions.cs b/Accordant.ModelChecking/Symbolic/EreEquivalenceCheckerOptions.cs new file mode 100644 index 0000000..6d3ba21 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreEquivalenceCheckerOptions.cs @@ -0,0 +1,51 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// Tunables for . + /// + /// Currently exposes a single switch for the EREQ E-frame UF merge + /// rule (paper-novel optimization, see plan §"EREQ Phase 6"). The + /// rule is sound for all inputs; the opt-out exists for differential and + /// performance comparison. + /// + /// + public sealed class EreEquivalenceCheckerOptions + { + /// + /// Default options (all optimizations enabled). + /// + public static EreEquivalenceCheckerOptions Default { get; } + = new EreEquivalenceCheckerOptions(); + + /// + /// Enable the EREQ E-frame UF merge rule: when a bisim residual leaf + /// is XOR-shaped and every operand has the form + /// ∃p.body_i with the same projector p, and every + /// body_i is already in the same union-find class under the + /// current candidate relation, discharge the leaf without expanding + /// its derivative. Sound by monotonicity of ∃p: + /// L(R) = L(S) ⇒ L(∃p.R) = L(∃p.S). + /// Default: true. + /// + /// Dormancy note (2026-05-29). Empirically the trigger + /// pattern almost never arises when checking inputs built via the + /// public smart constructors: the + /// factory + /// distributes ∃p over Union, Concat, Star, Fusion, + /// Intersect (partial extract) and Xor, so the ∃p + /// wrapper has typically already been pushed away from the leaf + /// shape the rule looks for. The + /// + /// counter is therefore expected to read 0 on natural + /// corpora (verified in EreqEFrameBenchmarkTests); the + /// rule remains in place because it is sound, very low-cost when + /// it never fires, and provably effective when callers seed the + /// UF state deliberately via + /// + /// or when a future non-canonicalising AST path produces the + /// trigger shape directly. + /// + /// + public bool UseEFrameMerge { get; set; } = true; + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreJson.cs b/Accordant.ModelChecking/Symbolic/EreJson.cs new file mode 100644 index 0000000..7bfdb87 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreJson.cs @@ -0,0 +1,263 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// + /// JSON serialization for over string + /// predicates. Mirrors the pattern: opaque + /// predicate labels, structural shapes, smart-constructor parse path + /// (which means deserialization re-runs all ERE simplifications). + /// + /// Format: + /// + /// ∅ → { "op": "Empty" } + /// ε → { "op": "Epsilon" } + /// atom p → { "op": "Atom", "pred": "p" } + /// r · s → { "op": "Concat", "left": ..., "right": ... } + /// r ∪ s ∪ ... → { "op": "Union", "args": [...] } + /// r ∩ s ∩ ... → { "op": "Intersect", "args": [...] } + /// r* → { "op": "Star", "inner": ... } + /// ~r → { "op": "Complement", "inner": ... } + /// r : s → { "op": "Fusion", "left": ..., "right": ... } + /// r ⊕ s ⊕ ... → { "op": "Xor", "args": [...] } (negated=false) + /// ~(r ⊕ s ⊕ ...) → { "op": "Xor", "args": [...], "neg": true } + /// + /// Sugar (parse-only): Sigma, Plus, Optional, Xnor. + /// + public static class EreJson + { + #region Serialization + + public static string Serialize(Ere regex) + { + if (regex == null) throw new ArgumentNullException(nameof(regex)); + var sb = new StringBuilder(); + SerializeCore(regex, sb, 0); + return sb.ToString(); + } + + internal static void SerializeCore(Ere regex, StringBuilder sb, int depth) + { + if (depth > 1000) + throw new InvalidOperationException("Regex nesting too deep (>1000)."); + + switch (regex) + { + case EreEmpty _: + sb.Append("{\"op\":\"Empty\"}"); break; + case EreEpsilon _: + sb.Append("{\"op\":\"Epsilon\"}"); break; + case EreAtom a: + sb.Append("{\"op\":\"Atom\",\"pred\":"); + JsonUtil.AppendString(sb, a.Predicate); + sb.Append('}'); + break; + case EreConcat c: + sb.Append("{\"op\":\"Concat\",\"left\":"); + SerializeCore(c.Left, sb, depth + 1); + sb.Append(",\"right\":"); + SerializeCore(c.Right, sb, depth + 1); + sb.Append('}'); + break; + case EreUnion u: + AppendArrayOp(sb, "Union", u.Operands, depth); + break; + case EreIntersect i: + AppendArrayOp(sb, "Intersect", i.Operands, depth); + break; + case EreStar s: + sb.Append("{\"op\":\"Star\",\"inner\":"); + SerializeCore(s.Inner, sb, depth + 1); + sb.Append('}'); + break; + case EreComplement n: + sb.Append("{\"op\":\"Complement\",\"inner\":"); + SerializeCore(n.Inner, sb, depth + 1); + sb.Append('}'); + break; + case EreFusion f: + sb.Append("{\"op\":\"Fusion\",\"left\":"); + SerializeCore(f.Left, sb, depth + 1); + sb.Append(",\"right\":"); + SerializeCore(f.Right, sb, depth + 1); + sb.Append('}'); + break; + case EreXor x: + sb.Append("{\"op\":\"Xor\",\"args\":["); + for (int k = 0; k < x.Operands.Count; k++) + { + if (k > 0) sb.Append(','); + SerializeCore(x.Operands[k], sb, depth + 1); + } + sb.Append(']'); + if (x.Negated) sb.Append(",\"neg\":true"); + sb.Append('}'); + break; + default: + throw new ArgumentException($"Unknown ERE type: {regex.GetType()}"); + } + } + + private static void AppendArrayOp(StringBuilder sb, string op, + IReadOnlyList> args, int depth) + { + sb.Append("{\"op\":\""); sb.Append(op); sb.Append("\",\"args\":["); + for (int i = 0; i < args.Count; i++) + { + if (i > 0) sb.Append(','); + SerializeCore(args[i], sb, depth + 1); + } + sb.Append("]}"); + } + + #endregion + + #region Deserialization + + public static Ere Deserialize(string json) + { + if (json == null) throw new ArgumentNullException(nameof(json)); + int pos = 0; + return ParseRegex(json, ref pos); + } + + internal static Ere ParseRegex(string json, ref int pos) + { + JsonUtil.SkipWhitespace(json, ref pos); + JsonUtil.Expect(json, ref pos, '{'); + JsonUtil.SkipWhitespace(json, ref pos); + var fields = JsonUtil.ParseFields(json, ref pos); + + if (!fields.TryGetValue("op", out var op)) + throw new FormatException("Missing 'op' field in ERE JSON."); + + switch (op) + { + case "Empty": return Ere.Empty(); + case "Epsilon": return Ere.Epsilon(); + case "Sigma": return Ere.Sigma(); + case "Atom": + { + if (!fields.TryGetValue("pred", out var pred)) + throw new FormatException("Atom missing 'pred' field."); + return Ere.Atom(pred); + } + case "Concat": + { + var (l, r) = ParseBinary(fields, "Concat"); + return Ere.Concat(l, r); + } + case "Fusion": + { + var (l, r) = ParseBinary(fields, "Fusion"); + return Ere.Fusion(l, r); + } + case "Union": + { + var args = ParseArgs(fields, "Union"); + var result = args[0]; + for (int i = 1; i < args.Count; i++) + result = Ere.Union(result, args[i]); + return result; + } + case "Intersect": + { + var args = ParseArgs(fields, "Intersect"); + var result = args[0]; + for (int i = 1; i < args.Count; i++) + result = Ere.Intersect(result, args[i]); + return result; + } + case "Xor": + { + var args = ParseArgs(fields, "Xor"); + var result = args[0]; + for (int i = 1; i < args.Count; i++) + result = Ere.Xor(result, args[i]); + bool neg = fields.TryGetValue("neg", out var nv) && nv == "true"; + return neg ? Ere.Complement(result) : result; + } + case "Xnor": + { + var args = ParseArgs(fields, "Xnor"); + var result = args[0]; + for (int i = 1; i < args.Count; i++) + result = Ere.Xor(result, args[i]); + return Ere.Complement(result); + } + case "Star": + { + if (!fields.TryGetValue("inner", out var innerJson)) + throw new FormatException("Star missing 'inner' field."); + int p = 0; + return Ere.Star(ParseRegex(innerJson, ref p)); + } + case "Complement": + { + if (!fields.TryGetValue("inner", out var innerJson)) + throw new FormatException("Complement missing 'inner' field."); + int p = 0; + return Ere.Complement(ParseRegex(innerJson, ref p)); + } + case "Plus": + { + if (!fields.TryGetValue("inner", out var innerJson)) + throw new FormatException("Plus missing 'inner' field."); + int p = 0; + return Ere.Plus(ParseRegex(innerJson, ref p)); + } + case "Optional": + { + if (!fields.TryGetValue("inner", out var innerJson)) + throw new FormatException("Optional missing 'inner' field."); + int p = 0; + return Ere.Optional(ParseRegex(innerJson, ref p)); + } + default: + throw new FormatException($"Unknown ERE op: '{op}'."); + } + } + + private static (Ere, Ere) ParseBinary( + Dictionary fields, string opName) + { + if (!fields.TryGetValue("left", out var leftJson)) + throw new FormatException($"{opName} missing 'left' field."); + if (!fields.TryGetValue("right", out var rightJson)) + throw new FormatException($"{opName} missing 'right' field."); + int p1 = 0, p2 = 0; + return (ParseRegex(leftJson, ref p1), ParseRegex(rightJson, ref p2)); + } + + private static List> ParseArgs( + Dictionary fields, string opName) + { + if (!fields.TryGetValue("args", out var argsJson)) + throw new FormatException($"{opName} missing 'args' field."); + var result = new List>(); + int pos = 0; + JsonUtil.SkipWhitespace(argsJson, ref pos); + JsonUtil.Expect(argsJson, ref pos, '['); + JsonUtil.SkipWhitespace(argsJson, ref pos); + if (pos < argsJson.Length && argsJson[pos] != ']') + { + result.Add(ParseRegex(argsJson, ref pos)); + JsonUtil.SkipWhitespace(argsJson, ref pos); + while (pos < argsJson.Length && argsJson[pos] == ',') + { + pos++; + JsonUtil.SkipWhitespace(argsJson, ref pos); + result.Add(ParseRegex(argsJson, ref pos)); + JsonUtil.SkipWhitespace(argsJson, ref pos); + } + } + if (result.Count == 0) + throw new FormatException($"{opName} must have at least one argument."); + return result; + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreWitness.cs b/Accordant.ModelChecking/Symbolic/EreWitness.cs new file mode 100644 index 0000000..9d55873 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreWitness.cs @@ -0,0 +1,145 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// A single position in an EREQ witness: the predicate guard that + /// constrains the letter at this position, paired with the + /// proposition valuation chosen for that letter (per Phase-2 D1: + /// propositions are negative-indexed in the + /// ). For non-EREQ + /// regexes is empty. + /// + public sealed class EreWitnessStep + { + public TPred Predicate { get; } + public IReadOnlyDictionary Propositions { get; } + + public EreWitnessStep(TPred predicate, IReadOnlyDictionary propositions) + { + Predicate = predicate; + Propositions = propositions ?? EmptyDict; + } + + private static readonly IReadOnlyDictionary EmptyDict = + new Dictionary(0); + } + + /// + /// Helpers for turning a reversed symbolic witness (a + /// of path-condition predicates, head = + /// last symbol) into a concrete forward sequence of elements. + /// + /// The EBA exposes + /// (a test) but not a "pick a satisfying element" primitive — that is + /// domain-specific. Callers therefore supply a chooseModel + /// delegate that returns some element satisfying a given predicate. + /// + public static class EreWitness + { + /// + /// Reverses into forward order + /// (first symbol first). Allocates a single array. + /// + public static IReadOnlyList ToForward( + ConsList witnessReverse) + { + if (witnessReverse == null) return Array.Empty(); + var arr = new TPred[witnessReverse.Count]; + int i = arr.Length - 1; + for (var n = witnessReverse; !n.IsEmpty; n = n.Tail) arr[i--] = n.Head; + return arr; + } + + /// + /// Materialises a concrete element word from the reversed symbolic + /// witness, using to instantiate each + /// predicate. The result is in forward order. + /// + public static IReadOnlyList Materialise( + ConsList witnessReverse, Func chooseModel) + { + if (chooseModel == null) throw new ArgumentNullException(nameof(chooseModel)); + var preds = ToForward(witnessReverse); + var arr = new TElem[preds.Count]; + for (int i = 0; i < preds.Count; i++) arr[i] = chooseModel(preds[i]); + return arr; + } + + /// + /// Materialises a concrete element word using the EBA's + /// + /// capability when available, falling back to + /// for predicates the EBA cannot + /// model. The parameter may be + /// null, in which case predicates the EBA cannot model + /// produce default(TElem). + /// + public static IReadOnlyList Materialise( + ConsList witnessReverse, + IEffectiveBooleanAlgebra algebra, + Func chooseModel = null) + { + if (algebra == null) throw new ArgumentNullException(nameof(algebra)); + var preds = ToForward(witnessReverse); + var arr = new TElem[preds.Count]; + for (int i = 0; i < preds.Count; i++) + { + if (algebra.TryGetModel(preds[i], out var elem)) + arr[i] = elem; + else if (chooseModel != null) + arr[i] = chooseModel(preds[i]); + else + arr[i] = default; + } + return arr; + } + + /// + /// EREQ Phase-4 reverse helper for the quantified-witness API: + /// turns a reversed list of + /// (head = last symbol) into forward order. + /// + public static IReadOnlyList> ToForward( + ConsList> witnessReverse) + { + if (witnessReverse == null) return Array.Empty>(); + var arr = new EreWitnessStep[witnessReverse.Count]; + int i = arr.Length - 1; + for (var n = witnessReverse; !n.IsEmpty; n = n.Tail) arr[i--] = n.Head; + return arr; + } + + /// + /// EREQ Phase-4 materialiser: turns a reversed quantified witness + /// into a forward list of (concrete-element, proposition-valuation) + /// pairs. The element is picked from the position's predicate via + /// the EBA's + /// (or as fallback); the proposition + /// valuation is forwarded unchanged. + /// + public static IReadOnlyList<(TElem letter, IReadOnlyDictionary propVals)> + Materialise( + ConsList> witnessReverse, + IEffectiveBooleanAlgebra algebra, + Func chooseModel = null) + { + if (algebra == null) throw new ArgumentNullException(nameof(algebra)); + var steps = ToForward(witnessReverse); + var arr = new (TElem, IReadOnlyDictionary)[steps.Count]; + for (int i = 0; i < steps.Count; i++) + { + TElem e; + if (algebra.TryGetModel(steps[i].Predicate, out var modelled)) + e = modelled; + else if (chooseModel != null) + e = chooseModel(steps[i].Predicate); + else + e = default; + arr[i] = (e, steps[i].Propositions); + } + return arr; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/EreqSExpr.cs b/Accordant.ModelChecking/Symbolic/EreqSExpr.cs new file mode 100644 index 0000000..c4e8696 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/EreqSExpr.cs @@ -0,0 +1,242 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// EREQ extension of : the same S-expression DSL + /// extended with the two EREQ Phase-2 forms + /// + /// + /// (prop NAME) + /// positive proposition atom (single letter where + /// NAME holds). NAME is auto-registered in the + /// supplied . + /// (nprop NAME) + /// negative proposition atom (single letter where + /// NAME does not hold). + /// (exists NAME r) + /// existential projection ∃NAME. r + /// (). + /// + /// + /// This module is the shared-DSL surface used for cross-validation + /// against the Rust resharp-algebra implementation (EREQ Phase 5): + /// the same printer feeds both BraggerSpecs and the Rust shim binary + /// at Rust/src/ereq/src/bin/ereq-emptiness.rs, so emptiness + /// verdicts can be compared structurally on identical inputs. + /// + /// The base ERE forms (empty, eps, (atom …), + /// (concat …), (union …), (inter …), (star r), + /// (comp r), (fusion r1 r2), (xor …), (xnor …)) + /// are delegated to so this module stays a thin + /// extension and round-trips with the existing tests. + /// + public static class EreqSExpr + { + // ───────────────────────────────────────────────────────────────── + // ERE+EREQ → SExpr + // ───────────────────────────────────────────────────────────────── + + public static SExpr ToSExpr( + Ere ere, + IPredicateCodec codec, + ConditionRegistry registry) + { + if (ere == null) throw new ArgumentNullException(nameof(ere)); + if (codec == null) throw new ArgumentNullException(nameof(codec)); + if (registry == null) throw new ArgumentNullException(nameof(registry)); + return EreqTo(ere, codec, registry); + } + + public static string Print( + Ere ere, + IPredicateCodec codec, + ConditionRegistry registry) + => ToSExpr(ere, codec, registry).ToString(); + + private static SExpr EreqTo( + Ere ere, + IPredicateCodec codec, + ConditionRegistry registry) + { + switch (ere) + { + case EreEmpty _: return new SAtom("empty"); + case EreEpsilon _: return new SAtom("eps"); + case EreAtom a: + return new SList(new SAtom("atom"), codec.Print(a.Predicate)); + case EreProposition p: + return new SList( + new SAtom(p.Polarity ? "prop" : "nprop"), + new SAtom(registry.GetPropositionName(p.PropositionIndex))); + case EreExists ex: + return new SList( + new SAtom("exists"), + new SAtom(registry.GetPropositionName(ex.PropositionIndex)), + EreqTo(ex.Body, codec, registry)); + case EreConcat c: + { + var ops = new List>(); + FlattenConcat(c, ops); + return Nary("concat", ops, codec, registry); + } + case EreUnion u: + return Nary("union", u.Operands, codec, registry); + case EreIntersect i: + return Nary("inter", i.Operands, codec, registry); + case EreComplement n: + return new SList(new SAtom("comp"), EreqTo(n.Inner, codec, registry)); + case EreStar s: + return new SList(new SAtom("star"), EreqTo(s.Inner, codec, registry)); + case EreFusion f: + return new SList(new SAtom("fusion"), + EreqTo(f.Left, codec, registry), + EreqTo(f.Right, codec, registry)); + case EreXor x: + { + var items = new SExpr[x.Operands.Count + 1]; + items[0] = new SAtom(x.Negated ? "xnor" : "xor"); + for (int j = 0; j < x.Operands.Count; j++) + items[j + 1] = EreqTo(x.Operands[j], codec, registry); + return new SList(items); + } + default: + throw new ArgumentException( + $"Unknown ERE node type: {ere.GetType().Name}"); + } + } + + private static void FlattenConcat(Ere e, List> acc) + { + if (e is EreConcat c) { FlattenConcat(c.Left, acc); FlattenConcat(c.Right, acc); } + else acc.Add(e); + } + + private static SExpr Nary( + string head, + IReadOnlyList> ops, + IPredicateCodec codec, + ConditionRegistry registry) + { + var items = new SExpr[ops.Count + 1]; + items[0] = new SAtom(head); + for (int i = 0; i < ops.Count; i++) + items[i + 1] = EreqTo(ops[i], codec, registry); + return new SList(items); + } + + // ───────────────────────────────────────────────────────────────── + // SExpr → ERE+EREQ + // ───────────────────────────────────────────────────────────────── + + public static Ere FromSExpr( + SExpr expr, + IPredicateCodec codec, + ConditionRegistry registry) + { + if (expr == null) throw new ArgumentNullException(nameof(expr)); + if (codec == null) throw new ArgumentNullException(nameof(codec)); + if (registry == null) throw new ArgumentNullException(nameof(registry)); + return ParseEreq(expr, codec, registry); + } + + public static Ere Parse( + string text, + IPredicateCodec codec, + ConditionRegistry registry) + => FromSExpr(SExpr.Parse(text), codec, registry); + + private static Ere ParseEreq( + SExpr expr, + IPredicateCodec codec, + ConditionRegistry registry) + { + if (expr is SAtom a) + { + switch (a.Value) + { + case "empty": return EreEmpty.Instance; + case "eps": return EreEpsilon.Instance; + default: + throw new FormatException( + $"Unknown ERE atom '{a.Value}'. Expected 'empty', 'eps', " + + "or a parenthesised form."); + } + } + var list = (SList)expr; + if (list.Items.Count == 0) + throw new FormatException("Empty list is not a valid ERE form."); + var head = ((SAtom)list.Items[0]).Value; + switch (head) + { + case "atom": + Expect(list, 2, "atom"); + return Ere.Atom(codec.Parse(list.Items[1])); + case "prop": + case "nprop": + Expect(list, 2, head); + { + var name = ((SAtom)list.Items[1]).Value; + int idx = registry.RegisterProposition(name); + return Ere.PropositionAtom(idx, polarity: head == "prop"); + } + case "exists": + Expect(list, 3, "exists"); + { + var name = ((SAtom)list.Items[1]).Value; + int idx = registry.RegisterProposition(name); + var body = ParseEreq(list.Items[2], codec, registry); + return Ere.Exists(idx, body); + } + case "concat": + return FoldBinary(list, codec, registry, Ere.Concat, minArity: 1); + case "union": + return FoldBinary(list, codec, registry, Ere.Union, minArity: 1); + case "inter": + return FoldBinary(list, codec, registry, Ere.Intersect, minArity: 1); + case "comp": + Expect(list, 2, "comp"); + return Ere.Complement(ParseEreq(list.Items[1], codec, registry)); + case "star": + Expect(list, 2, "star"); + return Ere.Star(ParseEreq(list.Items[1], codec, registry)); + case "fusion": + Expect(list, 3, "fusion"); + return Ere.Fusion( + ParseEreq(list.Items[1], codec, registry), + ParseEreq(list.Items[2], codec, registry)); + case "xor": + return FoldBinary(list, codec, registry, Ere.Xor, minArity: 2); + case "xnor": + return FoldBinary(list, codec, registry, Ere.Xnor, minArity: 2); + default: + throw new FormatException($"Unknown ERE/EREQ head '{head}'."); + } + } + + private static Ere FoldBinary( + SList list, + IPredicateCodec codec, + ConditionRegistry registry, + Func, Ere, Ere> combine, + int minArity) + { + int arity = list.Items.Count - 1; + if (arity < minArity) + throw new FormatException( + $"'{((SAtom)list.Items[0]).Value}' expects at least {minArity} operand(s), got {arity}."); + var acc = ParseEreq(list.Items[1], codec, registry); + for (int i = 2; i < list.Items.Count; i++) + acc = combine(acc, ParseEreq(list.Items[i], codec, registry)); + return acc; + } + + private static void Expect(SList list, int expected, string head) + { + if (list.Items.Count != expected) + throw new FormatException( + $"'{head}' expects exactly {expected - 1} operand(s), got {list.Items.Count - 1}."); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebra.cs b/Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebra.cs new file mode 100644 index 0000000..4ce19ff --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebra.cs @@ -0,0 +1,18 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// An Effective Boolean Algebra (EBA) over a universe of elements. + /// Provides the predicate operations needed for transition terms: + /// conjunction, disjunction, complement, and satisfiability checking. + /// + /// The type of predicates in the algebra. + /// The type of elements in the universe Σ. + public interface IEffectiveBooleanAlgebra + : IPredicateAlgebra + { + /// + /// The models relation: a ⊨ α. Returns true if element satisfies the predicate. + /// + bool Models(TElement element, TPredicate predicate); + } +} diff --git a/Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebraEx.cs b/Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebraEx.cs new file mode 100644 index 0000000..d8f5952 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/IEffectiveBooleanAlgebraEx.cs @@ -0,0 +1,31 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// Optional extension of + /// that an + /// algebra implements when it can produce a concrete element + /// witnessing satisfiability of a predicate. Typical SMT-backed + /// implementations satisfy this trivially via a model-extraction + /// call. + /// + /// Callers should not depend on this interface directly; the + /// extension method + /// probes for it and otherwise returns false, allowing the + /// caller to fall back to a domain-specific element chooser. + /// + public interface IEffectiveBooleanAlgebraEx + : IEffectiveBooleanAlgebra, + IPredicateAlgebraEx + { + /// + /// Try to produce a concrete element of the universe that + /// satisfies . Returns true and + /// sets when one is found; + /// false otherwise (including for unsatisfiable predicates + /// and for predicates the algebra cannot decide). The reported + /// element must satisfy + /// . + /// + bool TryGetModel(TPredicate predicate, out TElement element); + } +} diff --git a/Accordant.ModelChecking/Symbolic/ILeafAlgebra.cs b/Accordant.ModelChecking/Symbolic/ILeafAlgebra.cs new file mode 100644 index 0000000..7c2d172 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/ILeafAlgebra.cs @@ -0,0 +1,71 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System.Collections.Generic; + + /// + /// A Boolean algebra over leaf elements of transition terms. + /// + /// Defines the weak equivalence laws used to simplify leaves: + /// + /// ⊥ is the unit (identity) of ∨ (disjunction) and zero of ∧ + /// ⊤ is the unit (identity) of ∧ (conjunction) and zero of ∨ + /// ∨ and ∧ are Associative, Commutative, and Idempotent (ACI) + /// Law of excluded middle: ¬φ ∨ φ ≈ ⊤ + /// + /// + /// Implementors must ensure that Or and And return normalized results: + /// internally represented as sorted sequences without duplicates. + /// + /// The leaf type B. + public interface ILeafAlgebra + { + /// Top element ⊤ — the identity of ∧ and zero of ∨. + TLeaf Top { get; } + + /// Bottom element ⊥ — the identity of ∨ and zero of ∧. + TLeaf Bottom { get; } + + /// + /// Disjunction: φ ∨ ψ. + /// Must be ACI-normalized: associative, commutative, idempotent. + /// Returns ⊤ if the result is equivalent to top. + /// Eliminates ⊥ (unit of ∨). + /// + TLeaf Or(TLeaf a, TLeaf b); + + /// + /// Conjunction: φ ∧ ψ. + /// Must be ACI-normalized: associative, commutative, idempotent. + /// Returns ⊥ if the result is equivalent to bottom. + /// Eliminates ⊤ (unit of ∧). + /// + TLeaf And(TLeaf a, TLeaf b); + + /// + /// Complement: ¬φ. + /// May return null if complement is not supported for this leaf type. + /// + TLeaf Not(TLeaf a); + + /// + /// Symmetric difference (exclusive or): φ ⊕ ψ. + /// Implementations may use a primitive XOR form (e.g. ) + /// or fall back to (φ ∧ ¬ψ) ∨ (¬φ ∧ ψ). + /// Must satisfy: φ ⊕ ⊥ = φ, φ ⊕ φ = ⊥, + /// φ ⊕ ⊤ = ¬φ; ACI plus self-inverse. + /// + TLeaf Xor(TLeaf a, TLeaf b); + + /// Returns true if the leaf is equivalent to ⊤. + bool IsTop(TLeaf a); + + /// Returns true if the leaf is equivalent to ⊥. + bool IsBottom(TLeaf a); + + /// + /// Equality comparer for leaves. + /// Used for structural deduplication within ACI normalization. + /// + IEqualityComparer Comparer { get; } + } +} diff --git a/Accordant.ModelChecking/Symbolic/IPredicateAlgebra.cs b/Accordant.ModelChecking/Symbolic/IPredicateAlgebra.cs new file mode 100644 index 0000000..acb9030 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/IPredicateAlgebra.cs @@ -0,0 +1,38 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// The predicate-only fragment of an Effective Boolean Algebra: the + /// boolean lattice operations and conservative satisfiability test, + /// without the Models relation that ties predicates to a + /// concrete element universe Σ. + /// + /// Useful for components — like and + /// — that need to push boolean + /// combinations of predicates back into the EBA but never need to + /// evaluate them against concrete elements. + /// + public interface IPredicateAlgebra + { + /// Top element ⊤ — satisfied by all elements. + TPredicate Top { get; } + + /// Bottom element ⊥ — satisfied by no elements. + TPredicate Bottom { get; } + + /// Conjunction: α ⊓ β. + TPredicate And(TPredicate a, TPredicate b); + + /// Disjunction: α ⊔ β. + TPredicate Or(TPredicate a, TPredicate b); + + /// Complement: αᶜ. + TPredicate Not(TPredicate a); + + /// + /// Conservative satisfiability test. Returning false + /// guarantees unsatisfiability; returning true means + /// satisfiable or unknown. + /// + bool IsSatisfiable(TPredicate predicate); + } +} diff --git a/Accordant.ModelChecking/Symbolic/IPredicateAlgebraEx.cs b/Accordant.ModelChecking/Symbolic/IPredicateAlgebraEx.cs new file mode 100644 index 0000000..83e429b --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/IPredicateAlgebraEx.cs @@ -0,0 +1,41 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// Optional extension of + /// that an algebra implements when it can decide equivalence and + /// implication of predicates precisely (typically by + /// delegating to an SMT solver such as Z3). + /// + /// Callers should not depend on this interface directly; the + /// and + /// extension methods probe + /// for it and otherwise fall back to a conservative implementation + /// expressed in terms of : + /// + /// AreEquivalent(a, b) ≡ ¬IsSatisfiable(a ⊕ b) + /// Implies(a, b) ≡ ¬IsSatisfiable(a ∧ ¬b) + /// + /// The fallback is sound under the standard EBA invariant that + /// IsSatisfiable may be conservative-true (i.e. it never reports + /// false for a satisfiable predicate); it may be conservative + /// (return false when the algebra cannot prove + /// equivalence/implication). + /// + public interface IPredicateAlgebraEx : IPredicateAlgebra + { + /// + /// Decide whether and + /// denote the same set of elements. Implementations are required + /// to be precise when they return true; returning + /// false on equivalent inputs is permitted but discouraged. + /// + bool AreEquivalent(TPredicate a, TPredicate b); + + /// + /// Decide whether every element satisfying + /// also satisfies . Same precision contract + /// as . + /// + bool Implies(TPredicate a, TPredicate b); + } +} diff --git a/Accordant.ModelChecking/Symbolic/IncrementalAE.cs b/Accordant.ModelChecking/Symbolic/IncrementalAE.cs new file mode 100644 index 0000000..69277d5 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/IncrementalAE.cs @@ -0,0 +1,431 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Incremental (on-the-fly) alternation elimination algorithm. + /// Produces a lazily-constructed NBW from an ABW, computing transitions + /// only for states that are actually explored during model checking. + /// + /// Unlike the batch , + /// this version does not eagerly discover all reachable states. Instead, + /// it wraps the ABW and computes breakpoint transitions on demand when + /// is called. + /// + /// This follows the design in Section 5.3 of the POPL'25 paper where + /// the Æ construction is interleaved with the product construction + /// during model checking. + /// + public class IncrementalAE + { + private readonly SymbolicABW _abw; + private readonly TransitionTermAlgebra> _termAlgebra; + private readonly IComparer _stateComparer; + private readonly IComparer> _bpComparer; + private readonly Func, BreakpointState> _bpCanonicalizer; + private readonly Func, MacroReduction> _macroReducer; + private readonly bool _eagerAntimirov; + + // Cache for macrostate combined transitions + private readonly Dictionary, TransitionTerm>> _macroCache; + + // Cache for computed breakpoint transitions (the NBW uses this) + private readonly Dictionary, + IReadOnlyList>>>> _transitionCache; + + /// + /// Creates an incremental Æ instance for the given ABW. + /// + /// The alternating Büchi automaton to convert. + /// Optional canonicaliser applied + /// to every freshly-produced + /// (initial states and successors). Two breakpoint states that map to + /// the same representative are merged on-the-fly during NBW + /// construction, implementing the "weak equivalence merging" of the + /// Æ algorithm (JACM Example 5.1 state-reduction lemma): a freshly + /// generated (S,O) is replaced by an already-discovered + /// language-equivalent breakpoint when one exists, otherwise it + /// becomes the representative of its class. Pass null (default) + /// to disable merging — the construction is identity-only on + /// breakpoint states. + /// Optional macrostate reducer applied + /// to every freshly-produced macrostate S (initial states + /// and successors). Returns the reduced S' together with a + /// representative map. In the + /// obligation set O' is then derived as + /// { rep(q) : q ∈ clauseO, q ∉ F }, so obligations carried + /// by dropped states are forwarded to their surviving + /// representatives. Intended driver is the structural + /// rule: + /// two universal copies with identical transition terms + /// δ(q₁) = δ(q₂) and matching membership in F are + /// interchangeable in the breakpoint construction (JACM + /// Example 5.1 state-reduction lemma). Pass null (default) + /// to disable. + /// When true, each breakpoint + /// transition is eagerly normalised into Antimirov form via + /// FlattenDnfToAntimirov: every DNF clause at every BDD leaf is + /// distributed through the ITE structure, producing a list whose entries + /// each carry a single per leaf. This can + /// blow up multiplicatively in (BDD-depth × clauses/leaf), which is + /// expensive for highly-conjunctive properties such as + /// ∧ᵢ GF pᵢ. When false (the default), the algorithm + /// instead returns a single transition term whose leaves carry the + /// *union* of all DNF clauses as one . + /// This is semantically equivalent for NBW emptiness and product + /// traversal (both treat the outer list as disjunction and the leaf + /// set as nondeterministic choice over successor breakpoints) and + /// dramatically cheaper when the DNF is large. The previous default + /// (true) is kept available for ablation studies; see paper §6.2. + public IncrementalAE( + SymbolicABW abw, + Func, BreakpointState> breakpointCanonicalizer = null, + Func, MacroReduction> macroReducer = null, + bool eagerAntimirov = false) + { + _abw = abw ?? throw new ArgumentNullException(nameof(abw)); + _stateComparer = abw.DnfAlgebra.StateComparer; + _termAlgebra = abw.GetTermAlgebra(); + _bpComparer = BreakpointState.GetComparer(_stateComparer); + _bpCanonicalizer = breakpointCanonicalizer; + _macroReducer = macroReducer; + _eagerAntimirov = eagerAntimirov; + + _macroCache = new Dictionary, TransitionTerm>>(); + _transitionCache = new Dictionary, + IReadOnlyList>>>>( + BreakpointState.GetEqualityComparer()); + + // Initial states: one breakpoint (Sⱼ, ∅) per disjunct Sⱼ of the + // initial positive Boolean formula φ₀ = ⋁ⱼ Sⱼ ∈ B⁺(Q). + var emptyObligation = StateSet.Empty(_stateComparer); + var bps = new List>(abw.InitialState.ClauseCount); + foreach (var clause in abw.InitialState.Clauses) + { + var reduced = MacroReduce(clause).ReducedS; + bps.Add(CanonBp(new BreakpointState(reduced, emptyObligation))); + } + InitialStates = bps; + } + + private BreakpointState CanonBp(BreakpointState bp) + => _bpCanonicalizer == null ? bp : _bpCanonicalizer(bp); + + private MacroReduction MacroReduce(StateSet s) + => _macroReducer == null ? MacroReduction.Identity(s) : _macroReducer(s); + + /// + /// The initial breakpoint states — one per disjunct Sⱼ of φ₀. + /// + public IReadOnlyList> InitialStates { get; } + + /// + /// The single initial breakpoint state, available only when φ₀ is a + /// single-disjunct formula (the common case for LTL/RLTL derivation). + /// Throws if φ₀ has zero or + /// multiple disjuncts; use in that case. + /// + public BreakpointState InitialState + { + get + { + if (InitialStates.Count != 1) + throw new InvalidOperationException( + "InitialState requires a single-disjunct initial formula; " + + "use InitialStates for the general case."); + return InitialStates[0]; + } + } + + /// The underlying ABW. + public SymbolicABW Abw => _abw; + + /// + /// Determines if a breakpoint state is accepting. + /// A breakpoint state is accepting iff O = ∅ (obligation discharged). + /// + public bool IsAccepting(BreakpointState state) + => state.Obligation.IsEmpty; + + /// + /// Gets the transition for a breakpoint state, computing lazily if needed. + /// Returns transitions in Antimirov form. + /// + public IReadOnlyList>>> + GetTransition(BreakpointState state) + { + if (_transitionCache.TryGetValue(state, out var cached)) + return cached; + + var result = ComputeTransition(state); + _transitionCache[state] = result; + return result; + } + + /// + /// Constructs the lazy NBW backed by this incremental Æ instance. + /// The NBW computes transitions on demand via . + /// + public SymbolicNBW> ToNBW() + { + return new SymbolicNBW>( + _abw.Eba, + _abw.Registry, + InitialStates, + IsAccepting, + GetTransition, + BreakpointState.GetEqualityComparer()); + } + + /// + /// Number of breakpoint states whose transitions have been computed so far. + /// + public int ComputedStateCount => _transitionCache.Count; + + /// + /// Number of macrostate combined transitions cached. + /// + public int MacroCacheSize => _macroCache.Count; + + #region Private Implementation + + private TransitionTerm> GetMacroTransition(StateSet macrostate) + { + if (_macroCache.TryGetValue(macrostate, out var cached)) + return cached; + + TransitionTerm> combined = _termAlgebra.Top; + foreach (var q in macrostate) + { + var delta_q = _abw.GetTransition(q); + combined = _termAlgebra.And(combined, delta_q); + } + + _macroCache[macrostate] = combined; + return combined; + } + + private IReadOnlyList>>> + ComputeTransition(BreakpointState bpState) + { + var S = bpState.Macrostate; + var O = bpState.Obligation; + var result = new List>>>(); + + TransitionTerm>> dnfTerm; + if (O.IsEmpty) + { + // Breakpoint: reset. S' = clause from delta_S, O' = S' \ F + var delta_S = GetMacroTransition(S); + dnfTerm = _termAlgebra.MapUnary(delta_S, dnfS => + BuildResetDnf(dnfS)); + } + else + { + var sMinusO = S.Except(O); + + if (sMinusO.IsEmpty) + { + // O = S: all obligation states + var delta_O = GetMacroTransition(O); + dnfTerm = _termAlgebra.MapUnary(delta_O, dnfO => + BuildOOnlyDnf(dnfO)); + } + else + { + // General: S\O ≠ ∅, O ≠ ∅ + var delta_SminusO = GetMacroTransition(sMinusO); + var delta_O = GetMacroTransition(O); + + dnfTerm = _termAlgebra.ApplyCross( + delta_SminusO, delta_O, + (dnfSO, dnfO) => BuildCrossDnf(dnfSO, dnfO), + _abw.Eba.Top); + } + } + + if (_eagerAntimirov) + { + FlattenDnfToAntimirov(dnfTerm, result); + } + else + { + // DnfLeaves form: collapse the DNF at every leaf into the + // union of its clauses (a single StateSet of all candidate + // successor breakpoints). The outer list still represents + // top-level disjunction; this entry is the *one* term that + // covers every (cube → successor-set) pair without + // distributing DNF clauses through the BDD structure. + result.Add(CollapseDnfToStateSet(dnfTerm)); + } + return result; + } + + private TransitionTerm>> + CollapseDnfToStateSet(TransitionTerm>> term) + { + if (term is TransitionTermLeaf>> leaf) + { + var dnf = leaf.Value; + if (dnf.IsFalse || dnf.Clauses.Count == 0) + return TransitionTerm>>.Leaf( + StateSet>.Empty(_bpComparer)); + if (dnf.Clauses.Count == 1) + return TransitionTerm>>.Leaf(dnf.Clauses[0]); + var seen = new HashSet>( + BreakpointState.GetEqualityComparer()); + var union = new List>(); + foreach (var clause in dnf.Clauses) + foreach (var bp in clause) + if (seen.Add(bp)) + union.Add(bp); + return TransitionTerm>>.Leaf( + new StateSet>(union, _bpComparer)); + } + var ite = (TransitionTermIte>>)term; + return TransitionTerm>>.Ite( + ite.ConditionIndex, + CollapseDnfToStateSet(ite.Hi), + CollapseDnfToStateSet(ite.Lo)); + } + + private Dnf> BuildResetDnf(Dnf dnfS) + { + if (dnfS.IsFalse) + return new Dnf>( + Array.Empty>>()); + + var clauses = new List>>(); + foreach (var clauseS in dnfS.Clauses) + { + var reducedS = MacroReduce(clauseS).ReducedS; + var nonAccepting = new List(); + foreach (var q in reducedS) + if (!_abw.IsAccepting(q)) + nonAccepting.Add(q); + var oPrime = nonAccepting.Count > 0 + ? new StateSet(nonAccepting, _stateComparer) + : StateSet.Empty(_stateComparer); + + var successor = CanonBp(new BreakpointState(reducedS, oPrime)); + clauses.Add(StateSet>.Singleton(successor, _bpComparer)); + } + return new Dnf>(clauses.ToArray()); + } + + private Dnf> BuildOOnlyDnf(Dnf dnfO) + { + if (dnfO.IsFalse) + return new Dnf>( + Array.Empty>>()); + + var clauses = new List>>(); + foreach (var clauseO in dnfO.Clauses) + { + var reducedS = MacroReduce(clauseO).ReducedS; + var nonAccepting = new List(); + foreach (var q in reducedS) + if (!_abw.IsAccepting(q)) + nonAccepting.Add(q); + var oPrime = nonAccepting.Count > 0 + ? new StateSet(nonAccepting, _stateComparer) + : StateSet.Empty(_stateComparer); + + var successor = CanonBp(new BreakpointState(reducedS, oPrime)); + clauses.Add(StateSet>.Singleton(successor, _bpComparer)); + } + return new Dnf>(clauses.ToArray()); + } + + private Dnf> BuildCrossDnf( + Dnf dnfSO, Dnf dnfO) + { + if (dnfSO.IsFalse || dnfO.IsFalse) + return new Dnf>( + Array.Empty>>()); + + var clauses = new List>>(); + foreach (var clauseSO in dnfSO.Clauses) + { + foreach (var clauseO in dnfO.Clauses) + { + var sPrime = clauseSO.Union(clauseO); + var reduction = MacroReduce(sPrime); + var reducedS = reduction.ReducedS; + // O' = { rep(q) : q ∈ clauseO, q ∉ F }. Mapping + // through the representative function forwards every + // dropped obligation onto its surviving rep; the same- + // colour invariant of the reducer guarantees that the + // rep is itself non-accepting so it correctly stays in + // the obligation set. Dedup via HashSet on the BP + // comparer is implicit in the StateSet constructor. + var oReps = new List(); + var seen = new HashSet( + EqualityComparer.Default); + foreach (var q in clauseO) + { + if (_abw.IsAccepting(q)) continue; + var rep = reduction.RepOf(q); + if (seen.Add(rep)) oReps.Add(rep); + } + var oPrime = oReps.Count > 0 + ? new StateSet(oReps, _stateComparer) + : StateSet.Empty(_stateComparer); + + var successor = CanonBp(new BreakpointState(reducedS, oPrime)); + clauses.Add(StateSet>.Singleton(successor, _bpComparer)); + } + } + return new Dnf>(clauses.ToArray()); + } + + private void FlattenDnfToAntimirov( + TransitionTerm>> term, + List>>> result) + { + if (term is TransitionTermLeaf>> leaf) + { + foreach (var clause in leaf.Value.Clauses) + result.Add(TransitionTerm>>.Leaf(clause)); + return; + } + + var ite = (TransitionTermIte>>)term; + var hiTerms = new List>>>(); + var loTerms = new List>>>(); + FlattenDnfToAntimirov(ite.Hi, hiTerms); + FlattenDnfToAntimirov(ite.Lo, loTerms); + + // When one branch is ⊥ (empty), use empty StateSet as dead-end leaf. + var emptyLeaf = TransitionTerm>>.Leaf( + StateSet>.Empty(_bpComparer)); + + if (hiTerms.Count == 0 && loTerms.Count == 0) + { + return; + } + else if (loTerms.Count == 0) + { + foreach (var h in hiTerms) + result.Add(TransitionTerm>>.Ite( + ite.ConditionIndex, h, emptyLeaf)); + } + else if (hiTerms.Count == 0) + { + foreach (var l in loTerms) + result.Add(TransitionTerm>>.Ite( + ite.ConditionIndex, emptyLeaf, l)); + } + else + { + foreach (var h in hiTerms) + foreach (var l in loTerms) + result.Add(TransitionTerm>>.Ite( + ite.ConditionIndex, h, l)); + } + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Symbolic/JsonUtil.cs b/Accordant.ModelChecking/Symbolic/JsonUtil.cs new file mode 100644 index 0000000..a69cac7 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/JsonUtil.cs @@ -0,0 +1,141 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// + /// Shared JSON utility helpers used by both -style + /// and / serializers. + /// Extracted to remove duplication while keeping + /// API-stable (it still uses its own private copies; see file). + /// + internal static class JsonUtil + { + public static void AppendString(StringBuilder sb, string s) + { + sb.Append('"'); + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: sb.Append(c); break; + } + } + sb.Append('"'); + } + + public static void SkipWhitespace(string json, ref int pos) + { + while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++; + } + + public static void Expect(string json, ref int pos, char expected) + { + if (pos >= json.Length || json[pos] != expected) + throw new FormatException( + $"Expected '{expected}' at position {pos}, got '" + + (pos < json.Length ? json[pos].ToString() : "EOF") + "'."); + pos++; + } + + public static string ParseString(string json, ref int pos) + { + Expect(json, ref pos, '"'); + var sb = new StringBuilder(); + while (pos < json.Length && json[pos] != '"') + { + if (json[pos] == '\\') + { + pos++; + switch (json[pos]) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case '/': sb.Append('/'); break; + default: sb.Append(json[pos]); break; + } + } + else { sb.Append(json[pos]); } + pos++; + } + Expect(json, ref pos, '"'); + return sb.ToString(); + } + + public static Dictionary ParseFields(string json, ref int pos) + { + var fields = new Dictionary(); + SkipWhitespace(json, ref pos); + while (pos < json.Length && json[pos] != '}') + { + if (json[pos] == ',') { pos++; SkipWhitespace(json, ref pos); continue; } + var name = ParseString(json, ref pos); + SkipWhitespace(json, ref pos); + Expect(json, ref pos, ':'); + SkipWhitespace(json, ref pos); + var value = CaptureValue(json, ref pos); + fields[name] = value; + SkipWhitespace(json, ref pos); + } + if (pos < json.Length) pos++; // consume '}' + return fields; + } + + public static string CaptureValue(string json, ref int pos) + { + SkipWhitespace(json, ref pos); + char c = json[pos]; + if (c == '"') return ParseString(json, ref pos); + if (c == '{' || c == '[') + { + int start = pos; + int depth = 0; + bool inString = false; + while (pos < json.Length) + { + char ch = json[pos]; + if (inString) + { + if (ch == '\\') { pos++; } + else if (ch == '"') { inString = false; } + } + else + { + if (ch == '"') inString = true; + else if (ch == '{' || ch == '[') depth++; + else if (ch == '}' || ch == ']') + { + depth--; + if (depth == 0) { pos++; break; } + } + } + pos++; + } + return json.Substring(start, pos - start); + } + if (c == 't' || c == 'f' || c == 'n') + { + int start = pos; + while (pos < json.Length && char.IsLetter(json[pos])) pos++; + return json.Substring(start, pos - start); + } + if (char.IsDigit(c) || c == '-') + { + int start = pos; + while (pos < json.Length && (char.IsDigit(json[pos]) || json[pos] == '.' + || json[pos] == '-' || json[pos] == 'e' || json[pos] == 'E')) pos++; + return json.Substring(start, pos - start); + } + throw new FormatException($"Unexpected character '{c}' at position {pos}."); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/Ltl.cs b/Accordant.ModelChecking/Symbolic/Ltl.cs new file mode 100644 index 0000000..0c5143e --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/Ltl.cs @@ -0,0 +1,359 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// A symbolic LTL formula in negation normal form (NNF), generic over the predicate type. + /// Negation is pushed to atoms during construction, so all internal nodes are positive. + /// + /// Node types: + /// + /// : ⊤ + /// : ⊥ + /// : p or ¬p (predicate with optional negation) + /// : φ ∧ ψ (ACI-normalized) + /// : φ ∨ ψ (ACI-normalized) + /// : Xφ + /// : φ U ψ + /// : φ R ψ (dual of Until) + /// + /// + public abstract class Ltl : IEquatable>, IComparable> + { + private int? _hash; + + internal Ltl() { } + + /// Structural kind for ordering/comparison. + internal abstract int Kind { get; } + + #region Factory Methods + + public static Ltl True() => LtlTrue.Instance; + public static Ltl False() => LtlFalse.Instance; + + /// + /// Constructs an atomic LTL formula carrying . + /// Atoms hold only positive predicates; to negate, use + /// which pushes the complement + /// into the EBA via eba.Not. + /// + public static Ltl Atom(TPred predicate) => new LtlAtom(predicate); + + public static Ltl Next(Ltl inner) + { + if (inner is LtlTrue) return LtlTrue.Instance; + if (inner is LtlFalse) return LtlFalse.Instance; + return new LtlNext(inner); + } + + public static Ltl Until(Ltl left, Ltl right) + { + // φ U ⊤ = ⊤; φ U ⊥ = ⊥ would require infinite left; ⊥ U ψ = ψ + if (right is LtlTrue) return LtlTrue.Instance; + if (left is LtlFalse) return right; + return new LtlUntil(left, right); + } + + public static Ltl Release(Ltl left, Ltl right) + { + // φ R ⊥ = ⊥; φ R ⊤ = ⊤; ⊤ R ψ = ψ + if (right is LtlFalse) return LtlFalse.Instance; + if (right is LtlTrue) return LtlTrue.Instance; + if (left is LtlTrue) return right; + return new LtlRelease(left, right); + } + + /// Eventually: Fφ = ⊤ U φ + public static Ltl Eventually(Ltl phi) => Until(True(), phi); + + /// Globally: Gφ = ⊥ R φ + public static Ltl Globally(Ltl phi) => Release(False(), phi); + + #endregion + + #region Equality and Comparison + + public abstract bool Equals(Ltl other); + public override bool Equals(object obj) => Equals(obj as Ltl); + + public override int GetHashCode() + { + if (_hash == null) + _hash = ComputeHashCode(); + return _hash.Value; + } + + protected abstract int ComputeHashCode(); + + public int CompareTo(Ltl other) + { + if (other == null) return 1; + if (ReferenceEquals(this, other)) return 0; + int c = Kind.CompareTo(other.Kind); + if (c != 0) return c; + return CompareToSameKind(other); + } + + protected abstract int CompareToSameKind(Ltl other); + + public static bool operator ==(Ltl a, Ltl b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null) return false; + return a.Equals(b); + } + + public static bool operator !=(Ltl a, Ltl b) => !(a == b); + + #endregion + } + + /// Comparer for Ltl formulas, used in ACI normalization. + internal class LtlComparer : IComparer> + { + public static readonly LtlComparer Instance = new LtlComparer(); + public int Compare(Ltl x, Ltl y) => x.CompareTo(y); + } + + #region Node Types + + public sealed class LtlTrue : Ltl + { + public static readonly LtlTrue Instance = new LtlTrue(); + private LtlTrue() { } + internal override int Kind => 0; + public override bool Equals(Ltl other) => other is LtlTrue; + protected override int ComputeHashCode() => 0x7F7F7F7F; + protected override int CompareToSameKind(Ltl other) => 0; + public override string ToString() => "⊤"; + } + + public sealed class LtlFalse : Ltl + { + public static readonly LtlFalse Instance = new LtlFalse(); + private LtlFalse() { } + internal override int Kind => 1; + public override bool Equals(Ltl other) => other is LtlFalse; + protected override int ComputeHashCode() => 0x3F3F3F3F; + protected override int CompareToSameKind(Ltl other) => 0; + public override string ToString() => "⊥"; + } + + public sealed class LtlAtom : Ltl + { + /// + /// Construct an atom carrying . + /// Atoms are positive: negation is folded into the + /// predicate via the underlying EBA (see ). + /// + public LtlAtom(TPred predicate) + { + Predicate = predicate; + } + + public TPred Predicate { get; } + + internal override int Kind => 2; + + public override bool Equals(Ltl other) + { + if (other is LtlAtom atom) + return EqualityComparer.Default.Equals(Predicate, atom.Predicate); + return false; + } + + protected override int ComputeHashCode() + => EqualityComparer.Default.GetHashCode(Predicate); + + protected override int CompareToSameKind(Ltl other) + { + var atom = (LtlAtom)other; + return PredCompare.Compare(Predicate, atom.Predicate); + } + + public override string ToString() => Predicate.ToString(); + } + + public sealed class LtlNext : Ltl + { + public LtlNext(Ltl inner) + { + Inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public Ltl Inner { get; } + internal override int Kind => 3; + + public override bool Equals(Ltl other) + => other is LtlNext n && Inner.Equals(n.Inner); + + protected override int ComputeHashCode() + { + unchecked { return Inner.GetHashCode() * 31 + 3; } + } + + protected override int CompareToSameKind(Ltl other) + => Inner.CompareTo(((LtlNext)other).Inner); + + public override string ToString() => $"X({Inner})"; + } + + public sealed class LtlUntil : Ltl + { + public LtlUntil(Ltl left, Ltl right) + { + Left = left ?? throw new ArgumentNullException(nameof(left)); + Right = right ?? throw new ArgumentNullException(nameof(right)); + } + + public Ltl Left { get; } + public Ltl Right { get; } + internal override int Kind => 4; + + public override bool Equals(Ltl other) + => other is LtlUntil u && Left.Equals(u.Left) && Right.Equals(u.Right); + + protected override int ComputeHashCode() + { + unchecked { return (Left.GetHashCode() * 31 + Right.GetHashCode()) * 31 + 4; } + } + + protected override int CompareToSameKind(Ltl other) + { + var u = (LtlUntil)other; + int c = Left.CompareTo(u.Left); + return c != 0 ? c : Right.CompareTo(u.Right); + } + + public override string ToString() + => Left is LtlTrue ? $"F {Right}" : $"({Left} U {Right})"; + } + + public sealed class LtlRelease : Ltl + { + public LtlRelease(Ltl left, Ltl right) + { + Left = left ?? throw new ArgumentNullException(nameof(left)); + Right = right ?? throw new ArgumentNullException(nameof(right)); + } + + public Ltl Left { get; } + public Ltl Right { get; } + internal override int Kind => 5; + + public override bool Equals(Ltl other) + => other is LtlRelease r && Left.Equals(r.Left) && Right.Equals(r.Right); + + protected override int ComputeHashCode() + { + unchecked { return (Left.GetHashCode() * 31 + Right.GetHashCode()) * 31 + 5; } + } + + protected override int CompareToSameKind(Ltl other) + { + var r = (LtlRelease)other; + int c = Left.CompareTo(r.Left); + return c != 0 ? c : Right.CompareTo(r.Right); + } + + public override string ToString() + => Left is LtlFalse ? $"G {Right}" : $"({Left} R {Right})"; + } + + public sealed class LtlAnd : Ltl + { + internal LtlAnd(Ltl[] operands) + { + Operands = operands; + } + + /// Sorted, deduplicated operands. + public IReadOnlyList> Operands { get; } + internal override int Kind => 6; + + public override bool Equals(Ltl other) + { + if (!(other is LtlAnd and)) return false; + if (Operands.Count != and.Operands.Count) return false; + for (int i = 0; i < Operands.Count; i++) + if (!Operands[i].Equals(and.Operands[i])) return false; + return true; + } + + protected override int ComputeHashCode() + { + unchecked + { + int h = 6; + foreach (var op in Operands) h = h * 31 + op.GetHashCode(); + return h; + } + } + + protected override int CompareToSameKind(Ltl other) + { + var and = (LtlAnd)other; + int c = Operands.Count.CompareTo(and.Operands.Count); + if (c != 0) return c; + for (int i = 0; i < Operands.Count; i++) + { + c = Operands[i].CompareTo(and.Operands[i]); + if (c != 0) return c; + } + return 0; + } + + public override string ToString() => "(" + string.Join(" ∧ ", Operands) + ")"; + } + + public sealed class LtlOr : Ltl + { + internal LtlOr(Ltl[] operands) + { + Operands = operands; + } + + /// Sorted, deduplicated operands. + public IReadOnlyList> Operands { get; } + internal override int Kind => 7; + + public override bool Equals(Ltl other) + { + if (!(other is LtlOr or)) return false; + if (Operands.Count != or.Operands.Count) return false; + for (int i = 0; i < Operands.Count; i++) + if (!Operands[i].Equals(or.Operands[i])) return false; + return true; + } + + protected override int ComputeHashCode() + { + unchecked + { + int h = 7; + foreach (var op in Operands) h = h * 31 + op.GetHashCode(); + return h; + } + } + + protected override int CompareToSameKind(Ltl other) + { + var or = (LtlOr)other; + int c = Operands.Count.CompareTo(or.Operands.Count); + if (c != 0) return c; + for (int i = 0; i < Operands.Count; i++) + { + c = Operands[i].CompareTo(or.Operands[i]); + if (c != 0) return c; + } + return 0; + } + + public override string ToString() => "(" + string.Join(" ∨ ", Operands) + ")"; + } + + #endregion +} diff --git a/Accordant.ModelChecking/Symbolic/LtlAlgebra.cs b/Accordant.ModelChecking/Symbolic/LtlAlgebra.cs new file mode 100644 index 0000000..5e9b70c --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/LtlAlgebra.cs @@ -0,0 +1,237 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// EBA-aware factory for formulas. Pushes all + /// boolean combinations of atomic predicates into the underlying + /// so they become single + /// nodes carrying a fused predicate. + /// + /// Consequences: + /// + /// Not(LtlAtom(p)) = LtlAtom(eba.Not(p)) — atoms hold only + /// positive predicates; negation flows into the EBA. + /// And(LtlAtom(p), LtlAtom(q)) = LtlAtom(eba.And(p,q)) + /// (collapses to False when eba.IsSatisfiable says so; + /// in particular p ∧ ¬p ⇒ ⊥ via the EBA). + /// Or(LtlAtom(p), LtlAtom(q)) = LtlAtom(eba.Or(p,q)) + /// (collapses to True when eba.Not(eba.Or(p,q)) is + /// unsatisfiable; in particular p ∨ ¬p ⇒ ⊤). + /// + /// + /// All non-boolean structural factories (, + /// , ) are + /// re-exposed here as instance methods for uniformity. + /// + public sealed class LtlAlgebra + { + private readonly IPredicateAlgebra _eba; + + public LtlAlgebra(IPredicateAlgebra eba) + { + _eba = eba ?? throw new ArgumentNullException(nameof(eba)); + } + + /// The underlying predicate algebra. + public IPredicateAlgebra Eba => _eba; + + public Ltl True => LtlTrue.Instance; + public Ltl False => LtlFalse.Instance; + + /// + /// Builds an atom for , canonicalising + /// eba.Top/eba.Bottom into /. + /// + public Ltl Atom(TPred p) + { + if (p == null) throw new ArgumentNullException(nameof(p)); + if (EqualityComparer.Default.Equals(p, _eba.Top)) return True; + if (EqualityComparer.Default.Equals(p, _eba.Bottom)) return False; + return new LtlAtom(p); + } + + /// Negative atom: ¬p represented as Atom(eba.Not(p)). + public Ltl NegAtom(TPred p) => Atom(_eba.Not(p)); + + public Ltl Next(Ltl inner) => Ltl.Next(inner); + public Ltl Until(Ltl l, Ltl r) => Ltl.Until(l, r); + public Ltl Release(Ltl l, Ltl r) => Ltl.Release(l, r); + public Ltl Eventually(Ltl p) => Until(True, p); + public Ltl Globally(Ltl p) => Release(False, p); + + /// NNF negation that pushes complement into atomic predicates via the EBA. + public Ltl Not(Ltl f) + { + switch (f) + { + case LtlTrue _: return False; + case LtlFalse _: return True; + case LtlAtom a: return Atom(_eba.Not(a.Predicate)); + case LtlNext n: return Next(Not(n.Inner)); + case LtlUntil u: return Release(Not(u.Left), Not(u.Right)); + case LtlRelease r:return Until(Not(r.Left), Not(r.Right)); + case LtlAnd a: return OrMany(a.Operands.Select(Not)); + case LtlOr o: return AndMany(o.Operands.Select(Not)); + default: throw new ArgumentException($"Unknown formula type: {f.GetType()}"); + } + } + + public Ltl Implies(Ltl a, Ltl b) => Or(Not(a), b); + + public Ltl And(Ltl a, Ltl b) + { + if (a is LtlFalse || b is LtlFalse) return False; + if (a is LtlTrue) return b; + if (b is LtlTrue) return a; + + var operands = new SortedSet>(LtlComparer.Instance); + CollectAnd(a, operands); + CollectAnd(b, operands); + return FuseAndAtoms(operands); + } + + public Ltl Or(Ltl a, Ltl b) + { + if (a is LtlTrue || b is LtlTrue) return True; + if (a is LtlFalse) return b; + if (b is LtlFalse) return a; + + var operands = new SortedSet>(LtlComparer.Instance); + CollectOr(a, operands); + CollectOr(b, operands); + return FuseOrAtoms(operands); + } + + public Ltl And(params Ltl[] formulas) + => formulas.Aggregate(True, And); + + public Ltl Or(params Ltl[] formulas) + => formulas.Aggregate(False, Or); + + private Ltl AndMany(IEnumerable> ops) + => ops.Aggregate(True, And); + + private Ltl OrMany(IEnumerable> ops) + => ops.Aggregate(False, Or); + + private static void CollectAnd(Ltl f, SortedSet> s) + { + if (f is LtlAnd a) foreach (var op in a.Operands) s.Add(op); + else s.Add(f); + } + + private static void CollectOr(Ltl f, SortedSet> s) + { + if (f is LtlOr o) foreach (var op in o.Operands) s.Add(op); + else s.Add(f); + } + + /// + /// Fold all operands of an And into a + /// single atom via eba.And. If the fused predicate is + /// unsatisfiable, short-circuits to . + /// + private Ltl FuseAndAtoms(SortedSet> operands) + { + TPred fused = default; + bool hasAtom = false; + var nonAtoms = new List>(); + foreach (var op in operands) + { + if (op is LtlAtom atom) + { + fused = hasAtom ? _eba.And(fused, atom.Predicate) : atom.Predicate; + hasAtom = true; + } + else + { + nonAtoms.Add(op); + } + } + + if (hasAtom) + { + if (!_eba.IsSatisfiable(fused)) return False; + var fusedAtom = Atom(fused); + if (fusedAtom is LtlFalse) return False; + if (fusedAtom is LtlTrue) + { + if (nonAtoms.Count == 0) return True; + if (nonAtoms.Count == 1) return nonAtoms[0]; + return new LtlAnd(nonAtoms.ToArray()); + } + nonAtoms.Add(fusedAtom); + } + + if (nonAtoms.Count == 0) return True; + if (nonAtoms.Count == 1) return nonAtoms[0]; + var sorted = new SortedSet>(nonAtoms, LtlComparer.Instance); + return new LtlAnd(sorted.ToArray()); + } + + /// + /// Fold all operands of an Or into a + /// single atom via eba.Or. If the fused predicate is a + /// tautology (its complement is unsatisfiable), short-circuits to + /// . + /// + private Ltl FuseOrAtoms(SortedSet> operands) + { + TPred fused = default; + bool hasAtom = false; + var nonAtoms = new List>(); + foreach (var op in operands) + { + if (op is LtlAtom atom) + { + fused = hasAtom ? _eba.Or(fused, atom.Predicate) : atom.Predicate; + hasAtom = true; + } + else + { + nonAtoms.Add(op); + } + } + + if (hasAtom) + { + if (!_eba.IsSatisfiable(_eba.Not(fused))) return True; + var fusedAtom = Atom(fused); + if (fusedAtom is LtlTrue) return True; + if (fusedAtom is LtlFalse) + { + if (nonAtoms.Count == 0) return False; + if (nonAtoms.Count == 1) return nonAtoms[0]; + return new LtlOr(nonAtoms.ToArray()); + } + nonAtoms.Add(fusedAtom); + } + + if (nonAtoms.Count == 0) return False; + if (nonAtoms.Count == 1) return nonAtoms[0]; + var sorted = new SortedSet>(nonAtoms, LtlComparer.Instance); + return new LtlOr(sorted.ToArray()); + } + } + + /// + /// Convenience access to a default for + /// the common case of predicates over + /// model-program states. + /// + public static class LtlAlgebra + { + /// + /// Default LTL algebra over . The + /// underlying EBA is resolved on each access through + /// so that backends + /// registered after type-load (e.g. via module initializers) take + /// effect. + /// + public static LtlAlgebra Default + => new LtlAlgebra(StatePropEbaProvider.Default); + } +} diff --git a/Accordant.ModelChecking/Symbolic/LtlDerivative.cs b/Accordant.ModelChecking/Symbolic/LtlDerivative.cs new file mode 100644 index 0000000..ecf124c --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/LtlDerivative.cs @@ -0,0 +1,167 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Computes symbolic derivatives of formulas and + /// constructs a + /// whose states are LTL formulas. + /// + /// The symbolic derivative ∂ maps an LTL formula to a transition term + /// TTerm⟨A, B⁺(Ltl⟨A⟩)⟩: + /// + /// ∂(⊤) = ⊤ (leaf: Dnf.True) + /// ∂(⊥) = ⊥ (leaf: Dnf.False) + /// ∂(p) = ITE(p, ⊤, ⊥) + /// ∂(¬p) = ITE(p, ⊥, ⊤) + /// ∂(Xφ) = atom(φ) (next state is φ) + /// ∂(φ U ψ) = ∂(ψ) ∨ (∂(φ) ∧ atom(φ U ψ)) + /// ∂(φ R ψ) = (∂(ψ) ∧ atom(φ R ψ)) ∨ (∂(φ) ∧ ∂(ψ)) + /// ∂(φ ∧ ψ) = ∂(φ) ∧ ∂(ψ) + /// ∂(φ ∨ ψ) = ∂(φ) ∨ ∂(ψ) + /// + /// + /// Accepting states for the ABW: a formula is accepting iff it is NOT + /// an Until formula (Until formulas represent unfulfilled obligations). + /// + public class LtlDerivative + { + private readonly IEffectiveBooleanAlgebra _eba; + private readonly ConditionRegistry _registry; + private readonly DnfAlgebra> _dnfAlgebra; + private readonly TransitionTermAlgebra>> _termAlgebra; + private readonly Dictionary, TransitionTerm>>> _derivCache; + + public LtlDerivative( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry) + { + _eba = eba ?? throw new ArgumentNullException(nameof(eba)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _dnfAlgebra = new DnfAlgebra>(LtlComparer.Instance); + _termAlgebra = new TransitionTermAlgebra>>( + eba, registry, _dnfAlgebra); + _derivCache = new Dictionary, TransitionTerm>>>(); + } + + /// The EBA over predicates. + public IEffectiveBooleanAlgebra Eba => _eba; + + /// The condition registry. + public ConditionRegistry Registry => _registry; + + /// The B⁺(Ltl) leaf algebra. + public DnfAlgebra> DnfAlgebra => _dnfAlgebra; + + /// The transition term algebra for TTerm⟨A, B⁺(Ltl)⟩. + public TransitionTermAlgebra>> TermAlgebra => _termAlgebra; + + /// + /// Computes the symbolic derivative of an LTL formula. + /// Returns a transition term TTerm⟨A, B⁺(Ltl⟨A⟩)⟩. + /// Memoised: identical subformulas share computed transition terms. + /// + public TransitionTerm>> Derivative(Ltl formula) + { + if (_derivCache.TryGetValue(formula, out var cached)) + return cached; + var result = DerivativeUncached(formula); + _derivCache[formula] = result; + return result; + } + + /// Number of distinct subformulas whose derivative is cached. + public int DerivativeCacheSize => _derivCache.Count; + + private TransitionTerm>> DerivativeUncached(Ltl formula) + { + switch (formula) + { + case LtlTrue _: + return _termAlgebra.Top; + + case LtlFalse _: + return _termAlgebra.Bottom; + + case LtlAtom atom: + { + int condIdx = _registry.Register(atom.Predicate); + // ∂(p) = ITE(p, ⊤, ⊥). Negation was pushed into the EBA at + // formula-construction time, so atoms only carry positive + // predicates here. + return _termAlgebra.MkIte(condIdx, _termAlgebra.Top, _termAlgebra.Bottom); + } + + case LtlNext next: + // ∂(Xφ) = atom(φ) + return TransitionTerm>>.Leaf( + _dnfAlgebra.Atom(next.Inner)); + + case LtlUntil until: + { + // ∂(φ U ψ) = ∂(ψ) ∨ (∂(φ) ∧ atom(φ U ψ)) + var dPhi = Derivative(until.Left); + var dPsi = Derivative(until.Right); + var selfAtom = TransitionTerm>>.Leaf( + _dnfAlgebra.Atom(formula)); + var cont = _termAlgebra.And(dPhi, selfAtom); + return _termAlgebra.Or(dPsi, cont); + } + + case LtlRelease release: + { + // ∂(φ R ψ) = (∂(ψ) ∧ atom(φ R ψ)) ∨ (∂(φ) ∧ ∂(ψ)) + var dPhi = Derivative(release.Left); + var dPsi = Derivative(release.Right); + var selfAtom = TransitionTerm>>.Leaf( + _dnfAlgebra.Atom(formula)); + var cont = _termAlgebra.And(dPsi, selfAtom); + var done = _termAlgebra.And(dPhi, dPsi); + return _termAlgebra.Or(cont, done); + } + + case LtlAnd and: + { + var result = Derivative(and.Operands[0]); + for (int i = 1; i < and.Operands.Count; i++) + result = _termAlgebra.And(result, Derivative(and.Operands[i])); + return result; + } + + case LtlOr or: + { + var result = Derivative(or.Operands[0]); + for (int i = 1; i < or.Operands.Count; i++) + result = _termAlgebra.Or(result, Derivative(or.Operands[i])); + return result; + } + + default: + throw new ArgumentException($"Unknown formula type: {formula.GetType()}"); + } + } + + /// + /// Determines if an LTL formula is an accepting state in the ABW. + /// A formula is accepting iff it is NOT an Until formula. + /// (Until represents an unfulfilled obligation that must eventually be satisfied.) + /// + public static bool IsAccepting(Ltl formula) + => !(formula is LtlUntil); + + /// + /// Constructs a symbolic ABW from an LTL formula. + /// The initial state is the formula itself, and transitions are + /// computed lazily via . + /// + public SymbolicABW> ToABW(Ltl formula) + { + return new SymbolicABW>( + _eba, _registry, _dnfAlgebra, + formula, + IsAccepting, + Derivative); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/LtlJson.cs b/Accordant.ModelChecking/Symbolic/LtlJson.cs new file mode 100644 index 0000000..f216b4b --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/LtlJson.cs @@ -0,0 +1,432 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// + /// JSON serialization for formulas where + /// predicates are strings (proposition names). + /// + /// Format: + /// + /// true → { "op": "True" } + /// false → { "op": "False" } + /// atom p → { "op": "Atom", "pred": "p" } + /// negated atom ¬p → { "op": "Atom", "pred": "p", "neg": true } + /// X φ → { "op": "Next", "inner": ... } + /// φ U ψ → { "op": "Until", "left": ..., "right": ... } + /// φ R ψ → { "op": "Release", "left": ..., "right": ... } + /// φ ∧ ψ ∧ ... → { "op": "And", "args": [...] } + /// φ ∨ ψ ∨ ... → { "op": "Or", "args": [...] } + /// + /// + /// Sugar forms (deserialization only accepts canonical, but these + /// produce the correct formula): + /// + /// F φ → { "op": "Eventually", "inner": ... } → ⊤ U φ + /// G φ → { "op": "Globally", "inner": ... } → ⊥ R φ + /// φ → ψ → { "op": "Implies", "left": ..., "right": ... } → ¬φ ∨ ψ + /// + /// + public static class LtlJson + { + private static readonly LtlAlgebra StringAlgebra = + new LtlAlgebra(StringFreeAlgebra.Instance); + + #region Serialization + + /// + /// Serializes an LTL formula to JSON string. + /// + public static string Serialize(Ltl formula) + { + if (formula == null) throw new ArgumentNullException(nameof(formula)); + var sb = new StringBuilder(); + SerializeCore(formula, sb, 0); + return sb.ToString(); + } + + /// + /// Serializes with indentation for readability. + /// + public static string SerializePretty(Ltl formula) + { + if (formula == null) throw new ArgumentNullException(nameof(formula)); + var sb = new StringBuilder(); + SerializeCore(formula, sb, 0, pretty: true, indent: 0); + return sb.ToString(); + } + + private static void SerializeCore(Ltl formula, StringBuilder sb, int depth, + bool pretty = false, int indent = 0) + { + if (depth > 1000) + throw new InvalidOperationException("Formula nesting too deep (>1000)."); + + switch (formula) + { + case LtlTrue _: + sb.Append("{\"op\":\"True\"}"); + break; + + case LtlFalse _: + sb.Append("{\"op\":\"False\"}"); + break; + + case LtlAtom atom: + sb.Append("{\"op\":\"Atom\",\"pred\":"); + AppendJsonString(sb, atom.Predicate); + sb.Append('}'); + break; + + case LtlNext next: + sb.Append("{\"op\":\"Next\",\"inner\":"); + SerializeCore(next.Inner, sb, depth + 1, pretty, indent + 1); + sb.Append('}'); + break; + + case LtlUntil until: + sb.Append("{\"op\":\"Until\",\"left\":"); + SerializeCore(until.Left, sb, depth + 1, pretty, indent + 1); + sb.Append(",\"right\":"); + SerializeCore(until.Right, sb, depth + 1, pretty, indent + 1); + sb.Append('}'); + break; + + case LtlRelease release: + sb.Append("{\"op\":\"Release\",\"left\":"); + SerializeCore(release.Left, sb, depth + 1, pretty, indent + 1); + sb.Append(",\"right\":"); + SerializeCore(release.Right, sb, depth + 1, pretty, indent + 1); + sb.Append('}'); + break; + + case LtlAnd and: + sb.Append("{\"op\":\"And\",\"args\":["); + for (int i = 0; i < and.Operands.Count; i++) + { + if (i > 0) sb.Append(','); + SerializeCore(and.Operands[i], sb, depth + 1, pretty, indent + 1); + } + sb.Append("]}"); + break; + + case LtlOr or: + sb.Append("{\"op\":\"Or\",\"args\":["); + for (int i = 0; i < or.Operands.Count; i++) + { + if (i > 0) sb.Append(','); + SerializeCore(or.Operands[i], sb, depth + 1, pretty, indent + 1); + } + sb.Append("]}"); + break; + + default: + throw new ArgumentException($"Unknown formula type: {formula.GetType()}"); + } + } + + private static void AppendJsonString(StringBuilder sb, string s) + { + sb.Append('"'); + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: sb.Append(c); break; + } + } + sb.Append('"'); + } + + #endregion + + #region Deserialization + + /// + /// Deserializes a JSON string to an LTL formula. + /// Supports both canonical forms and sugar (Eventually, Globally, Implies). + /// + public static Ltl Deserialize(string json) + { + if (json == null) throw new ArgumentNullException(nameof(json)); + int pos = 0; + var result = ParseFormula(json, ref pos); + return result; + } + + private static Ltl ParseFormula(string json, ref int pos) + { + SkipWhitespace(json, ref pos); + Expect(json, ref pos, '{'); + SkipWhitespace(json, ref pos); + + var fields = ParseFields(json, ref pos); + + if (!fields.TryGetValue("op", out var op)) + throw new FormatException("Missing 'op' field in LTL JSON."); + + switch (op) + { + case "True": + return Ltl.True(); + + case "False": + return Ltl.False(); + + case "Atom": + { + if (!fields.TryGetValue("pred", out var pred)) + throw new FormatException("Atom missing 'pred' field."); + bool neg = fields.TryGetValue("neg", out var negVal) && negVal == "true"; + return neg ? StringAlgebra.NegAtom(pred) : StringAlgebra.Atom(pred); + } + + case "Next": + { + if (!fields.TryGetValue("inner", out var innerJson)) + throw new FormatException("Next missing 'inner' field."); + int p = 0; + return Ltl.Next(ParseFormula(innerJson, ref p)); + } + + case "Until": + { + var (left, right) = ParseBinary(fields, "Until"); + return Ltl.Until(left, right); + } + + case "Release": + { + var (left, right) = ParseBinary(fields, "Release"); + return Ltl.Release(left, right); + } + + case "And": + { + var args = ParseArgs(fields, "And"); + Ltl result = args[0]; + for (int i = 1; i < args.Count; i++) + result = StringAlgebra.And(result, args[i]); + return result; + } + + case "Or": + { + var args = ParseArgs(fields, "Or"); + Ltl result = args[0]; + for (int i = 1; i < args.Count; i++) + result = StringAlgebra.Or(result, args[i]); + return result; + } + + // Sugar forms + case "Eventually": + { + if (!fields.TryGetValue("inner", out var innerJson)) + throw new FormatException("Eventually missing 'inner' field."); + int p = 0; + return Ltl.Eventually(ParseFormula(innerJson, ref p)); + } + + case "Globally": + { + if (!fields.TryGetValue("inner", out var innerJson)) + throw new FormatException("Globally missing 'inner' field."); + int p = 0; + return Ltl.Globally(ParseFormula(innerJson, ref p)); + } + + case "Implies": + { + var (left, right) = ParseBinary(fields, "Implies"); + return StringAlgebra.Implies(left, right); + } + + default: + throw new FormatException($"Unknown op: '{op}'."); + } + } + + private static (Ltl, Ltl) ParseBinary( + Dictionary fields, string opName) + { + if (!fields.TryGetValue("left", out var leftJson)) + throw new FormatException($"{opName} missing 'left' field."); + if (!fields.TryGetValue("right", out var rightJson)) + throw new FormatException($"{opName} missing 'right' field."); + int p1 = 0, p2 = 0; + return (ParseFormula(leftJson, ref p1), ParseFormula(rightJson, ref p2)); + } + + private static List> ParseArgs( + Dictionary fields, string opName) + { + if (!fields.TryGetValue("args", out var argsJson)) + throw new FormatException($"{opName} missing 'args' field."); + var result = new List>(); + int pos = 0; + SkipWhitespace(argsJson, ref pos); + Expect(argsJson, ref pos, '['); + SkipWhitespace(argsJson, ref pos); + if (pos < argsJson.Length && argsJson[pos] != ']') + { + result.Add(ParseFormula(argsJson, ref pos)); + SkipWhitespace(argsJson, ref pos); + while (pos < argsJson.Length && argsJson[pos] == ',') + { + pos++; + SkipWhitespace(argsJson, ref pos); + result.Add(ParseFormula(argsJson, ref pos)); + SkipWhitespace(argsJson, ref pos); + } + } + if (result.Count == 0) + throw new FormatException($"{opName} must have at least one argument."); + return result; + } + + /// + /// Simple JSON object field parser. Returns field name → raw JSON value string. + /// Handles nested objects/arrays by tracking brace/bracket depth. + /// + private static Dictionary ParseFields(string json, ref int pos) + { + var fields = new Dictionary(); + SkipWhitespace(json, ref pos); + + while (pos < json.Length && json[pos] != '}') + { + if (json[pos] == ',') { pos++; SkipWhitespace(json, ref pos); continue; } + + // Parse field name + var name = ParseJsonString(json, ref pos); + SkipWhitespace(json, ref pos); + Expect(json, ref pos, ':'); + SkipWhitespace(json, ref pos); + + // Parse field value (capture raw JSON) + var value = CaptureValue(json, ref pos); + fields[name] = value; + SkipWhitespace(json, ref pos); + } + + if (pos < json.Length) pos++; // consume '}' + return fields; + } + + private static string CaptureValue(string json, ref int pos) + { + SkipWhitespace(json, ref pos); + char c = json[pos]; + + if (c == '"') + { + return ParseJsonString(json, ref pos); + } + else if (c == '{' || c == '[') + { + int start = pos; + int depth = 0; + bool inString = false; + while (pos < json.Length) + { + char ch = json[pos]; + if (inString) + { + if (ch == '\\') { pos++; } // skip escaped char + else if (ch == '"') { inString = false; } + } + else + { + if (ch == '"') inString = true; + else if (ch == '{' || ch == '[') depth++; + else if (ch == '}' || ch == ']') + { + depth--; + if (depth == 0) { pos++; break; } + } + } + pos++; + } + return json.Substring(start, pos - start); + } + else if (c == 't' || c == 'f') + { + // true or false + int start = pos; + while (pos < json.Length && char.IsLetter(json[pos])) pos++; + return json.Substring(start, pos - start); + } + else if (c == 'n') + { + // null + int start = pos; + while (pos < json.Length && char.IsLetter(json[pos])) pos++; + return json.Substring(start, pos - start); + } + else if (char.IsDigit(c) || c == '-') + { + int start = pos; + while (pos < json.Length && (char.IsDigit(json[pos]) || json[pos] == '.' || json[pos] == '-' || json[pos] == 'e' || json[pos] == 'E')) + pos++; + return json.Substring(start, pos - start); + } + else + { + throw new FormatException($"Unexpected character '{c}' at position {pos}."); + } + } + + private static string ParseJsonString(string json, ref int pos) + { + Expect(json, ref pos, '"'); + var sb = new StringBuilder(); + while (pos < json.Length && json[pos] != '"') + { + if (json[pos] == '\\') + { + pos++; + switch (json[pos]) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case '/': sb.Append('/'); break; + default: sb.Append(json[pos]); break; + } + } + else + { + sb.Append(json[pos]); + } + pos++; + } + Expect(json, ref pos, '"'); + return sb.ToString(); + } + + private static void SkipWhitespace(string json, ref int pos) + { + while (pos < json.Length && char.IsWhiteSpace(json[pos])) pos++; + } + + private static void Expect(string json, ref int pos, char expected) + { + if (pos >= json.Length || json[pos] != expected) + throw new FormatException( + $"Expected '{expected}' at position {pos}, got '{(pos < json.Length ? json[pos].ToString() : "EOF")}'."); + pos++; + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Symbolic/MacroReduction.cs b/Accordant.ModelChecking/Symbolic/MacroReduction.cs new file mode 100644 index 0000000..7c95241 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/MacroReduction.cs @@ -0,0 +1,44 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System.Collections.Generic; + + /// + /// Result of a macrostate reduction: the reduced macrostate together + /// with a representative map that tells callers (notably + /// ) how to + /// rewrite an obligation set O ⊆ S into the reduced + /// O' ⊆ S'. + /// + /// ABW state type. + public readonly struct MacroReduction + { + /// The reduced macrostate. + public readonly StateSet ReducedS; + + /// + /// Maps each dropped state to its surviving representative in + /// . Survivor states are typically NOT + /// listed in this map (treated as identity); the + /// helper handles both cases uniformly. + /// May be null when no state was dropped. + /// + public readonly IReadOnlyDictionary RepMap; + + public MacroReduction(StateSet reducedS, + IReadOnlyDictionary repMap = null) + { + ReducedS = reducedS; + RepMap = repMap; + } + + /// Returns the representative of + /// in , or itself + /// when it survived. + public TState RepOf(TState q) + => RepMap != null && RepMap.TryGetValue(q, out var r) ? r : q; + + /// Identity reduction (no states dropped). + public static MacroReduction Identity(StateSet s) + => new MacroReduction(s, null); + } +} diff --git a/Accordant.ModelChecking/Symbolic/NbwAeProduct.cs b/Accordant.ModelChecking/Symbolic/NbwAeProduct.cs new file mode 100644 index 0000000..158a15d --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/NbwAeProduct.cs @@ -0,0 +1,267 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Discriminated-union state type used by the Æ-based NBW product + /// (Section 5.3 of the JACM paper, "Symbolic Automata: Omega-Regularity + /// Modulo Theories"). A state is either tagged Left (from N₁) or + /// Right (from N₂); the two underlying state spaces remain disjoint. + /// + public sealed class EitherState : IEquatable> + { + public bool IsLeft { get; } + public TLeft Left { get; } + public TRight Right { get; } + + private EitherState(bool isLeft, TLeft left, TRight right) + { + IsLeft = isLeft; Left = left; Right = right; + } + + public static EitherState FromLeft(TLeft l) + => new EitherState(true, l, default); + public static EitherState FromRight(TRight r) + => new EitherState(false, default, r); + + public bool Equals(EitherState other) + { + if (ReferenceEquals(other, null)) return false; + if (IsLeft != other.IsLeft) return false; + return IsLeft + ? EqualityComparer.Default.Equals(Left, other.Left) + : EqualityComparer.Default.Equals(Right, other.Right); + } + + public override bool Equals(object obj) => Equals(obj as EitherState); + + public override int GetHashCode() + { + unchecked + { + return IsLeft + ? EqualityComparer.Default.GetHashCode(Left) * 31 + 1 + : EqualityComparer.Default.GetHashCode(Right) * 31 + 2; + } + } + + public override string ToString() => IsLeft ? $"L({Left})" : $"R({Right})"; + + public static IComparer> GetComparer( + IComparer leftCmp, IComparer rightCmp) + => new Comparer(leftCmp, rightCmp); + + private sealed class Comparer : IComparer> + { + private readonly IComparer _l; + private readonly IComparer _r; + public Comparer(IComparer l, IComparer r) { _l = l; _r = r; } + public int Compare(EitherState x, EitherState y) + { + if (ReferenceEquals(x, y)) return 0; + if (x == null) return -1; + if (y == null) return 1; + if (x.IsLeft && !y.IsLeft) return -1; + if (!x.IsLeft && y.IsLeft) return 1; + return x.IsLeft ? _l.Compare(x.Left, y.Left) : _r.Compare(x.Right, y.Right); + } + } + } + + /// + /// NBW product modulo theories via alternation elimination + /// (Section 5.3 of the JACM paper "Symbolic Automata: Omega-Regularity + /// Modulo Theories"): + /// + /// N₁ × N₂ ≝ AElim(N₁ ∧ N₂) + /// + /// where N₁ ∧ N₂ is the alternating Büchi automaton whose state + /// space is the disjoint union Q₁ ⊎ Q₂, initial formula + /// φ₀ = φ₀^N₁ ∧ φ₀^N₂ (conjunction in B⁺(Q)), transition function + /// δ dispatches by tag, and accepting set F = F₁ ∪ F₂. + /// + /// By Corollary 5.x in the paper, the resulting NBW has at most + /// 4·|Q₁|·|Q₂| breakpoint states, in only four shapes: + /// ⟨{q₁,q₂},∅⟩, ⟨∅,{q₁,q₂}⟩, ⟨{q₁},{q₂}⟩, + /// ⟨{q₂},{q₁}⟩. The alternation product on transition terms + /// runs in O(|N₁|·|N₂|) SAT calls (vs the + /// O(2^{|N₁|+|N₂|}) bound required by classical NBW + /// intersection if minterm-based bitblasting is needed for a common + /// finite alphabet). + /// + /// Unlike (which uses the classical + /// Büchi flag-trick over Q₁ × Q₂ × {0,1,2}), this construction + /// stays in the symbolic world end-to-end: it neither materialises a + /// minterm alphabet nor explodes the state space combinatorially. + /// + public static class NbwAeProduct + { + /// + /// Lifts an NBW into an ABW with the same state type. Each NBW + /// transition list [tt₁, …, ttₙ] becomes the disjunction + /// tt₁ ∨ … ∨ ttₙ as a single ABW transition term whose + /// leaves are disjunctions of singleton + /// clauses: a leaf StateSet {r₁,…,rₖ} becomes + /// {{r₁},…,{rₖ}}. The initial formula is the disjunction + /// of singleton clauses for each initial state. + /// + public static SymbolicABW NbwToAbw( + SymbolicNBW nbw, + IComparer stateOrd, + IEqualityComparer stateEq = null) + { + if (nbw == null) throw new ArgumentNullException(nameof(nbw)); + if (stateOrd == null) throw new ArgumentNullException(nameof(stateOrd)); + + var dnfAlg = new DnfAlgebra(stateOrd); + var ssAlg = new StateSetLeafAlgebra(stateOrd); + var srcTermAlg = new TransitionTermAlgebra>( + nbw.Eba, nbw.Registry, ssAlg); + var dstTermAlg = new TransitionTermAlgebra>( + nbw.Eba, nbw.Registry, dnfAlg); + + Dnf LeafToDnf(StateSet set) + { + if (set.IsEmpty) return dnfAlg.Bottom; + var clauses = new List>(set.Count); + foreach (var q in set) + clauses.Add(StateSet.Singleton(q, stateOrd)); + return dnfAlg.FromClauses(clauses); + } + + // Initial: φ₀ = ⋁ s∈I {{s}} + Dnf initial = nbw.InitialStates.Count == 0 + ? dnfAlg.Bottom + : dnfAlg.FromClauses(nbw.InitialStates + .Select(s => StateSet.Singleton(s, stateOrd))); + + TransitionTerm> Delta(TState s) + { + var list = nbw.GetTransition(s); + var acc = dstTermAlg.Bottom; + foreach (var tt in list) + { + var mapped = srcTermAlg.MapUnary>(tt, LeafToDnf); + acc = dstTermAlg.Or(acc, mapped); + } + return acc; + } + + return new SymbolicABW( + nbw.Eba, nbw.Registry, dnfAlg, initial, nbw.IsAccepting, Delta, stateEq); + } + + /// + /// Conjoins two compatible ABWs (sharing the same EBA / condition + /// registry) into a single ABW over . + /// Initial formula is the B⁺(Q) conjunction + /// φ₀^A ∧ φ₀^B (distributed into DNF). + /// Transitions dispatch by tag; F = F₁ ∪ F₂. + /// + public static SymbolicABW> ConjoinAbw( + SymbolicABW a, + SymbolicABW b, + IComparer ordL, + IComparer ordR) + { + if (a == null) throw new ArgumentNullException(nameof(a)); + if (b == null) throw new ArgumentNullException(nameof(b)); + if (!ReferenceEquals(a.Registry, b.Registry)) + throw new ArgumentException( + "Conjoined ABWs must share the same ConditionRegistry."); + + var eitherOrd = EitherState.GetComparer(ordL, ordR); + var eitherEq = EqualityComparer>.Default; + var dnfAlg = new DnfAlgebra>(eitherOrd); + var dstTermAlg = new TransitionTermAlgebra>>( + a.Eba, a.Registry, dnfAlg); + + // Helpers: map a Dnf / Dnf leaf into the tagged DnfAlgebra. + Dnf> EmbedL(Dnf d) + { + if (d.IsFalse) return dnfAlg.Bottom; + if (d.IsTrue) return dnfAlg.Top; + var clauses = new List>>(d.Clauses.Count); + foreach (var clause in d.Clauses) + { + var lifted = new List>(clause.Count); + foreach (var s in clause) lifted.Add(EitherState.FromLeft(s)); + lifted.Sort(eitherOrd); + clauses.Add(new StateSet>(lifted, eitherOrd)); + } + return dnfAlg.FromClauses(clauses); + } + Dnf> EmbedR(Dnf d) + { + if (d.IsFalse) return dnfAlg.Bottom; + if (d.IsTrue) return dnfAlg.Top; + var clauses = new List>>(d.Clauses.Count); + foreach (var clause in d.Clauses) + { + var lifted = new List>(clause.Count); + foreach (var s in clause) lifted.Add(EitherState.FromRight(s)); + lifted.Sort(eitherOrd); + clauses.Add(new StateSet>(lifted, eitherOrd)); + } + return dnfAlg.FromClauses(clauses); + } + + // Source-side term algebras for cross-type MapUnary. + var srcAlgL = new TransitionTermAlgebra>( + a.Eba, a.Registry, a.DnfAlgebra); + var srcAlgR = new TransitionTermAlgebra>( + b.Eba, b.Registry, b.DnfAlgebra); + + // Initial: φ₀^A ∧ φ₀^B (distributed into DNF over EitherState). + Dnf> initial = dnfAlg.And( + EmbedL(a.InitialState), EmbedR(b.InitialState)); + + TransitionTerm>> Delta(EitherState s) + { + if (s.IsLeft) + { + var srcTt = a.GetTransition(s.Left); + return srcAlgL.MapUnary>>(srcTt, EmbedL); + } + else + { + var srcTt = b.GetTransition(s.Right); + return srcAlgR.MapUnary>>(srcTt, EmbedR); + } + } + + bool IsAccepting(EitherState s) + => s.IsLeft ? a.IsAccepting(s.Left) : b.IsAccepting(s.Right); + + return new SymbolicABW>( + a.Eba, a.Registry, dnfAlg, initial, IsAccepting, Delta, eitherEq); + } + + /// + /// Æ-based product of two compatible symbolic NBWs: + /// N₁ × N₂ = AElim(NbwToAbw(N₁) ∧ NbwToAbw(N₂)). + /// Returns a lazy NBW over + /// of . The construction is purely + /// on-demand: only breakpoints visited by the caller (e.g., + /// / ) + /// are expanded. + /// + public static SymbolicNBW>> + Product( + SymbolicNBW n1, + SymbolicNBW n2, + IComparer ordL, + IComparer ordR, + IEqualityComparer eqL = null, + IEqualityComparer eqR = null) + { + var abw1 = NbwToAbw(n1, ordL, eqL); + var abw2 = NbwToAbw(n2, ordR, eqR); + var abwConj = ConjoinAbw(abw1, abw2, ordL, ordR); + var ae = new IncrementalAE>(abwConj); + return ae.ToNBW(); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/NbwProduct.cs b/Accordant.ModelChecking/Symbolic/NbwProduct.cs new file mode 100644 index 0000000..0e5c4e0 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/NbwProduct.cs @@ -0,0 +1,306 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Minimal leaf algebra for StateSet leaves in transition terms. + /// Used when constructing TransitionTermAlgebra instances for NBW operations. + /// Bottom = empty set, Top throws (not applicable for NBW transitions). + /// Or = union, And = intersection (for path merging), Not = not supported. + /// + internal sealed class StateSetLeafAlgebra : ILeafAlgebra> + { + private readonly IComparer _comparer; + private readonly StateSet _bottom; + + public StateSetLeafAlgebra(IComparer comparer) + { + _comparer = comparer; + _bottom = StateSet.Empty(comparer); + } + + public StateSet Top => throw new NotSupportedException( + "Top is not defined for StateSet leaf algebra in NBW context."); + + public StateSet Bottom => _bottom; + + public StateSet Or(StateSet a, StateSet b) => a.Union(b); + + public StateSet And(StateSet a, StateSet b) => a.Intersect(b); + + public StateSet Not(StateSet a) => throw new NotSupportedException( + "Negation is not defined for StateSet leaf algebra."); + + public StateSet Xor(StateSet a, StateSet b) => throw new NotSupportedException( + "XOR is not defined for StateSet leaf algebra (requires negation)."); + + public bool IsBottom(StateSet a) => a.IsEmpty; + + public bool IsTop(StateSet a) => false; + + public IEqualityComparer> Comparer => EqualityComparer>.Default; + } + + /// + /// Product (intersection) of two Symbolic NBWs. + /// + /// Given NBW₁ = (Q₁, I₁, δ₁, F₁) and NBW₂ = (Q₂, I₂, δ₂, F₂) over the same EBA, + /// produces NBW = (Q₁ × Q₂ × {0,1,2}, I₁ × I₂ × {0}, δ, F) where: + /// + /// The flag tracks acceptance progress: + /// 0 → waiting for F₁ acceptance (transitions to 1 when q₁' ∈ F₁) + /// 1 → waiting for F₂ acceptance (transitions to 2 when q₂' ∈ F₂) + /// 2 → accepting state (immediately resets to 0) + /// + /// F = Q₁ × Q₂ × {2} (states with flag=2 are accepting) + /// + /// This is the standard index-based construction for Büchi intersection, + /// lifted to the symbolic setting where transitions are TTerm⟨A, StateSet⟩. + /// + /// Per the paper (Section 5): the product of two NBWs is a special case + /// of the Æ algorithm, but this direct construction avoids the overhead + /// of converting to ABW and applying full Miyano-Hayashi. + /// + public static class NbwProduct + { + /// + /// Computes the product (intersection) of two symbolic NBWs. + /// Both NBWs must share the same EBA and ConditionRegistry. + /// + public static SymbolicNBW> + Intersect( + SymbolicNBW nbw1, + SymbolicNBW nbw2, + IComparer comparer1 = null, + IComparer comparer2 = null) + { + comparer1 = comparer1 ?? Comparer.Default; + comparer2 = comparer2 ?? Comparer.Default; + var productComparer = new ProductStateComparer(comparer1, comparer2); + var productEqComparer = new ProductStateEqualityComparer( + EqualityComparer.Default, EqualityComparer.Default); + + // Algebra for the left NBW's transition terms (needed for ApplyCross) + var algebra1 = new TransitionTermAlgebra>( + nbw1.Eba, nbw1.Registry, new StateSetLeafAlgebra(comparer1)); + + // Initial states: I₁ × I₂ × {initial flag} + var initialStates = new List>(); + foreach (var q1 in nbw1.InitialStates) + { + foreach (var q2 in nbw2.InitialStates) + { + // Start at flag 0; if q1 ∈ F₁ start at 1; if also q2 ∈ F₂ start at 2 + int flag = 0; + if (nbw1.IsAccepting(q1)) + flag = 1; + if (flag == 1 && nbw2.IsAccepting(q2)) + flag = 2; + initialStates.Add(new ProductState(q1, q2, flag)); + } + } + + // Lazy transition function + IReadOnlyList>>> + Delta(ProductState state) + { + var trans1 = nbw1.GetTransition(state.State1); + var trans2 = nbw2.GetTransition(state.State2); + int currentFlag = state.Flag; + + // Combine all pairs of Antimirov disjuncts from both NBWs + var result = new List>>>(); + + foreach (var t1 in trans1) + { + foreach (var t2 in trans2) + { + // Use ApplyCross to combine the two transition terms symbolically + var combined = algebra1.ApplyCross( + t1, t2, + (ss1, ss2) => CombineStateSets( + ss1, ss2, currentFlag, + nbw1.IsAccepting, nbw2.IsAccepting, + productComparer), + nbw1.Eba.Top); + + // Skip if the result is bottom (empty state set) + if (!IsBottom(combined, productComparer)) + result.Add(combined); + } + } + + return result; + } + + // Accepting: flag == 2 + bool IsAccepting(ProductState state) => state.Flag == 2; + + return new SymbolicNBW>( + nbw1.Eba, + nbw1.Registry, + initialStates, + IsAccepting, + Delta, + productEqComparer); + } + + /// + /// Combines two state sets from NBW₁ and NBW₂ into a product state set, + /// advancing the acceptance flag according to the breakpoint rule. + /// + private static StateSet> + CombineStateSets( + StateSet ss1, + StateSet ss2, + int currentFlag, + Func isAccepting1, + Func isAccepting2, + IComparer> comparer) + { + if (ss1.IsEmpty || ss2.IsEmpty) + return StateSet>.Empty(comparer); + + var pairs = new List>(); + foreach (var q1 in ss1) + { + foreach (var q2 in ss2) + { + int nextFlag = AdvanceFlag(currentFlag, q1, q2, isAccepting1, isAccepting2); + pairs.Add(new ProductState(q1, q2, nextFlag)); + } + } + + return new StateSet>(pairs, comparer); + } + + /// + /// Advances the acceptance flag based on successor states. + /// Flag 0: waiting for F₁ → if q1 ∈ F₁, advance to 1 + /// Flag 1: waiting for F₂ → if q2 ∈ F₂, advance to 2 + /// Flag 2: already accepting → reset to 0 (and re-check) + /// + private static int AdvanceFlag( + int currentFlag, + TState1 q1, TState2 q2, + Func isAccepting1, + Func isAccepting2) + { + int flag = currentFlag; + + // From flag 2, reset to 0 + if (flag == 2) flag = 0; + + // Try to advance through phases in one step + if (flag == 0 && isAccepting1(q1)) flag = 1; + if (flag == 1 && isAccepting2(q2)) flag = 2; + + return flag; + } + + private static bool IsBottom( + TransitionTerm> term, + IComparer comparer) + { + if (term is TransitionTermLeaf> leaf) + return leaf.Value.IsEmpty; + return false; + } + } + + /// + /// Product state: (q₁, q₂, flag) where flag ∈ {0, 1, 2}. + /// + public sealed class ProductState : IEquatable> + { + public TState1 State1 { get; } + public TState2 State2 { get; } + public int Flag { get; } + + public ProductState(TState1 state1, TState2 state2, int flag) + { + State1 = state1; + State2 = state2; + Flag = flag; + } + + public bool Equals(ProductState other) + { + if (other == null) return false; + return EqualityComparer.Default.Equals(State1, other.State1) + && EqualityComparer.Default.Equals(State2, other.State2) + && Flag == other.Flag; + } + + public override bool Equals(object obj) => Equals(obj as ProductState); + + public override int GetHashCode() + { + unchecked + { + int hash = EqualityComparer.Default.GetHashCode(State1) * 31; + hash = (hash + EqualityComparer.Default.GetHashCode(State2)) * 31; + return hash + Flag; + } + } + + public override string ToString() => $"({State1}, {State2}, {Flag})"; + } + + internal sealed class ProductStateComparer + : IComparer> + { + private readonly IComparer _cmp1; + private readonly IComparer _cmp2; + + public ProductStateComparer(IComparer cmp1, IComparer cmp2) + { + _cmp1 = cmp1; + _cmp2 = cmp2; + } + + public int Compare(ProductState x, ProductState y) + { + if (x == null && y == null) return 0; + if (x == null) return -1; + if (y == null) return 1; + + int c = _cmp1.Compare(x.State1, y.State1); + if (c != 0) return c; + c = _cmp2.Compare(x.State2, y.State2); + if (c != 0) return c; + return x.Flag.CompareTo(y.Flag); + } + } + + internal sealed class ProductStateEqualityComparer + : IEqualityComparer> + { + private readonly IEqualityComparer _eq1; + private readonly IEqualityComparer _eq2; + + public ProductStateEqualityComparer( + IEqualityComparer eq1, IEqualityComparer eq2) + { + _eq1 = eq1; + _eq2 = eq2; + } + + public bool Equals(ProductState x, ProductState y) + { + if (x == null && y == null) return true; + if (x == null || y == null) return false; + return _eq1.Equals(x.State1, y.State1) + && _eq2.Equals(x.State2, y.State2) + && x.Flag == y.Flag; + } + + public int GetHashCode(ProductState obj) + { + if (obj == null) return 0; + return obj.GetHashCode(); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs b/Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs new file mode 100644 index 0000000..8155f3b --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs @@ -0,0 +1,408 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Memory-efficient on-the-fly emptiness check for the product of a model + /// program () and a symbolic NBW, implemented + /// as Algorithm B (Nested Depth-First Search) from + /// + /// Costas Courcoubetis, Moshe Y. Vardi, Pierre Wolper, Mihalis Yannakakis. + /// "Memory-Efficient Algorithms for the Verification of Temporal Properties." + /// Formal Methods in System Design 1 (1992), 275–288. + /// https://doi.org/10.1007/BF00121128 + /// + /// Algorithm B uses two interleaved DFS passes: + /// + /// + /// + /// dfs1 (outer): a standard DFS from the initial + /// product states. When a product node s is about to be popped + /// (i.e. post-order) and nbw.IsAccepting(s.NbwState) holds, + /// dfs1 launches dfs2 with seed = s. + /// + /// + /// dfs2 (inner): a DFS from seed. If it ever + /// reaches seed as a successor, an accepting cycle exists and + /// the search terminates. + /// + /// + /// + /// Both DFS passes share a single visited2 set across all dfs2 + /// invocations — this is what makes the algorithm linear-time and + /// linear-space (only two bits per product state are required, regardless + /// of how many accepting states there are). + /// + /// Both passes are implemented iteratively (explicit frame stacks) so that + /// they do not blow the CLR call stack on deep product graphs. + /// + public static class NestedDfsCheck + { + /// + /// Check emptiness of the language of the product (System × NBW) using + /// Algorithm B. Returns + /// with a counterexample when an accepting cycle is found, and + /// otherwise. + /// + /// NBW state type. + /// Root system node. + /// Symbolic NBW over / + /// . + /// If positive: any product node first reached + /// at depth ≥ is treated as a frontier + /// node with only a stutter self-loop, matching the bounded-depth + /// semantics of . + /// Equality comparer for NBW states. + /// Defaults to . + public static PropertyCheckingResult Check( + StateGraphNode root, + SymbolicNBW nbw, + int maxDepth = 0, + IEqualityComparer nbwStateComparer = null) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (nbw == null) throw new ArgumentNullException(nameof(nbw)); + + var nbwCmp = nbwStateComparer ?? EqualityComparer.Default; + var registry = nbw.Registry; + + // Per-node info (parent pointers for counterexample reconstruction, + // visited flags). Indexed by composite key (system fingerprint + + // NBW state). + var nodes = new Dictionary>(StringComparer.Ordinal); + + // Find or create a product node entry. + Node Intern(StateGraphNode sys, TNbwState q, int depth) + { + var key = MakeKey(sys, q); + if (!nodes.TryGetValue(key, out var n)) + { + n = new Node(sys, q, key, depth); + nodes[key] = n; + } + return n; + } + + // Enumerate product successors on-the-fly. + IEnumerable> Successors(Node p) + { + var nbwTrans = nbw.GetTransition(p.NbwState); + var nbwSuccs = EvaluateNbwTransitions(nbwTrans, p.SystemNode.State, registry, nbwCmp); + + var edges = p.SystemNode.Edges; + var atFrontier = (maxDepth > 0 && p.Depth >= maxDepth); + var terminal = edges == null || edges.Count == 0; + + if (terminal || atFrontier) + { + foreach (var q in nbwSuccs) + yield return new Successor(p.SystemNode, q, null); + yield break; + } + + foreach (var edge in edges) + { + foreach (var q in nbwSuccs) + yield return new Successor(edge.Target, q, edge.StepFunction); + } + } + + // -------- Outer DFS (dfs1) -------- + // For each initial product node, run dfs1 if not already visited. + // dfs1 maintains an explicit frame stack with a successor + // enumerator per frame. When a frame's enumerator is exhausted, we + // post-process: if the frame's node is accepting, launch dfs2. + + var outerStack = new Stack>(); + + foreach (var nbwInit in nbw.InitialStates) + { + var init = Intern(root, nbwInit, 0); + if (init.Visited1) continue; + + init.Visited1 = true; + outerStack.Push(new Frame(init, Successors(init).GetEnumerator())); + + while (outerStack.Count > 0) + { + var top = outerStack.Peek(); + + if (top.Successors.MoveNext()) + { + var s = top.Successors.Current; + var child = Intern(s.SystemNode, s.NbwState, top.Node.Depth + 1); + if (!child.Visited1) + { + child.Visited1 = true; + child.OuterParent = top.Node; + child.IncomingStep = s.StepFunction; + outerStack.Push(new Frame(child, Successors(child).GetEnumerator())); + } + } + else + { + // Post-order: enumerator exhausted, ready to pop. + top.Successors.Dispose(); + outerStack.Pop(); + + if (nbw.IsAccepting(top.Node.NbwState)) + { + // Run dfs2 with seed = top.Node. + if (RunInnerDfs(top.Node, Successors, nodes, out var closingPredecessor)) + { + // Found an accepting cycle. + var trace = BuildCounterexample(top.Node, closingPredecessor); + trace = TraceInstantiation.AttachValuations(trace, registry); + var badCycle = StronglyConnectedComponent.FromSystemNodes( + CollectCycleSystemNodes(top.Node, closingPredecessor)); + return PropertyCheckingResult.Failure(trace, badCycle); + } + } + } + } + } + + return PropertyCheckingResult.Success(); + } + + /// + /// Inner DFS (dfs2): from , search for a path + /// back to . Returns true if a cycle is found; + /// in that case is the node from + /// which seed was discovered as a successor (i.e. the last node + /// on the cycle before it closes back onto seed). + /// The shared Visited2 flag is preserved across all dfs2 calls. + /// + private static bool RunInnerDfs( + Node seed, + Func, IEnumerable>> successors, + Dictionary> nodes, + out Node closingPredecessor) + { + closingPredecessor = null; + + // If seed already visited by a previous dfs2 (it cannot be — dfs2 + // only runs at most once per accepting node and shares visited2 — + // but this guards against re-entry should that invariant change). + if (seed.Visited2) return false; + seed.Visited2 = true; + seed.InnerParent = null; + + var stack = new Stack>(); + stack.Push(new Frame(seed, successors(seed).GetEnumerator())); + + while (stack.Count > 0) + { + var top = stack.Peek(); + + if (top.Successors.MoveNext()) + { + var s = top.Successors.Current; + var key = MakeKey(s.SystemNode, s.NbwState); + + // Algorithm B's defining check: did dfs2 reach the seed? + if (key.Equals(seed.Key, StringComparison.Ordinal)) + { + closingPredecessor = top.Node; + // Drain frames so enumerators are disposed. + while (stack.Count > 0) + stack.Pop().Successors.Dispose(); + return true; + } + + if (!nodes.TryGetValue(key, out var child)) + { + // dfs2 should only reach nodes already discovered by + // dfs1 in a single connected exploration, but the + // product is built on-the-fly, so just intern. + child = new Node(s.SystemNode, s.NbwState, key, top.Node.Depth + 1); + nodes[key] = child; + } + + if (!child.Visited2) + { + child.Visited2 = true; + child.InnerParent = top.Node; + child.InnerIncomingStep = s.StepFunction; + stack.Push(new Frame(child, successors(child).GetEnumerator())); + } + } + else + { + top.Successors.Dispose(); + stack.Pop(); + } + } + + return false; + } + + /// + /// Walks the inner-parent chain from + /// back to to collect the system nodes + /// participating in the accepting cycle. Feeds the system-level + /// projection used for + /// so the enabled-but-not-taken fairness hint fires for + /// nested-DFS-discovered counterexamples on parity with the + /// explicit-LTL backend. + /// + private static IEnumerable CollectCycleSystemNodes( + Node seed, + Node closingPredecessor) + { + yield return seed.SystemNode; + if (closingPredecessor == null) yield break; + for (var n = closingPredecessor; n != null && n != seed; n = n.InnerParent) + yield return n.SystemNode; + } + + /// + /// Builds a counterexample trace: prefix (initial → seed) followed by + /// cycle (seed → … → seed). The closing edge is implied by the last + /// trace item being the predecessor that discovered seed as a + /// successor in dfs2. + /// + private static List BuildCounterexample( + Node seed, + Node closingPredecessor) + { + var trace = new List(); + + // Prefix: walk outer-parent pointers from seed back to its root, + // then reverse. The root has OuterParent == null. + var prefix = new List>(); + for (var n = seed; n != null; n = n.OuterParent) + prefix.Add(n); + prefix.Reverse(); + + foreach (var n in prefix) + trace.Add(new TraceItem(n.IncomingStep, n.SystemNode, isInCycle: false)); + + // Cycle: walk inner-parent pointers from closingPredecessor back + // to seed, reverse, then append the seed itself to close the + // cycle. + if (closingPredecessor != null) + { + var cycle = new List>(); + for (var n = closingPredecessor; n != null && n != seed; n = n.InnerParent) + cycle.Add(n); + cycle.Reverse(); + + foreach (var n in cycle) + trace.Add(new TraceItem(n.InnerIncomingStep, n.SystemNode, isInCycle: true)); + + // Closing edge: from closingPredecessor (the last node added, + // or seed itself if cycle is empty i.e. self-loop) back to + // seed. The step function on this edge is the step function + // that took closingPredecessor → seed in the product. We + // don't track that here; emit the seed as the final cycle + // item with a null step (the cycle interpretation is "return + // to seed"). + trace.Add(new TraceItem(null, seed.SystemNode, isInCycle: true)); + } + + return trace; + } + + /// + /// Evaluate Antimirov-form transitions against a concrete system state + /// to produce the set of successor NBW states. + /// + private static HashSet EvaluateNbwTransitions( + IReadOnlyList>> transitions, + IState systemState, + ConditionRegistry registry, + IEqualityComparer comparer) + { + var result = new HashSet(comparer); + foreach (var term in transitions) + { + var leaf = EvaluateTerm(term, systemState, registry); + if (leaf != null) + foreach (var s in leaf) + result.Add(s); + } + return result; + } + + /// + /// Walks an ITE transition term against a concrete state, following + /// the unique path to a leaf. + /// + private static StateSet EvaluateTerm( + TransitionTerm> term, + IState systemState, + ConditionRegistry registry) + { + while (true) + { + if (term is TransitionTermLeaf> leaf) + return leaf.Value; + var ite = (TransitionTermIte>)term; + var pred = registry.GetPredicate(ite.ConditionIndex); + term = pred.Eval(systemState) ? ite.Hi : ite.Lo; + } + } + + private static string MakeKey(StateGraphNode sys, TNbwState q) + => $"{sys.GetNodeFingerprint()}|{q?.GetHashCode():X8}|{q}"; + + #region Internal types + + private sealed class Node + { + public StateGraphNode SystemNode { get; } + public TNbwState NbwState { get; } + public string Key { get; } + public int Depth { get; } + + public bool Visited1 { get; set; } + public bool Visited2 { get; set; } + + // Outer-DFS spanning-tree info (for prefix reconstruction). + public Node OuterParent { get; set; } + public IStepFunction IncomingStep { get; set; } + + // Inner-DFS spanning-tree info (for cycle reconstruction). + public Node InnerParent { get; set; } + public IStepFunction InnerIncomingStep { get; set; } + + public Node(StateGraphNode sys, TNbwState q, string key, int depth) + { + SystemNode = sys; + NbwState = q; + Key = key; + Depth = depth; + } + } + + private readonly struct Successor + { + public StateGraphNode SystemNode { get; } + public TNbwState NbwState { get; } + public IStepFunction StepFunction { get; } + + public Successor(StateGraphNode sys, TNbwState q, IStepFunction sf) + { + SystemNode = sys; + NbwState = q; + StepFunction = sf; + } + } + + private sealed class Frame + { + public Node Node { get; } + public IEnumerator> Successors { get; } + + public Frame(Node node, IEnumerator> succs) + { + Node = node; + Successors = succs; + } + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Symbolic/PredCompare.cs b/Accordant.ModelChecking/Symbolic/PredCompare.cs new file mode 100644 index 0000000..0186a86 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/PredCompare.cs @@ -0,0 +1,63 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Total order over that is consistent with + /// : Compare(a,b) == 0 if + /// and only if Equals(a,b). + /// + /// This is required for ACI normalization of formulas that use + /// for deduplicating + /// disjuncts/conjuncts. A comparer that ordered predicates by + /// GetHashCode alone would (a) be inconsistent with Equals + /// on hash collisions and (b) silently drop one of two genuinely different + /// predicates whose hashes collide. + /// + /// + /// Strategy: + /// + /// If implements + /// , use it. + /// Otherwise, if Equals(a,b), return 0. + /// Otherwise, order by GetHashCode; on hash collision (which + /// implies the values are NOT equal here), tiebreak on + /// . + /// + /// In every case the equivalence + /// Compare(a,b)==0 ⇔ EqualityComparer<TPred>.Default.Equals(a,b) + /// is preserved, which is what SortedSet-based dedup relies on. + /// + /// + internal static class PredCompare + { + private static readonly bool _useDefault = + typeof(IComparable).IsAssignableFrom(typeof(TPred)) || + typeof(IComparable).IsAssignableFrom(typeof(TPred)); + + public static int Compare(TPred a, TPred b) + { + // Fast / always-correct path for equal values. + if (EqualityComparer.Default.Equals(a, b)) return 0; + + // Use the natural ordering when available. + if (_useDefault) + { + int c = Comparer.Default.Compare(a, b); + if (c != 0) return c; + // Defensive: Compare returned 0 but Equals returned false + // (unusual but not forbidden — fall through). + } + + // Last-resort deterministic order: hash, then string repr. + int hc = EqualityComparer.Default.GetHashCode(a) + .CompareTo(EqualityComparer.Default.GetHashCode(b)); + if (hc != 0) return hc; + return string.Compare( + a == null ? null : a.ToString(), + b == null ? null : b.ToString(), + StringComparison.Ordinal); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/Rltl.cs b/Accordant.ModelChecking/Symbolic/Rltl.cs new file mode 100644 index 0000000..2c42bc9 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/Rltl.cs @@ -0,0 +1,632 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// RLTL formula in NNF (Section 7 of the POPL'25 paper; closures from JACM). + /// Extends pure LTL with two regex-prefix operators, their NNF duals, and + /// three regex closures: + /// + /// : R ; φ — ∃k≥0. w[0..k]∈R ∧ w[k..]⊨φ + /// : R : φ — ∃k≥0. w[0..k+1]∈R ∧ w[k..]⊨φ + /// : R ⊳ φ — ∀k≥0. w[0..k]∈R → w[k..]⊨φ (= ¬(R; ¬φ)) + /// : R ⊳⊳ φ — ∀k≥0. w[0..k+1]∈R → w[k..]⊨φ (= ¬(R: ¬φ)) + /// : {R} — ε⊨R ∨ ∃i: w[..i]⊨R ∨ ∀i: ∂(w[..i],R)≢⊥ + /// : {{R}}̄ — dual of {R} + /// : {R}ω — infinite concatenation of R-matches + /// + /// All temporal operators of LTL (Until, Release, Next, And, Or, Atom) + /// are also available with their standard semantics. + /// pushes negation to atoms via De Morgan and the regex-operator duals. + /// + public abstract class Rltl : IEquatable>, IComparable> + { + private int? _hash; + private int _id = -1; + internal Rltl() { } + internal abstract int Kind { get; } + + /// + /// Unique non-negative identifier within the + /// . Assigned on first interning. + /// Id 0 == ⊥, Id 1 == ⊤. + /// + public int Id => _id; + + internal bool HasId => _id >= 0; + + internal void AssignId(int id) { _id = id; } + + /// + /// Per- default term builder. All static + /// factories on this type route through it so every returned RLTL + /// formula is canonical (reference-equal to structurally equivalent + /// terms). + /// + public static RltlBuilder DefaultBuilder => BuilderHolder.Instance; + + private static class BuilderHolder + { + internal static readonly RltlBuilder Instance = new RltlBuilder(); + } + + #region Factories + + public static Rltl True() => DefaultBuilder.True; + public static Rltl False() => DefaultBuilder.False; + + /// + /// Construct a positive atom carrying . To + /// negate, use which pushes + /// complement into the underlying EBA. + /// + public static Rltl Atom(TPred p) + => DefaultBuilder.Intern(new RltlAtom(p)); + + public static Rltl Next(Rltl inner) + { + if (inner is RltlTrue) return True(); + if (inner is RltlFalse) return False(); + return DefaultBuilder.Intern(new RltlNext(inner)); + } + + public static Rltl Until(Rltl l, Rltl r) + { + if (r is RltlTrue) return True(); + if (r is RltlFalse) return False(); // l U ⊥ = ⊥ + if (l is RltlFalse) return r; + return DefaultBuilder.Intern(new RltlUntil(l, r)); + } + + public static Rltl Release(Rltl l, Rltl r) + { + if (r is RltlFalse) return False(); + if (r is RltlTrue) return True(); + if (l is RltlTrue) return r; + return DefaultBuilder.Intern(new RltlRelease(l, r)); + } + + public static Rltl Eventually(Rltl p) => Until(True(), p); + public static Rltl Globally(Rltl p) => Release(False(), p); + + /// R ; φ — sequential regex prefix (∃-quantification). + public static Rltl SeqPrefix(Ere r, Rltl phi) + { + if (r is EreEmpty) return False(); + if (r is EreEpsilon) return phi; // (ε ; φ) = φ + if (phi is RltlFalse) return False(); // R ; ⊥ = ⊥ + if (IsSigmaStar(r)) return Eventually(phi); // Σ* ; φ ≡ ◇φ + // Distribute over Union in the regex argument (∃-style): + // (R₁ + R₂) ; φ ≡ (R₁;φ) ∨ (R₂;φ) + // Exposes per-branch prefix obligations; each disjunct may + // canonicalise further (e.g. Σ* branch → ◇φ). + if (r is EreUnion u) + { + Rltl acc = False(); + foreach (var op in u.Operands) acc = Or(acc, SeqPrefix(op, phi)); + return acc; + } + return DefaultBuilder.Intern(new RltlSeqPrefix(r, phi)); + } + + /// R : φ — overlapping regex prefix (∃-quantification, ≥1 match). + public static Rltl OvlPrefix(Ere r, Rltl phi) + { + if (r is EreEmpty) return False(); + if (r is EreEpsilon) return False(); // : requires positive-length match + if (phi is RltlFalse) return False(); + if (IsSigmaStar(r)) return Eventually(phi); // Σ* : φ ≡ ◇φ + // Distribute over Union (∃-style, dual of SeqPrefix above): + // (R₁ + R₂) : φ ≡ (R₁:φ) ∨ (R₂:φ) + if (r is EreUnion u) + { + Rltl acc = False(); + foreach (var op in u.Operands) acc = Or(acc, OvlPrefix(op, phi)); + return acc; + } + return DefaultBuilder.Intern(new RltlOvlPrefix(r, phi)); + } + + /// R ⊳ φ — universal trigger (∀-quantification), dual of ;. + public static Rltl Trigger(Ere r, Rltl phi) + { + if (r is EreEmpty) return True(); // no prefix matches ∅ + if (r is EreEpsilon) return phi; // only k=0 matches: φ at pos 0 + if (phi is RltlTrue) return True(); + if (IsSigmaStar(r)) return Globally(phi); // Σ* ⊳ φ ≡ □φ + // Distribute over Union (∀-style → conjunction, dual of ∃): + // (R₁ + R₂) ⊳ φ ≡ (R₁⊳φ) ∧ (R₂⊳φ) + // because ∀k (k matches R₁∪R₂ → φ@k) splits into the conjunction + // of the per-disjunct universal obligations. + if (r is EreUnion u) + { + Rltl acc = True(); + foreach (var op in u.Operands) acc = And(acc, Trigger(op, phi)); + return acc; + } + return DefaultBuilder.Intern(new RltlTrigger(r, phi)); + } + + // ----------------------------------------------------------------- + // "Raw" prefix-operator factories: same unit-law simplifications as + // the smart constructors above, but they DO NOT distribute Union in + // the regex argument (Layer A is bypassed). Used by + // RltlDerivative when constructed with + // distributePrefixUnion = false — primarily for benchmarks + // that need to measure state-space size with the distribution rule + // turned off as a baseline. + // ----------------------------------------------------------------- + public static Rltl SeqPrefixRaw(Ere r, Rltl phi) + { + if (r is EreEmpty) return False(); + if (r is EreEpsilon) return phi; + if (phi is RltlFalse) return False(); + if (IsSigmaStar(r)) return Eventually(phi); + return DefaultBuilder.Intern(new RltlSeqPrefix(r, phi)); + } + + public static Rltl OvlPrefixRaw(Ere r, Rltl phi) + { + if (r is EreEmpty) return False(); + if (r is EreEpsilon) return False(); + if (phi is RltlFalse) return False(); + if (IsSigmaStar(r)) return Eventually(phi); + return DefaultBuilder.Intern(new RltlOvlPrefix(r, phi)); + } + + public static Rltl TriggerRaw(Ere r, Rltl phi) + { + if (r is EreEmpty) return True(); + if (r is EreEpsilon) return phi; + if (phi is RltlTrue) return True(); + if (IsSigmaStar(r)) return Globally(phi); + return DefaultBuilder.Intern(new RltlTrigger(r, phi)); + } + + public static Rltl MatchRaw(Ere r, Rltl phi) + { + if (r is EreEmpty) return True(); + if (r is EreEpsilon) return True(); + if (phi is RltlTrue) return True(); + if (IsSigmaStar(r)) return Globally(phi); + return DefaultBuilder.Intern(new RltlMatch(r, phi)); + } + + /// R ⊳⊳ φ — universal match (∀-quantification, ≥1 length), dual of :. + public static Rltl Match(Ere r, Rltl phi) + { + if (r is EreEmpty) return True(); + if (r is EreEpsilon) return True(); + if (phi is RltlTrue) return True(); + if (IsSigmaStar(r)) return Globally(phi); // Σ* ⊳⊳ φ ≡ □φ + // Distribute over Union (∀-style → conjunction, dual of OvlPrefix): + // (R₁ + R₂) ⊳⊳ φ ≡ (R₁⊳⊳φ) ∧ (R₂⊳⊳φ) + if (r is EreUnion u) + { + Rltl acc = True(); + foreach (var op in u.Operands) acc = And(acc, Match(op, phi)); + return acc; + } + return DefaultBuilder.Intern(new RltlMatch(r, phi)); + } + + /// + /// Disjunction φ ∨ ψ — minimal static helper used by the + /// distribution rewrites in and + /// . Applies the standard ⊥/⊤ unit and + /// absorption laws and flattens nested + /// via canonical sorted-set deduplication. Does not perform + /// the EBA atom-fusion that does; + /// callers that need full canonicalisation should run the algebra + /// afterwards. + /// + public static Rltl Or(Rltl a, Rltl b) + { + if (a is RltlTrue || b is RltlTrue) return True(); + if (a is RltlFalse) return b; + if (b is RltlFalse) return a; + var ops = new SortedSet>(RltlComparer.Instance); + CollectOr(a, ops); CollectOr(b, ops); + if (ops.Count == 1) return ops.First(); + return DefaultBuilder.Intern(new RltlOr(ops.ToArray())); + } + + /// + /// Conjunction φ ∧ ψ — minimal static helper used by the + /// distribution rewrites in and + /// . Same caveats as : no + /// EBA atom-fusion is performed here. + /// + public static Rltl And(Rltl a, Rltl b) + { + if (a is RltlFalse || b is RltlFalse) return False(); + if (a is RltlTrue) return b; + if (b is RltlTrue) return a; + var ops = new SortedSet>(RltlComparer.Instance); + CollectAnd(a, ops); CollectAnd(b, ops); + if (ops.Count == 1) return ops.First(); + return DefaultBuilder.Intern(new RltlAnd(ops.ToArray())); + } + + private static void CollectOr(Rltl f, SortedSet> s) + { + if (f is RltlOr o) foreach (var op in o.Operands) s.Add(op); + else s.Add(f); + } + + private static void CollectAnd(Rltl f, SortedSet> s) + { + if (f is RltlAnd a) foreach (var op in a.Operands) s.Add(op); + else s.Add(f); + } + + /// + /// Weak closure {R} — JACM eq. (2737). This static factory applies + /// only the cheap *syntactic* rewrites: {⊥} ≡ ⊥ and (if + /// R.Nullable) {R} ≡ ⊤. The full semantic check + /// FLang(R) = ∅ needed for the {R} ≡ ⊥ simplification + /// is performed lazily by + /// inside . + /// + public static Rltl WeakClosure(Ere r) + { + if (r == null) throw new ArgumentNullException(nameof(r)); + if (r is EreEmpty) return False(); + if (r.Nullable) return True(); + return DefaultBuilder.Intern(new RltlWeakClosure(r)); + } + + /// + /// Negated weak closure {{R}}̄ — JACM eq. (2747). Syntactic rewrites: + /// {{⊥}}̄ ≡ ⊤, and if R.Nullable then {{R}}̄ ≡ ⊥. + /// Semantic {{R}}̄ ≡ ⊤ when FLang(R) = ∅ is applied by + /// in + /// . + /// + public static Rltl NegWeakClosure(Ere r) + { + if (r == null) throw new ArgumentNullException(nameof(r)); + if (r is EreEmpty) return True(); + if (r.Nullable) return False(); + return DefaultBuilder.Intern(new RltlNegWeakClosure(r)); + } + + /// + /// ω-closure {R}ω — JACM eq. (2754). Syntactic rewrite: {⊥}ω ≡ ⊥. + /// No nullable shortcut: ε ∈ L(R) does not make {R}ω trivially + /// hold. Note: this operator must not occur negatively (RLTL+ disallows + /// ¬{R}ω); throws + /// on encountering it. + /// + public static Rltl OmegaClosure(Ere r) + { + if (r == null) throw new ArgumentNullException(nameof(r)); + if (r is EreEmpty) return False(); + return DefaultBuilder.Intern(new RltlOmegaClosure(r)); + } + + /// True iff is the universal language Σ* = (~∅)*. + private static bool IsSigmaStar(Ere r) + => r is EreStar s + && s.Inner is EreComplement c + && c.Inner is EreEmpty; + + #endregion + + #region Equality / Comparison + + public abstract bool Equals(Rltl other); + public override bool Equals(object obj) => Equals(obj as Rltl); + public override int GetHashCode() + { + if (_hash == null) _hash = ComputeHashCode(); + return _hash.Value; + } + protected abstract int ComputeHashCode(); + + public int CompareTo(Rltl other) + { + if (other == null) return 1; + if (ReferenceEquals(this, other)) return 0; + int c = Kind.CompareTo(other.Kind); + if (c != 0) return c; + return CompareToSameKind(other); + } + protected abstract int CompareToSameKind(Rltl other); + + public static bool operator ==(Rltl a, Rltl b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null) return false; + return a.Equals(b); + } + public static bool operator !=(Rltl a, Rltl b) => !(a == b); + + #endregion + } + + internal sealed class RltlComparer : IComparer> + { + public static readonly RltlComparer Instance = new RltlComparer(); + public int Compare(Rltl x, Rltl y) => x.CompareTo(y); + } + + public sealed class RltlTrue : Rltl + { + public static readonly RltlTrue Instance = new RltlTrue(); + private RltlTrue() { } + internal override int Kind => 0; + public override bool Equals(Rltl other) => other is RltlTrue; + protected override int ComputeHashCode() => 0x7F7F7F7F; + protected override int CompareToSameKind(Rltl other) => 0; + public override string ToString() => "⊤"; + } + + public sealed class RltlFalse : Rltl + { + public static readonly RltlFalse Instance = new RltlFalse(); + private RltlFalse() { } + internal override int Kind => 1; + public override bool Equals(Rltl other) => other is RltlFalse; + protected override int ComputeHashCode() => 0x3F3F3F3F; + protected override int CompareToSameKind(Rltl other) => 0; + public override string ToString() => "⊥"; + } + + public sealed class RltlAtom : Rltl + { + /// + /// Construct a positive atom. Negation flows into the predicate + /// via . + /// + public RltlAtom(TPred p) { Predicate = p; } + public TPred Predicate { get; } + internal override int Kind => 2; + public override bool Equals(Rltl other) + => other is RltlAtom a + && EqualityComparer.Default.Equals(Predicate, a.Predicate); + protected override int ComputeHashCode() + => EqualityComparer.Default.GetHashCode(Predicate); + protected override int CompareToSameKind(Rltl other) + { + var a = (RltlAtom)other; + return PredCompare.Compare(Predicate, a.Predicate); + } + public override string ToString() => Predicate.ToString(); + } + + public sealed class RltlNext : Rltl + { + public RltlNext(Rltl inner) { Inner = inner ?? throw new ArgumentNullException(nameof(inner)); } + public Rltl Inner { get; } + internal override int Kind => 3; + public override bool Equals(Rltl other) + => other is RltlNext n && Inner.Equals(n.Inner); + protected override int ComputeHashCode() => unchecked(Inner.GetHashCode() * 13 + 3); + protected override int CompareToSameKind(Rltl other) + => Inner.CompareTo(((RltlNext)other).Inner); + public override string ToString() => $"X({Inner})"; + } + + public sealed class RltlUntil : Rltl + { + public RltlUntil(Rltl l, Rltl r) { Left = l; Right = r; } + public Rltl Left { get; } + public Rltl Right { get; } + internal override int Kind => 4; + public override bool Equals(Rltl other) + => other is RltlUntil u && Left.Equals(u.Left) && Right.Equals(u.Right); + protected override int ComputeHashCode() => unchecked(Left.GetHashCode() * 17 + Right.GetHashCode() * 19 + 4); + protected override int CompareToSameKind(Rltl other) + { + var u = (RltlUntil)other; + int c = Left.CompareTo(u.Left); + return c != 0 ? c : Right.CompareTo(u.Right); + } + public override string ToString() + => Left is RltlTrue ? $"F {Right}" : $"({Left} U {Right})"; + } + + public sealed class RltlRelease : Rltl + { + public RltlRelease(Rltl l, Rltl r) { Left = l; Right = r; } + public Rltl Left { get; } + public Rltl Right { get; } + internal override int Kind => 5; + public override bool Equals(Rltl other) + => other is RltlRelease r && Left.Equals(r.Left) && Right.Equals(r.Right); + protected override int ComputeHashCode() => unchecked(Left.GetHashCode() * 23 + Right.GetHashCode() * 29 + 5); + protected override int CompareToSameKind(Rltl other) + { + var r = (RltlRelease)other; + int c = Left.CompareTo(r.Left); + return c != 0 ? c : Right.CompareTo(r.Right); + } + public override string ToString() + => Left is RltlFalse ? $"G {Right}" : $"({Left} R {Right})"; + } + + public sealed class RltlAnd : Rltl + { + internal RltlAnd(Rltl[] ops) { Operands = ops; } + public IReadOnlyList> Operands { get; } + internal override int Kind => 6; + public override bool Equals(Rltl other) + { + if (!(other is RltlAnd a)) return false; + if (Operands.Count != a.Operands.Count) return false; + for (int i = 0; i < Operands.Count; i++) + if (!Operands[i].Equals(a.Operands[i])) return false; + return true; + } + protected override int ComputeHashCode() + { + unchecked { int h = 6; foreach (var o in Operands) h = h * 31 + o.GetHashCode(); return h; } + } + protected override int CompareToSameKind(Rltl other) + { + var a = (RltlAnd)other; + int c = Operands.Count.CompareTo(a.Operands.Count); + if (c != 0) return c; + for (int i = 0; i < Operands.Count; i++) + { + c = Operands[i].CompareTo(a.Operands[i]); + if (c != 0) return c; + } + return 0; + } + public override string ToString() => "(" + string.Join(" ∧ ", Operands) + ")"; + } + + public sealed class RltlOr : Rltl + { + internal RltlOr(Rltl[] ops) { Operands = ops; } + public IReadOnlyList> Operands { get; } + internal override int Kind => 7; + public override bool Equals(Rltl other) + { + if (!(other is RltlOr a)) return false; + if (Operands.Count != a.Operands.Count) return false; + for (int i = 0; i < Operands.Count; i++) + if (!Operands[i].Equals(a.Operands[i])) return false; + return true; + } + protected override int ComputeHashCode() + { + unchecked { int h = 7; foreach (var o in Operands) h = h * 31 + o.GetHashCode(); return h; } + } + protected override int CompareToSameKind(Rltl other) + { + var a = (RltlOr)other; + int c = Operands.Count.CompareTo(a.Operands.Count); + if (c != 0) return c; + for (int i = 0; i < Operands.Count; i++) + { + c = Operands[i].CompareTo(a.Operands[i]); + if (c != 0) return c; + } + return 0; + } + public override string ToString() => "(" + string.Join(" ∨ ", Operands) + ")"; + } + + /// R ; φ — there exists a prefix matching R after which φ holds. + public sealed class RltlSeqPrefix : Rltl + { + public RltlSeqPrefix(Ere regex, Rltl phi) { Regex = regex; Phi = phi; } + public Ere Regex { get; } + public Rltl Phi { get; } + internal override int Kind => 8; + public override bool Equals(Rltl other) + => other is RltlSeqPrefix s && Regex.Equals(s.Regex) && Phi.Equals(s.Phi); + protected override int ComputeHashCode() => unchecked(Regex.GetHashCode() * 41 + Phi.GetHashCode() * 43 + 8); + protected override int CompareToSameKind(Rltl other) + { + var s = (RltlSeqPrefix)other; + int c = Regex.CompareTo(s.Regex); + return c != 0 ? c : Phi.CompareTo(s.Phi); + } + public override string ToString() => $"({Regex} ; {Phi})"; + } + + /// R : φ — overlapping match (length ≥ 1). + public sealed class RltlOvlPrefix : Rltl + { + public RltlOvlPrefix(Ere regex, Rltl phi) { Regex = regex; Phi = phi; } + public Ere Regex { get; } + public Rltl Phi { get; } + internal override int Kind => 9; + public override bool Equals(Rltl other) + => other is RltlOvlPrefix s && Regex.Equals(s.Regex) && Phi.Equals(s.Phi); + protected override int ComputeHashCode() => unchecked(Regex.GetHashCode() * 47 + Phi.GetHashCode() * 53 + 9); + protected override int CompareToSameKind(Rltl other) + { + var s = (RltlOvlPrefix)other; + int c = Regex.CompareTo(s.Regex); + return c != 0 ? c : Phi.CompareTo(s.Phi); + } + public override string ToString() => $"({Regex} : {Phi})"; + } + + /// R ⊳ φ — universal trigger (= ¬(R ; ¬φ)). + public sealed class RltlTrigger : Rltl + { + public RltlTrigger(Ere regex, Rltl phi) { Regex = regex; Phi = phi; } + public Ere Regex { get; } + public Rltl Phi { get; } + internal override int Kind => 10; + public override bool Equals(Rltl other) + => other is RltlTrigger s && Regex.Equals(s.Regex) && Phi.Equals(s.Phi); + protected override int ComputeHashCode() => unchecked(Regex.GetHashCode() * 59 + Phi.GetHashCode() * 61 + 10); + protected override int CompareToSameKind(Rltl other) + { + var s = (RltlTrigger)other; + int c = Regex.CompareTo(s.Regex); + return c != 0 ? c : Phi.CompareTo(s.Phi); + } + public override string ToString() => $"({Regex} ⊳ {Phi})"; + } + + /// R ⊳⊳ φ — universal overlapping match (= ¬(R : ¬φ)). + public sealed class RltlMatch : Rltl + { + public RltlMatch(Ere regex, Rltl phi) { Regex = regex; Phi = phi; } + public Ere Regex { get; } + public Rltl Phi { get; } + internal override int Kind => 11; + public override bool Equals(Rltl other) + => other is RltlMatch s && Regex.Equals(s.Regex) && Phi.Equals(s.Phi); + protected override int ComputeHashCode() => unchecked(Regex.GetHashCode() * 67 + Phi.GetHashCode() * 71 + 11); + protected override int CompareToSameKind(Rltl other) + { + var s = (RltlMatch)other; + int c = Regex.CompareTo(s.Regex); + return c != 0 ? c : Phi.CompareTo(s.Phi); + } + public override string ToString() => $"({Regex} ⊳⊳ {Phi})"; + } + + /// {R} — weak closure (JACM eq. 2737). + public sealed class RltlWeakClosure : Rltl + { + public RltlWeakClosure(Ere regex) { Regex = regex ?? throw new ArgumentNullException(nameof(regex)); } + public Ere Regex { get; } + internal override int Kind => 12; + public override bool Equals(Rltl other) + => other is RltlWeakClosure s && Regex.Equals(s.Regex); + protected override int ComputeHashCode() => unchecked(Regex.GetHashCode() * 73 + 12); + protected override int CompareToSameKind(Rltl other) + => Regex.CompareTo(((RltlWeakClosure)other).Regex); + public override string ToString() => $"{{{Regex}}}"; + } + + /// {{R}}̄ — negated weak closure (JACM eq. 2747). + public sealed class RltlNegWeakClosure : Rltl + { + public RltlNegWeakClosure(Ere regex) { Regex = regex ?? throw new ArgumentNullException(nameof(regex)); } + public Ere Regex { get; } + internal override int Kind => 13; + public override bool Equals(Rltl other) + => other is RltlNegWeakClosure s && Regex.Equals(s.Regex); + protected override int ComputeHashCode() => unchecked(Regex.GetHashCode() * 79 + 13); + protected override int CompareToSameKind(Rltl other) + => Regex.CompareTo(((RltlNegWeakClosure)other).Regex); + public override string ToString() => $"¬{{{Regex}}}"; + } + + /// {R}ω — ω-closure (JACM eq. 2754). Not closed under negation (RLTL+). + public sealed class RltlOmegaClosure : Rltl + { + public RltlOmegaClosure(Ere regex) { Regex = regex ?? throw new ArgumentNullException(nameof(regex)); } + public Ere Regex { get; } + internal override int Kind => 14; + public override bool Equals(Rltl other) + => other is RltlOmegaClosure s && Regex.Equals(s.Regex); + protected override int ComputeHashCode() => unchecked(Regex.GetHashCode() * 83 + 14); + protected override int CompareToSameKind(Rltl other) + => Regex.CompareTo(((RltlOmegaClosure)other).Regex); + public override string ToString() => $"{{{Regex}}}ω"; + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlAlgebra.cs b/Accordant.ModelChecking/Symbolic/RltlAlgebra.cs new file mode 100644 index 0000000..c77de42 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlAlgebra.cs @@ -0,0 +1,236 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// EBA-aware factory for formulas — mirrors + /// for RLTL. Boolean combinations of + /// atomic predicates flow into the EBA and become single + /// nodes; regex-prefix operators + /// (, , + /// , ) are delegated to the + /// existing static smart constructors. + /// + public sealed class RltlAlgebra + { + private readonly IPredicateAlgebra _eba; + private readonly IEreCanonicalizer _ereCanon; + + public RltlAlgebra(IPredicateAlgebra eba) + : this(eba, null) + { + } + + /// + /// Constructs an RLTL algebra with an optional ERE canonicaliser + /// (G8-c). When supplied, every embedded regex passed to + /// , , + /// , , , + /// , or is + /// replaced by the canonical representative of its language-equivalence + /// class. Combined with RLTL hash-consing this turns RLTL structural + /// equality into RLTL equality modulo embedded-ERE equivalence. + /// + public RltlAlgebra( + IPredicateAlgebra eba, + IEreCanonicalizer ereCanonicalizer) + { + _eba = eba ?? throw new ArgumentNullException(nameof(eba)); + _ereCanon = ereCanonicalizer; + } + + public IPredicateAlgebra Eba => _eba; + + /// + /// The active ERE canonicaliser, or null when none was supplied. + /// + public IEreCanonicalizer EreCanonicalizer => _ereCanon; + + private Ere Canon(Ere r) + => _ereCanon != null ? _ereCanon.Canonicalize(r) : r; + + public Rltl True => RltlTrue.Instance; + public Rltl False => RltlFalse.Instance; + + public Rltl Atom(TPred p) + { + if (p == null) throw new ArgumentNullException(nameof(p)); + if (EqualityComparer.Default.Equals(p, _eba.Top)) return True; + if (EqualityComparer.Default.Equals(p, _eba.Bottom)) return False; + return Rltl.Atom(p); + } + + public Rltl NegAtom(TPred p) => Atom(_eba.Not(p)); + + public Rltl Next(Rltl inner) => Rltl.Next(inner); + public Rltl Until(Rltl l, Rltl r) => Rltl.Until(l, r); + public Rltl Release(Rltl l, Rltl r) => Rltl.Release(l, r); + public Rltl Eventually(Rltl p) => Until(True, p); + public Rltl Globally(Rltl p) => Release(False, p); + + public Rltl SeqPrefix(Ere r, Rltl phi) => Rltl.SeqPrefix(Canon(r), phi); + public Rltl OvlPrefix(Ere r, Rltl phi) => Rltl.OvlPrefix(Canon(r), phi); + public Rltl Trigger(Ere r, Rltl phi) => Rltl.Trigger(Canon(r), phi); + public Rltl Match(Ere r, Rltl phi) => Rltl.Match(Canon(r), phi); + public Rltl WeakClosure(Ere r) => Rltl.WeakClosure(Canon(r)); + public Rltl NegWeakClosure(Ere r) => Rltl.NegWeakClosure(Canon(r)); + public Rltl OmegaClosure(Ere r) => Rltl.OmegaClosure(Canon(r)); + + public Rltl Not(Rltl f) + { + switch (f) + { + case RltlTrue _: return False; + case RltlFalse _: return True; + case RltlAtom a: return Atom(_eba.Not(a.Predicate)); + case RltlNext n: return Next(Not(n.Inner)); + case RltlUntil u: return Release(Not(u.Left), Not(u.Right)); + case RltlRelease r: return Until(Not(r.Left), Not(r.Right)); + case RltlAnd a: return OrMany(a.Operands.Select(Not)); + case RltlOr o: return AndMany(o.Operands.Select(Not)); + // Regex-prefix duals (Section 7 NNF): + // ¬(R ; φ) = R ⊳ ¬φ + // ¬(R : φ) = R ⊳⊳ ¬φ + // ¬(R ⊳ φ) = R ; ¬φ + // ¬(R ⊳⊳ φ) = R : ¬φ + case RltlSeqPrefix s: return Trigger(s.Regex, Not(s.Phi)); + case RltlOvlPrefix s: return Match(s.Regex, Not(s.Phi)); + case RltlTrigger s: return SeqPrefix(s.Regex, Not(s.Phi)); + case RltlMatch s: return OvlPrefix(s.Regex, Not(s.Phi)); + // Closure duals — JACM Def. RLTLp (line 2779): + // ¬{R} = {{R}}̄ + // ¬{{R}}̄ = {R} + // ω-closure is *not* closed under negation in RLTL+ (line 2781). + case RltlWeakClosure w: return NegWeakClosure(w.Regex); + case RltlNegWeakClosure n: return WeakClosure(n.Regex); + case RltlOmegaClosure _: + throw new NotSupportedException( + "Negated ω-closure is not in RLTL+. The ω-closure operator " + + "must not occur in a negative position."); + default: throw new ArgumentException($"Unknown RLTL node: {f.GetType()}"); + } + } + + public Rltl Implies(Rltl a, Rltl b) => Or(Not(a), b); + + public Rltl And(Rltl a, Rltl b) + { + if (a is RltlFalse || b is RltlFalse) return False; + if (a is RltlTrue) return b; + if (b is RltlTrue) return a; + var ops = new SortedSet>(RltlComparer.Instance); + CollectAnd(a, ops); CollectAnd(b, ops); + return FuseAndAtoms(ops); + } + + public Rltl Or(Rltl a, Rltl b) + { + if (a is RltlTrue || b is RltlTrue) return True; + if (a is RltlFalse) return b; + if (b is RltlFalse) return a; + var ops = new SortedSet>(RltlComparer.Instance); + CollectOr(a, ops); CollectOr(b, ops); + return FuseOrAtoms(ops); + } + + public Rltl And(params Rltl[] f) => f.Aggregate(True, And); + public Rltl Or(params Rltl[] f) => f.Aggregate(False, Or); + private Rltl AndMany(IEnumerable> f) => f.Aggregate(True, And); + private Rltl OrMany(IEnumerable> f) => f.Aggregate(False, Or); + + private static void CollectAnd(Rltl f, SortedSet> s) + { + if (f is RltlAnd a) foreach (var op in a.Operands) s.Add(op); + else s.Add(f); + } + + private static void CollectOr(Rltl f, SortedSet> s) + { + if (f is RltlOr o) foreach (var op in o.Operands) s.Add(op); + else s.Add(f); + } + + private Rltl FuseAndAtoms(SortedSet> ops) + { + TPred fused = default; + bool hasAtom = false; + var nonAtoms = new List>(); + foreach (var op in ops) + { + if (op is RltlAtom atom) + { + fused = hasAtom ? _eba.And(fused, atom.Predicate) : atom.Predicate; + hasAtom = true; + } + else nonAtoms.Add(op); + } + + if (hasAtom) + { + if (!_eba.IsSatisfiable(fused)) return False; + var fusedAtom = Atom(fused); + if (fusedAtom is RltlFalse) return False; + if (fusedAtom is RltlTrue) + { + if (nonAtoms.Count == 0) return True; + if (nonAtoms.Count == 1) return nonAtoms[0]; + return new RltlAnd(nonAtoms.ToArray()); + } + nonAtoms.Add(fusedAtom); + } + + if (nonAtoms.Count == 0) return True; + if (nonAtoms.Count == 1) return nonAtoms[0]; + var sorted = new SortedSet>(nonAtoms, RltlComparer.Instance); + return new RltlAnd(sorted.ToArray()); + } + + private Rltl FuseOrAtoms(SortedSet> ops) + { + TPred fused = default; + bool hasAtom = false; + var nonAtoms = new List>(); + foreach (var op in ops) + { + if (op is RltlAtom atom) + { + fused = hasAtom ? _eba.Or(fused, atom.Predicate) : atom.Predicate; + hasAtom = true; + } + else nonAtoms.Add(op); + } + + if (hasAtom) + { + if (!_eba.IsSatisfiable(_eba.Not(fused))) return True; + var fusedAtom = Atom(fused); + if (fusedAtom is RltlTrue) return True; + if (fusedAtom is RltlFalse) + { + if (nonAtoms.Count == 0) return False; + if (nonAtoms.Count == 1) return nonAtoms[0]; + return new RltlOr(nonAtoms.ToArray()); + } + nonAtoms.Add(fusedAtom); + } + + if (nonAtoms.Count == 0) return False; + if (nonAtoms.Count == 1) return nonAtoms[0]; + var sorted = new SortedSet>(nonAtoms, RltlComparer.Instance); + return new RltlOr(sorted.ToArray()); + } + } + + /// Default over . + public static class RltlAlgebra + { + /// + /// Default RLTL algebra over . Resolved + /// on each access through . + /// + public static RltlAlgebra Default + => new RltlAlgebra(StatePropEbaProvider.Default); + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlBreakpointCanonicalizer.cs b/Accordant.ModelChecking/Symbolic/RltlBreakpointCanonicalizer.cs new file mode 100644 index 0000000..7d6e7c9 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlBreakpointCanonicalizer.cs @@ -0,0 +1,112 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// On-the-fly canonicaliser for + /// whose TState is an formula, used to + /// implement the "weak equivalence merging" step of the alternation + /// elimination (Æ) construction — i.e. the state-reduction lemma of + /// JACM Example 5.1. + /// + /// + /// Two breakpoint states (S,O) and (S',O') are considered + /// weakly equivalent when their conjunctive RLTL "meanings" + /// coincide as ω-languages: + /// + /// + /// ⋀S ≡ ⋀S' (language equivalence of macrostate conjunction) + /// ⋀O ≡ ⋀O' (language equivalence of obligation conjunction) + /// + /// + /// + /// Both equivalences are decided by + /// + /// (sound + complete modulo the EBA's IsSatisfiable precision). + /// The canonicaliser keeps a list of discovered representatives and a + /// per-input cache; a fresh (S,O) matched against an existing + /// representative collapses to it, otherwise it becomes the representative + /// of a new class. + /// + /// + /// + /// This is the macrostate-level companion of the per-atom + /// : the per-atom canonicaliser + /// can never see, for instance, that {Fa, G(Fa∧F¬a)} is language- + /// equivalent to {G(Fa∧F¬a)} because the conjunction subsumes the + /// loose Fa. The breakpoint canonicaliser does see it by + /// conjoining the set and comparing as ω-languages. + /// + /// + /// + /// Cost: each unique new breakpoint triggers up to K NBW emptiness + /// checks (where K is the current number of representative classes, + /// times two — one for S, one for O). Strictly opt-in. See + /// 's mergeWeakEquivalent flag. + /// + /// + public sealed class RltlBreakpointCanonicalizer + { + private readonly IEffectiveBooleanAlgebra _eba; + private readonly RltlAlgebra _algebra; + private readonly List>> _representatives; + private readonly Dictionary>, BreakpointState>> _cache; + + public RltlBreakpointCanonicalizer( + IEffectiveBooleanAlgebra eba, + RltlAlgebra algebra) + { + _eba = eba ?? throw new ArgumentNullException(nameof(eba)); + _algebra = algebra ?? throw new ArgumentNullException(nameof(algebra)); + _representatives = new List>>(); + _cache = new Dictionary>, BreakpointState>>( + BreakpointState>.GetEqualityComparer()); + } + + /// The representatives discovered so far (one per equivalence class). + public IReadOnlyList>> Representatives => _representatives; + + /// Number of distinct weak-equivalence classes discovered. + public int ClassCount => _representatives.Count; + + /// + /// Returns the canonical representative of 's + /// weak-equivalence class. The first breakpoint submitted from a class + /// wins and is returned for all subsequent equivalent inputs. + /// + public BreakpointState> Canonicalize(BreakpointState> bp) + { + if (bp == null) throw new ArgumentNullException(nameof(bp)); + if (_cache.TryGetValue(bp, out var cached)) return cached; + + var sConj = ConjoinSet(bp.Macrostate); + var oConj = ConjoinSet(bp.Obligation); + + foreach (var rep in _representatives) + { + var sRepConj = ConjoinSet(rep.Macrostate); + if (!RltlLanguageEquivalence.AreEquivalent(_eba, _algebra, sConj, sRepConj)) + continue; + var oRepConj = ConjoinSet(rep.Obligation); + if (!RltlLanguageEquivalence.AreEquivalent(_eba, _algebra, oConj, oRepConj)) + continue; + _cache[bp] = rep; + return rep; + } + + _representatives.Add(bp); + _cache[bp] = bp; + return bp; + } + + // Conjunction of an RLTL state set; empty set ↦ True (vacuous). + private Rltl ConjoinSet(StateSet> set) + { + Rltl acc = null; + foreach (var x in set) + acc = acc == null ? x : _algebra.And(acc, x); + return acc ?? _algebra.True; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlBuilder.cs b/Accordant.ModelChecking/Symbolic/RltlBuilder.cs new file mode 100644 index 0000000..35fd7ad --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlBuilder.cs @@ -0,0 +1,58 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System.Collections.Generic; + + /// + /// Hash-cons builder for terms. Mirrors + /// : structurally equal RLTL formulas + /// become the same object (reference equality) and share a unique + /// non-negative integer . + /// + /// Reserved ids: 0 = ⊥ (RltlFalse), 1 = ⊤ (RltlTrue). + /// + /// Storage layout matches the ERE builder: a dense _byId + /// list (direct array indexing once an Id is known) plus a + /// consulted only during + /// to dedup-on-construction via the existing + /// structural Equals/GetHashCode on . + /// + public sealed class RltlBuilder + { + private readonly List> _byId = new List>(); + private readonly Dictionary, Rltl> _intern = + new Dictionary, Rltl>(); + + public RltlBuilder() + { + Assign(RltlFalse.Instance); + Assign(RltlTrue.Instance); + } + + /// The canonical False (⊥) formula, always at Id 0. + public Rltl False => _byId[0]; + + /// The canonical True (⊤) formula, always at Id 1. + public Rltl True => _byId[1]; + + /// Number of distinct canonical formulas currently stored. + public int Count => _byId.Count; + + public Rltl Get(int id) => _byId[id]; + + public Rltl Intern(Rltl candidate) + { + if (candidate == null) return null; + if (candidate.HasId) return _byId[candidate.Id]; + if (_intern.TryGetValue(candidate, out var existing)) return existing; + Assign(candidate); + return candidate; + } + + private void Assign(Rltl term) + { + term.AssignId(_byId.Count); + _byId.Add(term); + _intern[term] = term; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlCanonicalizer.cs b/Accordant.ModelChecking/Symbolic/RltlCanonicalizer.cs new file mode 100644 index 0000000..81235c6 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlCanonicalizer.cs @@ -0,0 +1,97 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Canonicalises an formula by language + /// equivalence: two formulas whose recognised ω-languages are equal map + /// to a single reference-equal representative. + /// + /// + /// Used by at derivative time + /// to canonicalise newly created RLTL atoms appearing as + /// leaves of the transition term. Combined + /// with the breakpoint NBW construction in + /// , this + /// collapses NBW states whose acceptance-language residuals are + /// equivalent — the "state minimisation via precise equivalence" pass. + /// + /// + public interface IRltlCanonicalizer + { + /// + /// Returns a canonical representative of 's + /// language-equivalence class. The first formula submitted from a + /// class wins and is returned for all subsequent equivalent inputs. + /// + Rltl Canonicalize(Rltl f); + } + + /// + /// Equivalence-class-based canonicaliser for + /// backed by (the sound+complete + /// oracle, modulo the underlying EBA's IsSatisfiable precision). + /// + /// + /// Maintains an input→representative cache so already-seen formulas are + /// returned in O(1). For unseen formulas a linear scan of existing + /// representatives is performed. + /// internally constructs vanilla (non-canonicalising) RLTL derivative + /// engines, so the canonicaliser is non-recursive. + /// + /// + /// + /// The lookup cost per unique formula is one NBW emptiness check per + /// existing representative; this is expensive, so canonicalisation is + /// strictly opt-in. Combined with the cheaper ERE-level canonicaliser + /// () it provides + /// equivalence-as-state-reduction beyond what hash-consing + ERE + /// canonicalisation alone can achieve (e.g. LTL-level identities not + /// captured by the smart constructors). + /// + /// + public sealed class RltlCanonicalizer : IRltlCanonicalizer + { + private readonly IEffectiveBooleanAlgebra _eba; + private readonly RltlAlgebra _algebra; + private readonly List> _representatives; + private readonly Dictionary, Rltl> _cache; + + public RltlCanonicalizer( + IEffectiveBooleanAlgebra eba, + RltlAlgebra algebra) + { + _eba = eba ?? throw new ArgumentNullException(nameof(eba)); + _algebra = algebra ?? throw new ArgumentNullException(nameof(algebra)); + _representatives = new List>(); + _cache = new Dictionary, Rltl>(); + } + + /// The representatives discovered so far (one per equivalence class). + public IReadOnlyList> Representatives => _representatives; + + /// Number of distinct equivalence classes discovered. + public int ClassCount => _representatives.Count; + + public Rltl Canonicalize(Rltl f) + { + if (f == null) throw new ArgumentNullException(nameof(f)); + if (_cache.TryGetValue(f, out var cached)) return cached; + + foreach (var rep in _representatives) + { + if (ReferenceEquals(f, rep) + || RltlLanguageEquivalence.AreEquivalent(_eba, _algebra, f, rep)) + { + _cache[f] = rep; + return rep; + } + } + + _representatives.Add(f); + _cache[f] = f; + return f; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlColour.cs b/Accordant.ModelChecking/Symbolic/RltlColour.cs new file mode 100644 index 0000000..3b1dc4d --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlColour.cs @@ -0,0 +1,62 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// Syntactic classifier that assigns each RLTL formula a binary + /// "colour" matching its membership in the ABW's co-Büchi rejecting + /// set F (see ): + /// + /// + /// Guarantee (= rejecting, ∈ F): the formula's head + /// imposes an unfulfilled eventuality obligation that the + /// breakpoint construction must discharge. Currently: + /// U (Until), SeqPrefix, OvlPrefix. + /// Safety (= accepting, ∉ F): every other formula head + /// (atoms, X, R/G, And, Or, + /// WeakClosure, NegWeakClosure). + /// + /// + /// + /// This is the colour used by + /// as the + /// weak-equivalence guard: states are bucketed by colour so that + /// universal copies are only merged when they share F-membership. + /// + /// + /// + /// Pure-syntactic; constant-time per call. Mirrors + /// RltlDerivative.IsAccepting by head pattern only, except + /// for WeakClosure and NegWeakClosure which + /// conservatively returns false + /// (= accepting / safety) to avoid a regex-emptiness call. This + /// over-classifies them as safety; the soundness consequence is at + /// worst that fewer drops occur — never an unsound drop. + /// + /// + public static class RltlColour + { + /// + /// True iff is in the ABW's co-Büchi + /// rejecting set (an unfulfilled eventuality at the head). + /// + public static bool IsRejecting(Rltl f) + { + switch (f) + { + case RltlUntil _: + case RltlSeqPrefix _: + case RltlOvlPrefix _: + return true; + default: + return false; + } + } + + /// + /// True iff and share + /// the same colour and may therefore participate in a + /// macrostate-subsumption drop. + /// + public static bool SameColour(Rltl p, Rltl q) + => IsRejecting(p) == IsRejecting(q); + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlDerivative.cs b/Accordant.ModelChecking/Symbolic/RltlDerivative.cs new file mode 100644 index 0000000..910d4e0 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlDerivative.cs @@ -0,0 +1,346 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + + /// + /// Symbolic derivative for formulas. Produces a + /// transition term TTerm⟨A, B⁺(Rltl⟨A⟩)⟩ — the same shape as the LTL + /// derivative — so the result plugs directly into + /// and the existing + /// alternation-elimination pipeline. + /// + /// LTL rules (identical to ) plus: + /// + /// ∂(R ; φ) = lift(R' → R';φ) (∂_ere(R)) ∨ (R.Nullable ? ∂(φ) : ⊥) + /// ∂(R : φ) = applyCross(∂_ere(R), ∂(φ), + /// (R',d_φ) → atom(R':φ) ∨ (R'.Nullable ? d_φ : ⊥)) + /// ∂(R ⊳ φ) = lift(R' → R'⊳φ) (∂_ere(R)) ∧ (R.Nullable ? ∂(φ) : ⊤) + /// ∂(R ⊳⊳ φ) = applyCross(∂_ere(R), ∂(φ), + /// (R',d_φ) → atom(R'⊳⊳φ) ∧ (R'.Nullable ? d_φ : ⊤)) + /// + /// + public class RltlDerivative + { + private readonly IEffectiveBooleanAlgebra _eba; + private readonly ConditionRegistry _registry; + private readonly DnfAlgebra> _dnfAlgebra; + private readonly TransitionTermAlgebra>> _termAlgebra; + private readonly EreDerivative _ereDeriv; + private readonly EreEmptinessChecker _emptiness; + private readonly IEreCanonicalizer _ereCanon; + private readonly IRltlCanonicalizer _rltlCanon; + private readonly bool _distributePrefixUnion; + + public RltlDerivative( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry) + : this(eba, registry, null, null, true) + { + } + + public RltlDerivative( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + IEreCanonicalizer ereCanonicalizer) + : this(eba, registry, ereCanonicalizer, null, true) + { + } + + public RltlDerivative( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + IEreCanonicalizer ereCanonicalizer, + IRltlCanonicalizer rltlCanonicalizer) + : this(eba, registry, ereCanonicalizer, rltlCanonicalizer, true) + { + } + + /// + /// Full constructor exposing the Layer-A prefix-Union distribution + /// toggle. When is false, + /// derivative leaves wrap residual regexes in raw RLTL prefix atoms + /// (no (R₁+R₂):φ → R₁:φ ∨ R₂:φ distribution). Used by + /// benchmarks that need the un-distributed baseline as a control + /// for state-space measurements. + /// + public RltlDerivative( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + IEreCanonicalizer ereCanonicalizer, + IRltlCanonicalizer rltlCanonicalizer, + bool distributePrefixUnion) + { + _eba = eba ?? throw new ArgumentNullException(nameof(eba)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _dnfAlgebra = new DnfAlgebra>(RltlComparer.Instance); + _termAlgebra = new TransitionTermAlgebra>>( + eba, registry, _dnfAlgebra); + _ereDeriv = new EreDerivative(eba, registry); + _emptiness = new EreEmptinessChecker(_ereDeriv); + _ereCanon = ereCanonicalizer; + _rltlCanon = rltlCanonicalizer; + _distributePrefixUnion = distributePrefixUnion; + } + + public IEffectiveBooleanAlgebra Eba => _eba; + public ConditionRegistry Registry => _registry; + public DnfAlgebra> DnfAlgebra => _dnfAlgebra; + public TransitionTermAlgebra>> TermAlgebra => _termAlgebra; + public EreDerivative EreDerivative => _ereDeriv; + public EreEmptinessChecker Emptiness => _emptiness; + public IEreCanonicalizer EreCanonicalizer => _ereCanon; + public IRltlCanonicalizer RltlCanonicalizer => _rltlCanon; + + private Ere Canon(Ere r) => _ereCanon == null ? r : _ereCanon.Canonicalize(r); + + /// Computes ∂(φ) for a RLTL formula. + public TransitionTerm>> Derivative(Rltl formula) + { + switch (formula) + { + case RltlTrue _: return _termAlgebra.Top; + case RltlFalse _: return _termAlgebra.Bottom; + + case RltlAtom atom: + { + int idx = _registry.Register(atom.Predicate); + // Atoms only carry positive predicates; negation flowed + // into the EBA at formula-construction time. + return _termAlgebra.MkIte(idx, _termAlgebra.Top, _termAlgebra.Bottom); + } + + case RltlNext next: + return TransitionTerm>>.Leaf(ToDnfAtom(next.Inner)); + + case RltlUntil until: + { + var dPhi = Derivative(until.Left); + var dPsi = Derivative(until.Right); + var selfAtom = TransitionTerm>>.Leaf(_dnfAlgebra.Atom(formula)); + return _termAlgebra.Or(dPsi, _termAlgebra.And(dPhi, selfAtom)); + } + + case RltlRelease release: + { + var dPhi = Derivative(release.Left); + var dPsi = Derivative(release.Right); + var selfAtom = TransitionTerm>>.Leaf(_dnfAlgebra.Atom(formula)); + return _termAlgebra.Or(_termAlgebra.And(dPsi, selfAtom), _termAlgebra.And(dPhi, dPsi)); + } + + case RltlAnd and: + { + var result = Derivative(and.Operands[0]); + for (int i = 1; i < and.Operands.Count; i++) + result = _termAlgebra.And(result, Derivative(and.Operands[i])); + return result; + } + + case RltlOr or: + { + var result = Derivative(or.Operands[0]); + for (int i = 1; i < or.Operands.Count; i++) + result = _termAlgebra.Or(result, Derivative(or.Operands[i])); + return result; + } + + case RltlSeqPrefix seq: + { + var dR = _ereDeriv.Derivative(seq.Regex); + var lifted = _ereDeriv.TermAlgebra.MapUnary>>( + dR, r => ToDnfAtom(MkSeqPrefix(Canon(r), seq.Phi))); + if (seq.Regex.Nullable) + return _termAlgebra.Or(lifted, Derivative(seq.Phi)); + return lifted; + } + + case RltlOvlPrefix ovl: + { + var dR = _ereDeriv.Derivative(ovl.Regex); + var dPhi = Derivative(ovl.Phi); + return _ereDeriv.TermAlgebra.ApplyCross>, Dnf>>( + dR, dPhi, + (rPrime, dF) => + { + var atomDnf = ToDnfAtom(MkOvlPrefix(Canon(rPrime), ovl.Phi)); + if (rPrime.Nullable) + return _dnfAlgebra.Or(atomDnf, dF); + return atomDnf; + }, + _eba.Top); + } + + case RltlTrigger trig: + { + var dR = _ereDeriv.Derivative(trig.Regex); + var lifted = _ereDeriv.TermAlgebra.MapUnary>>( + dR, r => ToDnfAtom(MkTrigger(Canon(r), trig.Phi))); + if (trig.Regex.Nullable) + return _termAlgebra.And(lifted, Derivative(trig.Phi)); + return lifted; + } + + case RltlMatch mat: + { + var dR = _ereDeriv.Derivative(mat.Regex); + var dPhi = Derivative(mat.Phi); + return _ereDeriv.TermAlgebra.ApplyCross>, Dnf>>( + dR, dPhi, + (rPrime, dF) => + { + var atomDnf = ToDnfAtom(MkMatch(Canon(rPrime), mat.Phi)); + if (rPrime.Nullable) + return _dnfAlgebra.And(atomDnf, dF); + return atomDnf; + }, + _eba.Top); + } + + // Closures — JACM eq. (3010)–(3014). + // + // deriv({R}) = ite(Null(R), ⊤, {deriv(R)}) + // deriv({{R}}̄) = ite(Null(R), ⊥, {{deriv(R)}}̄) + // deriv({R}ω) = deriv(R ; X {R}ω) + // + // The Rltl.{WeakClosure, NegWeakClosure, OmegaClosure} + // factories apply syntactic shortcuts (EreEmpty, Nullable); we + // additionally apply the *semantic* emptiness check via the + // EreEmptinessChecker so that residuals which simplify to + // semantically-dead (but not syntactically EreEmpty) regexes + // become ⊥/⊤ instead of unreachable junk states. This is + // important for the correctness of IsAccepting (Acc(RLTL+): + // {R} ∈ Acc iff R alive; {{R}}̄ ∈ Acc iff R dead). + + case RltlWeakClosure wcl: + { + if (wcl.Regex.Nullable) return _termAlgebra.Top; + var dR = _ereDeriv.Derivative(wcl.Regex); + return _ereDeriv.TermAlgebra.MapUnary>>( + dR, r => ToDnfAtom(LiftWeakClosure(r))); + } + + case RltlNegWeakClosure nwcl: + { + if (nwcl.Regex.Nullable) return _termAlgebra.Bottom; + var dR = _ereDeriv.Derivative(nwcl.Regex); + return _ereDeriv.TermAlgebra.MapUnary>>( + dR, r => ToDnfAtom(LiftNegWeakClosure(r))); + } + + case RltlOmegaClosure ocl: + { + // deriv({R}ω) = deriv(R ; X{R}ω) + return Derivative( + Rltl.SeqPrefix(ocl.Regex, Rltl.Next(ocl))); + } + + default: + throw new ArgumentException($"Unknown RLTL: {formula.GetType()}"); + } + } + + /// Build {R'} with semantic dead-check. + private Rltl LiftWeakClosure(Ere r) + { + if (_emptiness.IsDead(r)) return Rltl.False(); + return Rltl.WeakClosure(Canon(r)); + } + + /// Build {{R'}}̄ with semantic dead-check. + private Rltl LiftNegWeakClosure(Ere r) + { + if (_emptiness.IsDead(r)) return Rltl.True(); + return Rltl.NegWeakClosure(Canon(r)); + } + + // Layer-A-toggle dispatch helpers. When _distributePrefixUnion is true + // these call the smart constructors that distribute Union; when false + // they fall back to the raw factories that keep the Union nested. + private Rltl MkSeqPrefix(Ere r, Rltl phi) + => _distributePrefixUnion + ? Rltl.SeqPrefix(r, phi) + : Rltl.SeqPrefixRaw(r, phi); + + private Rltl MkOvlPrefix(Ere r, Rltl phi) + => _distributePrefixUnion + ? Rltl.OvlPrefix(r, phi) + : Rltl.OvlPrefixRaw(r, phi); + + private Rltl MkTrigger(Ere r, Rltl phi) + => _distributePrefixUnion + ? Rltl.Trigger(r, phi) + : Rltl.TriggerRaw(r, phi); + + private Rltl MkMatch(Ere r, Rltl phi) + => _distributePrefixUnion + ? Rltl.Match(r, phi) + : Rltl.MatchRaw(r, phi); + + /// + /// Converts a Rltl formula into the corresponding Dnf leaf, handling + /// the structural ⊤/⊥ cases so that the resulting Dnf is canonical. + /// + private Dnf> ToDnfAtom(Rltl f) + { + if (f is RltlFalse) return _dnfAlgebra.Bottom; + if (f is RltlTrue) return _dnfAlgebra.Top; + if (_rltlCanon != null) + { + var canonF = _rltlCanon.Canonicalize(f); + if (canonF is RltlFalse) return _dnfAlgebra.Bottom; + if (canonF is RltlTrue) return _dnfAlgebra.Top; + f = canonF; + } + return _dnfAlgebra.Atom(f); + } + + /// + /// Accepting condition for the RLTL ABW (JACM Def. M-RLTL+, line 3032): + /// accepting iff the state carries no liveness obligation. + /// + /// ⊤, R⊳φ (uimpl), R⊳⊳φ, Release, all Boolean / Next over + /// non-liveness — accepting (no live obligation). + /// U, R;φ (eimpl), R:φ — non-accepting (have liveness). + /// {R} weak closure: accepting iff R is alive (semantic + /// check via ). + /// Dead-R weak closures should have been simplified to ⊥ by the + /// derivative pipeline, but we re-check defensively. + /// {{R}}̄ neg-weak closure: accepting iff R is dead + /// (the obligation has been discharged). + /// {R}ω ω-closure: always accepting (its obligation is + /// absorbed by the SeqPrefix unrolling in the derivative). + /// + /// + public bool IsAccepting(Rltl f) + { + switch (f) + { + case RltlUntil _: + case RltlSeqPrefix _: + case RltlOvlPrefix _: + return false; + case RltlWeakClosure w: + return _emptiness.IsAlive(w.Regex); + case RltlNegWeakClosure n: + return _emptiness.IsDead(n.Regex); + default: + return true; + } + } + + /// + /// Constructs a symbolic ABW for an RLTL formula. The resulting + /// automaton can be passed to or + /// the incremental + /// to obtain an NBW for model checking. + /// + public SymbolicABW> ToABW(Rltl formula) + { + return new SymbolicABW>( + _eba, _registry, _dnfAlgebra, + formula, + IsAccepting, + Derivative); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlDerivativeBisim.cs b/Accordant.ModelChecking/Symbolic/RltlDerivativeBisim.cs new file mode 100644 index 0000000..b4ec729 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlDerivativeBisim.cs @@ -0,0 +1,260 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Sound-but-incomplete bisimulation-based equivalence check on the + /// symbolic RLTL derivative graph (G8-a). Mirrors the shape of + /// but cannot achieve + /// completeness for ω-regular languages: alternating-Büchi acceptance is + /// determined by formula structure (Until / Release / closures) and is + /// not always preserved under arbitrary derivative-DAG bisimulation, and + /// determinisation is in general not available for ω-automata. + /// + /// Algorithm. Hopcroft–Karp-style union-find on RLTL + /// formula s: + /// + /// Initially union(p, q); push the pair onto a worklist. + /// For each pair (a, b) popped, compute ∂(a) and ∂(b) and + /// enumerate their transition-term leaves with path guards. + /// For every pair of leaves (g_a, D_a) and (g_b, D_b) whose + /// path guards conjoin to a satisfiable predicate, compare the + /// leaves under the current UF substitution. + /// If they coincide as canonical Dnfs after rep-rewriting, continue. + /// Otherwise attempt a single-atom merge (both Dnfs are single + /// singletons {x} vs {y}): union(x, y) and push (x, y). Any other + /// mismatch returns false (inconclusive). + /// If the worklist drains, return true: the bisim closed + /// and every observation from p induces the same configuration set as + /// from q under the partial bijection on derivative states. + /// + /// + /// Soundness. A successful bisim establishes that the two + /// formulas have isomorphic symbolic derivative DAGs modulo the chosen + /// state-merging partition; for the LTL fragment this implies + /// language equivalence. For full RLTL with closures and Büchi-style + /// acceptance the structural bisim is a sufficient but not + /// necessary witness — many equivalences (e.g. F G φ ≡ G F φ when + /// they happen to agree, complex regex-prefix rewritings) cannot be + /// shown by this technique. Differentially tested against the G8-b + /// oracle : every + /// formula pair on which this checker returns true is also + /// reported equivalent by the (sound+complete) oracle. + /// + /// Intended use. Cheap pre-check inside tableau/closure + /// construction (RLTL ABW state dedup, sub-formula sharing) where a + /// fast structural answer is preferable to invoking the full + /// emptiness-based oracle. + /// + public sealed class RltlDerivativeBisim + { + private readonly RltlDerivative _deriv; + private readonly IPredicateAlgebra _eba; + private readonly DnfAlgebra> _dnfAlgebra; + + public RltlDerivativeBisim(RltlDerivative deriv) + { + _deriv = deriv ?? throw new ArgumentNullException(nameof(deriv)); + _eba = deriv.Eba; + _dnfAlgebra = deriv.DnfAlgebra; + } + + /// + /// Attempts to prove L(p) = L(q) via structural bisimulation + /// on the derivative DAG. Returns true when the bisim closes + /// (sound: equivalent), false otherwise (incomplete: + /// inconclusive, NOT a proof of inequivalence). + /// + public bool TryProveEquivalent(Rltl p, Rltl q) + { + if (p == null) throw new ArgumentNullException(nameof(p)); + if (q == null) throw new ArgumentNullException(nameof(q)); + if (ReferenceEquals(p, q) || p.Equals(q)) return true; + + var uf = new IntUnionFind(); + var idMap = new Dictionary, int>(RltlEqualityComparer); + var visited = new HashSet<(int, int)>(); + var stack = new Stack<(Rltl a, Rltl b)>(); + + EnqueuePair(uf, idMap, visited, stack, p, q); + + while (stack.Count > 0) + { + var (a, b) = stack.Pop(); + int ia = GetId(idMap, a); + int ib = GetId(idMap, b); + if (uf.Find(ia) != uf.Find(ib)) uf.Union(ia, ib); + if (a.Equals(b)) continue; + + var da = _deriv.Derivative(a); + var db = _deriv.Derivative(b); + + var leavesA = EnumerateLeaves(da, _eba.Top).ToList(); + var leavesB = EnumerateLeaves(db, _eba.Top).ToList(); + + foreach (var (gA, DA) in leavesA) + { + foreach (var (gB, DB) in leavesB) + { + var gAnd = _eba.And(gA, gB); + if (!_eba.IsSatisfiable(gAnd)) continue; + if (!CompareDnf(DA, DB, uf, idMap, visited, stack)) return false; + } + } + } + return true; + } + + private static readonly IEqualityComparer> RltlEqualityComparer + = EqualityComparer>.Default; + + private static int GetId(Dictionary, int> idMap, Rltl f) + { + if (idMap.TryGetValue(f, out var id)) return id; + id = idMap.Count; + idMap[f] = id; + return id; + } + + // ---------- internals ---------- + + private bool CompareDnf( + Dnf> a, Dnf> b, + IntUnionFind uf, + Dictionary, int> idMap, + HashSet<(int, int)> visited, + Stack<(Rltl, Rltl)> stack) + { + if (a.Equals(b)) return true; + var rewA = RewriteUnderUF(a, uf, idMap); + var rewB = RewriteUnderUF(b, uf, idMap); + if (rewA.Equals(rewB)) return true; + + // Single-atom shortcut: both sides are {{x}} vs {{y}}. Try a + // candidate merge; the bisim will check it on the next iteration. + if (IsSingleAtom(a, out var pa) && IsSingleAtom(b, out var pb)) + { + int ia = GetId(idMap, pa); + int ib = GetId(idMap, pb); + if (uf.Find(ia) == uf.Find(ib)) return true; + EnqueuePair(uf, idMap, visited, stack, pa, pb); + return true; + } + + // Same clause shape, same per-clause arity: optimistically pair + // formulas by their order and enqueue any new pairs. Handles + // symmetric Dnfs whose clauses contain already-aligned formulas + // modulo a single residual mismatch. + if (rewA.ClauseCount == rewB.ClauseCount + && a.ClauseCount == b.ClauseCount) + { + bool madeProgress = false; + for (int i = 0; i < a.ClauseCount; i++) + { + var ca = a.Clauses[i].ToList(); + var cb = b.Clauses[i].ToList(); + if (ca.Count != cb.Count) return false; + for (int j = 0; j < ca.Count; j++) + { + var x = ca[j]; + var y = cb[j]; + int ix = GetId(idMap, x); + int iy = GetId(idMap, y); + if (uf.Find(ix) == uf.Find(iy)) continue; + if (!x.Equals(y)) + { + EnqueuePair(uf, idMap, visited, stack, x, y); + madeProgress = true; + } + } + } + if (madeProgress) return true; + } + return false; + } + + private static bool IsSingleAtom(Dnf> d, out Rltl atom) + { + atom = null; + if (d.ClauseCount != 1) return false; + var c = d.Clauses[0]; + if (c.Count != 1) return false; + atom = c.Single(); + return true; + } + + private Dnf> RewriteUnderUF( + Dnf> d, + IntUnionFind uf, + Dictionary, int> idMap) + { + if (d.IsTrue || d.IsFalse) return d; + var repCache = new Dictionary>(); + Rltl Rep(Rltl f) + { + int rid = uf.Find(GetId(idMap, f)); + if (repCache.TryGetValue(rid, out var r)) return r; + repCache[rid] = f; + return f; + } + // Register reps in deterministic id order so the result is + // independent of clause traversal order. + var allFormulas = new SortedDictionary>(); + foreach (var f in d.GetAllStates()) allFormulas[GetId(idMap, f)] = f; + foreach (var kv in allFormulas) Rep(kv.Value); + + var newClauses = new List>>(d.ClauseCount); + foreach (var clause in d.Clauses) + { + var mapped = new List>(clause.Count); + foreach (var f in clause) mapped.Add(Rep(f)); + newClauses.Add(new StateSet>(mapped, RltlComparer.Instance)); + } + return _dnfAlgebra.FromClauses(newClauses); + } + + private static void EnqueuePair( + IntUnionFind uf, + Dictionary, int> idMap, + HashSet<(int, int)> visited, + Stack<(Rltl, Rltl)> stack, + Rltl a, Rltl b) + { + int ia = GetId(idMap, a); + int ib = GetId(idMap, b); + if (a.Equals(b)) { uf.Union(ia, ib); return; } + int lo = Math.Min(ia, ib); + int hi = Math.Max(ia, ib); + if (!visited.Add((lo, hi))) return; + uf.Union(ia, ib); + stack.Push((a, b)); + } + + // BDD-style ITE leaf enumeration with path-guard accumulation — + // identical to EreEquivalenceChecker.EnumerateLeaves but typed for + // RLTL Dnf leaves. + private IEnumerable<(TPred guard, Dnf> leaf)> EnumerateLeaves( + TransitionTerm>> term, TPred pathGuard) + { + if (term is TransitionTermLeaf>> leaf) + { + yield return (pathGuard, leaf.Value); + yield break; + } + var ite = (TransitionTermIte>>)term; + var pred = _deriv.Registry.GetPredicate(ite.ConditionIndex); + + var hiGuard = _eba.And(pathGuard, pred); + if (_eba.IsSatisfiable(hiGuard)) + foreach (var t in EnumerateLeaves(ite.Hi, hiGuard)) + yield return t; + + var loGuard = _eba.And(pathGuard, _eba.Not(pred)); + if (_eba.IsSatisfiable(loGuard)) + foreach (var t in EnumerateLeaves(ite.Lo, loGuard)) + yield return t; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlJson.cs b/Accordant.ModelChecking/Symbolic/RltlJson.cs new file mode 100644 index 0000000..45e5a24 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlJson.cs @@ -0,0 +1,267 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// + /// JSON serialization for over string + /// predicates. Mirrors and extends it with the + /// RLTL-specific embedded-regex constructors that delegate to + /// . + /// + /// Format (superset of ): + /// + /// SeqPrefix R;φ → { "op": "SeqPrefix", "regex": <ERE-JSON>, "phi": ... } + /// OvlPrefix R:φ → { "op": "OvlPrefix", "regex": <ERE-JSON>, "phi": ... } + /// Trigger R⊳φ → { "op": "Trigger", "regex": <ERE-JSON>, "phi": ... } + /// Match R⊳⊳φ → { "op": "Match", "regex": <ERE-JSON>, "phi": ... } + /// WeakClosure {R} → { "op": "WeakClosure", "regex": <ERE-JSON> } + /// NegWeakClosure {{R}}̄ → { "op": "NegWeakClosure", "regex": <ERE-JSON> } + /// OmegaClosure {R}ω → { "op": "OmegaClosure", "regex": <ERE-JSON> } + /// + /// Predicates are opaque strings; deserialization re-runs RLTL's smart + /// constructors via with + /// . + /// + public static class RltlJson + { + private static readonly RltlAlgebra StringAlgebra = + new RltlAlgebra(StringFreeAlgebra.Instance); + + #region Serialization + + public static string Serialize(Rltl formula) + { + if (formula == null) throw new ArgumentNullException(nameof(formula)); + var sb = new StringBuilder(); + SerializeCore(formula, sb, 0); + return sb.ToString(); + } + + private static void SerializeCore(Rltl f, StringBuilder sb, int depth) + { + if (depth > 1000) + throw new InvalidOperationException("Formula nesting too deep (>1000)."); + + switch (f) + { + case RltlTrue _: sb.Append("{\"op\":\"True\"}"); break; + case RltlFalse _: sb.Append("{\"op\":\"False\"}"); break; + case RltlAtom a: + sb.Append("{\"op\":\"Atom\",\"pred\":"); + JsonUtil.AppendString(sb, a.Predicate); + sb.Append('}'); + break; + case RltlNext n: + sb.Append("{\"op\":\"Next\",\"inner\":"); + SerializeCore(n.Inner, sb, depth + 1); + sb.Append('}'); + break; + case RltlUntil u: + sb.Append("{\"op\":\"Until\",\"left\":"); + SerializeCore(u.Left, sb, depth + 1); + sb.Append(",\"right\":"); + SerializeCore(u.Right, sb, depth + 1); + sb.Append('}'); + break; + case RltlRelease r: + sb.Append("{\"op\":\"Release\",\"left\":"); + SerializeCore(r.Left, sb, depth + 1); + sb.Append(",\"right\":"); + SerializeCore(r.Right, sb, depth + 1); + sb.Append('}'); + break; + case RltlAnd a: + sb.Append("{\"op\":\"And\",\"args\":["); + for (int i = 0; i < a.Operands.Count; i++) + { + if (i > 0) sb.Append(','); + SerializeCore(a.Operands[i], sb, depth + 1); + } + sb.Append("]}"); + break; + case RltlOr o: + sb.Append("{\"op\":\"Or\",\"args\":["); + for (int i = 0; i < o.Operands.Count; i++) + { + if (i > 0) sb.Append(','); + SerializeCore(o.Operands[i], sb, depth + 1); + } + sb.Append("]}"); + break; + case RltlSeqPrefix s: EmitRegexAndPhi(sb, "SeqPrefix", s.Regex, s.Phi, depth); break; + case RltlOvlPrefix s: EmitRegexAndPhi(sb, "OvlPrefix", s.Regex, s.Phi, depth); break; + case RltlTrigger s: EmitRegexAndPhi(sb, "Trigger", s.Regex, s.Phi, depth); break; + case RltlMatch s: EmitRegexAndPhi(sb, "Match", s.Regex, s.Phi, depth); break; + case RltlWeakClosure w: EmitRegexOnly(sb, "WeakClosure", w.Regex, depth); break; + case RltlNegWeakClosure w: EmitRegexOnly(sb, "NegWeakClosure", w.Regex, depth); break; + case RltlOmegaClosure w: EmitRegexOnly(sb, "OmegaClosure", w.Regex, depth); break; + default: + throw new ArgumentException($"Unknown RLTL type: {f.GetType()}"); + } + } + + private static void EmitRegexAndPhi(StringBuilder sb, string op, + Ere regex, Rltl phi, int depth) + { + sb.Append("{\"op\":\""); sb.Append(op); sb.Append("\",\"regex\":"); + EreJson.SerializeCore(regex, sb, depth + 1); + sb.Append(",\"phi\":"); + SerializeCore(phi, sb, depth + 1); + sb.Append('}'); + } + + private static void EmitRegexOnly(StringBuilder sb, string op, + Ere regex, int depth) + { + sb.Append("{\"op\":\""); sb.Append(op); sb.Append("\",\"regex\":"); + EreJson.SerializeCore(regex, sb, depth + 1); + sb.Append('}'); + } + + #endregion + + #region Deserialization + + public static Rltl Deserialize(string json) + { + if (json == null) throw new ArgumentNullException(nameof(json)); + int pos = 0; + return ParseFormula(json, ref pos); + } + + private static Rltl ParseFormula(string json, ref int pos) + { + JsonUtil.SkipWhitespace(json, ref pos); + JsonUtil.Expect(json, ref pos, '{'); + JsonUtil.SkipWhitespace(json, ref pos); + var fields = JsonUtil.ParseFields(json, ref pos); + + if (!fields.TryGetValue("op", out var op)) + throw new FormatException("Missing 'op' field in RLTL JSON."); + + switch (op) + { + case "True": return StringAlgebra.True; + case "False": return StringAlgebra.False; + case "Atom": + { + if (!fields.TryGetValue("pred", out var pred)) + throw new FormatException("Atom missing 'pred' field."); + bool neg = fields.TryGetValue("neg", out var nv) && nv == "true"; + return neg ? StringAlgebra.NegAtom(pred) : StringAlgebra.Atom(pred); + } + case "Next": + { + var inner = ParseInner(fields, "Next"); + return StringAlgebra.Next(inner); + } + case "Until": + { + var (l, r) = ParseBinary(fields, "Until"); + return StringAlgebra.Until(l, r); + } + case "Release": + { + var (l, r) = ParseBinary(fields, "Release"); + return StringAlgebra.Release(l, r); + } + case "Eventually": return StringAlgebra.Eventually(ParseInner(fields, "Eventually")); + case "Globally": return StringAlgebra.Globally(ParseInner(fields, "Globally")); + case "And": + { + var args = ParseArgs(fields, "And"); + var r = args[0]; + for (int i = 1; i < args.Count; i++) r = StringAlgebra.And(r, args[i]); + return r; + } + case "Or": + { + var args = ParseArgs(fields, "Or"); + var r = args[0]; + for (int i = 1; i < args.Count; i++) r = StringAlgebra.Or(r, args[i]); + return r; + } + case "Implies": + { + var (l, r) = ParseBinary(fields, "Implies"); + return StringAlgebra.Or(StringAlgebra.Not(l), r); + } + case "SeqPrefix": return StringAlgebra.SeqPrefix(ParseRegex(fields, "SeqPrefix"), ParsePhi(fields, "SeqPrefix")); + case "OvlPrefix": return StringAlgebra.OvlPrefix(ParseRegex(fields, "OvlPrefix"), ParsePhi(fields, "OvlPrefix")); + case "Trigger": return StringAlgebra.Trigger(ParseRegex(fields, "Trigger"), ParsePhi(fields, "Trigger")); + case "Match": return StringAlgebra.Match(ParseRegex(fields, "Match"), ParsePhi(fields, "Match")); + case "WeakClosure": return StringAlgebra.WeakClosure(ParseRegex(fields, "WeakClosure")); + case "NegWeakClosure": return StringAlgebra.NegWeakClosure(ParseRegex(fields, "NegWeakClosure")); + case "OmegaClosure": return StringAlgebra.OmegaClosure(ParseRegex(fields, "OmegaClosure")); + default: + throw new FormatException($"Unknown RLTL op: '{op}'."); + } + } + + private static Rltl ParseInner(Dictionary fields, string opName) + { + if (!fields.TryGetValue("inner", out var j)) + throw new FormatException($"{opName} missing 'inner' field."); + int p = 0; + return ParseFormula(j, ref p); + } + + private static (Rltl, Rltl) ParseBinary( + Dictionary fields, string opName) + { + if (!fields.TryGetValue("left", out var leftJson)) + throw new FormatException($"{opName} missing 'left' field."); + if (!fields.TryGetValue("right", out var rightJson)) + throw new FormatException($"{opName} missing 'right' field."); + int p1 = 0, p2 = 0; + return (ParseFormula(leftJson, ref p1), ParseFormula(rightJson, ref p2)); + } + + private static List> ParseArgs( + Dictionary fields, string opName) + { + if (!fields.TryGetValue("args", out var argsJson)) + throw new FormatException($"{opName} missing 'args' field."); + var result = new List>(); + int pos = 0; + JsonUtil.SkipWhitespace(argsJson, ref pos); + JsonUtil.Expect(argsJson, ref pos, '['); + JsonUtil.SkipWhitespace(argsJson, ref pos); + if (pos < argsJson.Length && argsJson[pos] != ']') + { + result.Add(ParseFormula(argsJson, ref pos)); + JsonUtil.SkipWhitespace(argsJson, ref pos); + while (pos < argsJson.Length && argsJson[pos] == ',') + { + pos++; + JsonUtil.SkipWhitespace(argsJson, ref pos); + result.Add(ParseFormula(argsJson, ref pos)); + JsonUtil.SkipWhitespace(argsJson, ref pos); + } + } + if (result.Count == 0) + throw new FormatException($"{opName} must have at least one argument."); + return result; + } + + private static Ere ParseRegex(Dictionary fields, string opName) + { + if (!fields.TryGetValue("regex", out var rJson)) + throw new FormatException($"{opName} missing 'regex' field."); + int p = 0; + return EreJson.ParseRegex(rJson, ref p); + } + + private static Rltl ParsePhi(Dictionary fields, string opName) + { + if (!fields.TryGetValue("phi", out var pJson)) + throw new FormatException($"{opName} missing 'phi' field."); + int p = 0; + return ParseFormula(pJson, ref p); + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlLanguageEquivalence.cs b/Accordant.ModelChecking/Symbolic/RltlLanguageEquivalence.cs new file mode 100644 index 0000000..6d10bcb --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlLanguageEquivalence.cs @@ -0,0 +1,106 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Sound and complete language equivalence for + /// formulas — the trusted oracle. Implements equivalence as two-way + /// inclusion via emptiness on the Boolean closure of the input formulas: + /// + /// + /// Equiv(φ, ψ) ⟺ L(φ ∧ ¬ψ) = ∅ ∧ L(¬φ ∧ ψ) = ∅ + /// + /// + /// + /// RLTL is closed under Boolean ops (see ), + /// so the conjunctions φ ∧ ¬ψ and ¬φ ∧ ψ are themselves + /// RLTL formulas. For each one we build an alternating Büchi automaton + /// via , eliminate + /// alternation incrementally (), + /// and decide language emptiness of the resulting NBW with + /// . + /// + /// + /// + /// Precision: results are sound and complete modulo the precision + /// of . With a + /// conservative-true IsSatisfiable, the answer characterises symbolic + /// equivalence over independent predicate symbols (every distinct + /// predicate is treated as satisfiable); with a precise IsSatisfiable + /// (e.g. a Z3-backed EBA), the answer is true semantic equivalence. + /// + /// + /// + /// This is the "trusted oracle" baseline for cheaper but incomplete + /// equivalence procedures such as the planned RLTL derivative-bisimulation + /// check (G8-a). + /// + /// + public static class RltlLanguageEquivalence + { + /// + /// Returns true iff L(φ) = L(ψ) modulo + /// algebra.Eba.IsSatisfiable precision. + /// + public static bool AreEquivalent( + IEffectiveBooleanAlgebra eba, + RltlAlgebra algebra, + Rltl phi, + Rltl psi) + { + if (eba == null) throw new ArgumentNullException(nameof(eba)); + if (algebra == null) throw new ArgumentNullException(nameof(algebra)); + if (phi == null) throw new ArgumentNullException(nameof(phi)); + if (psi == null) throw new ArgumentNullException(nameof(psi)); + + if (ReferenceEquals(phi, psi)) return true; + return Includes(eba, algebra, phi, psi) + && Includes(eba, algebra, psi, phi); + } + + /// + /// Returns true iff L(φ) ⊆ L(ψ) modulo + /// algebra.Eba.IsSatisfiable precision. + /// Implemented as IsLanguageEmpty(φ ∧ ¬ψ). + /// + public static bool Includes( + IEffectiveBooleanAlgebra eba, + RltlAlgebra algebra, + Rltl phi, + Rltl psi) + { + if (eba == null) throw new ArgumentNullException(nameof(eba)); + if (algebra == null) throw new ArgumentNullException(nameof(algebra)); + var diff = algebra.And(phi, algebra.Not(psi)); + return IsLanguageEmpty(eba, diff); + } + + /// + /// Returns true iff L(φ) = ∅ modulo + /// eba.IsSatisfiable precision. The pipeline is: + /// RLTL φ → ABW → (incremental Æ) → lazy NBW → NDFS emptiness. + /// + public static bool IsLanguageEmpty( + IEffectiveBooleanAlgebra eba, + Rltl phi) + { + if (eba == null) throw new ArgumentNullException(nameof(eba)); + if (phi == null) throw new ArgumentNullException(nameof(phi)); + + // Constant short-circuits avoid building an automaton for trivial cases. + if (phi is RltlFalse) return true; + if (phi is RltlTrue) return false; + + var registry = new ConditionRegistry( + EqualityComparer.Default); + var derivative = new RltlDerivative(eba, registry); + var abw = derivative.ToABW(phi); + var incAE = new IncrementalAE>(abw); + var nbw = incAE.ToNBW(); + + return SymbolicNbwEmptiness.IsEmpty( + nbw, eba, BreakpointState>.GetEqualityComparer()); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlMacrostateTransitionMerge.cs b/Accordant.ModelChecking/Symbolic/RltlMacrostateTransitionMerge.cs new file mode 100644 index 0000000..6c4a03d --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlMacrostateTransitionMerge.cs @@ -0,0 +1,101 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Macrostate reducer based on transition-term equality (JACM + /// Example 5.1 state-reduction lemma): two universal copies + /// q₁, q₂ ∈ S are interchangeable in the breakpoint + /// construction whenever + /// + /// they have identical transition terms + /// δ(q₁) ≡ δ(q₂) (weak equivalence collapses to + /// syntactic equality in the simple case, which is what we + /// check here — the ADD nodes returned by + /// + /// are interned so equal terms are reference-equal + /// objects), and + /// they share the same colour per + /// (same membership in the ABW's co-Büchi rejecting set + /// F), which preserves the breakpoint obligation + /// tracking. + /// + /// + /// + /// One representative is kept per equivalence class, chosen as the + /// element minimal under the macrostate's own + /// (deterministic, + /// idempotent). The resulting + /// carries a representative map so that + /// can + /// forward obligations from dropped states onto their surviving + /// representative — this is necessary for soundness because the + /// dropped state might have been the only carrier of a particular + /// breakpoint obligation. + /// + /// + /// + /// Unlike a language-subsumption rule, this check is purely + /// structural: identical transition terms imply identical futures + /// by construction, so the reduction is sound without any + /// language-theoretic argument and triggers exactly when JACM + /// Example 5.1 says it should — including on + /// GFa ∧ GF(¬a), where after a few steps the two universal + /// copies of F· and GF· produce coinciding derivative + /// terms. + /// + /// + public sealed class RltlMacrostateTransitionMerge + { + private readonly Func, TransitionTerm>>> _getDelta; + + /// + /// Construct with a per-state transition lookup. Typical usage + /// passes abw.GetTransition so the cached interned terms + /// are reused. + /// + public RltlMacrostateTransitionMerge( + Func, TransitionTerm>>> getDelta) + { + _getDelta = getDelta ?? throw new ArgumentNullException(nameof(getDelta)); + } + + /// + /// Bucket by (δ(q), colour(q)) and + /// keep the comparator-minimum element per bucket; map every + /// dropped state to its bucket's representative. + /// + public MacroReduction> Reduce(StateSet> s) + { + if (s.Count <= 1) return MacroReduction>.Identity(s); + var comparer = s.Comparer; + + // First pass: pick a representative per bucket. + var reps = new Dictionary<(TransitionTerm>>, bool), Rltl>(); + foreach (var q in s) + { + var key = (_getDelta(q), RltlColour.IsRejecting(q)); + if (!reps.TryGetValue(key, out var current) + || comparer.Compare(q, current) < 0) + { + reps[key] = q; + } + } + if (reps.Count == s.Count) return MacroReduction>.Identity(s); + + // Second pass: build the rep map (only for non-survivors). + var repMap = new Dictionary, Rltl>(); + foreach (var q in s) + { + var rep = reps[(_getDelta(q), RltlColour.IsRejecting(q))]; + if (!ReferenceEquals(rep, q) && !rep.Equals(q)) + repMap[q] = rep; + } + + return new MacroReduction>( + new StateSet>(reps.Values, comparer), + repMap); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/RltlSExpr.cs b/Accordant.ModelChecking/Symbolic/RltlSExpr.cs new file mode 100644 index 0000000..356db47 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/RltlSExpr.cs @@ -0,0 +1,452 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Codec for embedding a predicate as an + /// S-expression. Atoms in ERE / RLTL formulas carry a predicate value; + /// the codec is the extension point that lets the DSL serialise and + /// parse those predicates without knowing the concrete type. + /// + public interface IPredicateCodec + { + /// Serialise a predicate to an S-expression. + SExpr Print(TPred predicate); + /// Parse an S-expression back to a predicate. + TPred Parse(SExpr expr); + } + + /// + /// Default codec for -valued predicates: prints as a + /// bare atom (auto-quoted when it contains delimiters) and parses any + /// by returning its raw . + /// Useful as the "eager fallback" requested by the DSL todo when no + /// structured predicate codec is available. + /// + public sealed class StringPredicateCodec : IPredicateCodec + { + public static readonly StringPredicateCodec Instance = new StringPredicateCodec(); + public SExpr Print(string predicate) => new SAtom(predicate ?? string.Empty); + public string Parse(SExpr expr) + { + if (expr is SAtom a) return a.Value; + throw new FormatException($"Expected a string predicate atom, got list: {expr}"); + } + } + + /// + /// S-expression surface DSL for ERE and RLTL formulas. The vocabulary is + /// deliberately small and orthogonal: + /// + /// ERE forms (constructors of ): + /// + /// emptythe empty language + /// epsthe empty word + /// (atom <pred>)predicate atom + /// (concat r1 r2 ...)n-ary, left-associated + /// (union r1 r2 ...)n-ary union + /// (inter r1 r2 ...)n-ary intersection + /// (star r)Kleene star + /// (comp r)complement + /// (fusion r1 r2)fusion (binary) + /// (xor r1 r2)symmetric difference (binary) + /// + /// + /// RLTL forms (constructors of ): + /// + /// true / falseconstants + /// (atom <pred>)predicate atom + /// (X phi)next; alias (next phi) + /// (U l r)until; alias (until l r) + /// (R l r)release; alias (release l r) + /// (F phi)eventually = (U true phi) + /// (G phi)globally = (R false phi) + /// (and phi1 phi2 ...)n-ary conjunction + /// (or phi1 phi2 ...)n-ary disjunction + /// (seq R phi)R ; phi + /// (ovl R phi)R : phi + /// (trig R phi)R ⊳ phi + /// (match R phi)R ⊳⊳ phi + /// (wcl R)weak closure {R} + /// (nwcl R)negated weak closure + /// (ocl R)ω-closure {R}ω + /// + /// + /// + /// Predicates are embedded via an + /// supplied by the caller. The printer guarantees a round-trip: the + /// output of + /// parsed back via + /// yields a structurally equal expression (the same holds for RLTL). + /// + /// + public static class RltlSExpr + { + // ───────────────────────────────────────────────────────────────── + // ERE → SExpr + // ───────────────────────────────────────────────────────────────── + + public static SExpr ToSExpr(Ere ere, IPredicateCodec codec) + { + if (ere == null) throw new ArgumentNullException(nameof(ere)); + if (codec == null) throw new ArgumentNullException(nameof(codec)); + return EreTo(ere, codec); + } + + private static SExpr EreTo(Ere ere, IPredicateCodec codec) + { + switch (ere) + { + case EreEmpty _: return new SAtom("empty"); + case EreEpsilon _: return new SAtom("eps"); + case EreAtom a: return new SList(new SAtom("atom"), codec.Print(a.Predicate)); + case EreConcat c: return Flatten("concat", c, codec); + case EreUnion u: return Nary("union", u.Operands, codec); + case EreIntersect i:return Nary("inter", i.Operands, codec); + case EreComplement n: return new SList(new SAtom("comp"), EreTo(n.Inner, codec)); + case EreStar s: return new SList(new SAtom("star"), EreTo(s.Inner, codec)); + case EreFusion f: return new SList(new SAtom("fusion"), EreTo(f.Left, codec), EreTo(f.Right, codec)); + case EreXor x: + var head = new SAtom(x.Negated ? "xnor" : "xor"); + var items = new SExpr[x.Operands.Count + 1]; + items[0] = head; + for (int i = 0; i < x.Operands.Count; i++) items[i + 1] = EreTo(x.Operands[i], codec); + return new SList(items); + default: throw new ArgumentException($"Unknown ERE node type: {ere.GetType().Name}"); + } + } + + private static SExpr Nary(string head, IReadOnlyList> ops, IPredicateCodec codec) + { + var items = new SExpr[ops.Count + 1]; + items[0] = new SAtom(head); + for (int i = 0; i < ops.Count; i++) items[i + 1] = EreTo(ops[i], codec); + return new SList(items); + } + + // EreConcat is binary in the AST; collect a left-associated chain + // into a flat (concat ...) form for cleaner output. + private static SExpr Flatten(string head, EreConcat top, IPredicateCodec codec) + { + var collected = new List>(); + void Walk(Ere e) + { + if (e is EreConcat c) { Walk(c.Left); Walk(c.Right); } + else collected.Add(e); + } + Walk(top); + var items = new SExpr[collected.Count + 1]; + items[0] = new SAtom(head); + for (int i = 0; i < collected.Count; i++) items[i + 1] = EreTo(collected[i], codec); + return new SList(items); + } + + // ───────────────────────────────────────────────────────────────── + // SExpr → ERE + // ───────────────────────────────────────────────────────────────── + + public static Ere EreFromSExpr(SExpr expr, IPredicateCodec codec) + { + if (expr == null) throw new ArgumentNullException(nameof(expr)); + if (codec == null) throw new ArgumentNullException(nameof(codec)); + return ParseEreS(expr, codec); + } + + private static Ere ParseEreS(SExpr expr, IPredicateCodec codec) + { + if (expr is SAtom a) + { + switch (a.Value) + { + case "empty": return EreEmpty.Instance; + case "eps": return EreEpsilon.Instance; + default: + throw new FormatException( + $"Unknown ERE atom '{a.Value}'. Expected 'empty', 'eps', or a parenthesised form."); + } + } + var list = (SList)expr; + if (list.Items.Count == 0) + throw new FormatException("Empty list is not a valid ERE form."); + var head = ExpectAtom(list.Items[0], "ERE head"); + switch (head) + { + case "atom": + Expect(list, 2, "atom"); + return Ere.Atom(codec.Parse(list.Items[1])); + case "concat": + return FoldBinaryEre(list, codec, Ere.Concat, minArity: 1); + case "union": + return FoldBinaryEre(list, codec, Ere.Union, minArity: 1); + case "inter": + return FoldBinaryEre(list, codec, Ere.Intersect, minArity: 1); + case "comp": + Expect(list, 2, "comp"); + return Ere.Complement(ParseEreS(list.Items[1], codec)); + case "star": + Expect(list, 2, "star"); + return Ere.Star(ParseEreS(list.Items[1], codec)); + case "fusion": + Expect(list, 3, "fusion"); + return Ere.Fusion( + ParseEreS(list.Items[1], codec), + ParseEreS(list.Items[2], codec)); + case "xor": + return FoldBinaryEre(list, codec, Ere.Xor, minArity: 2); + case "xnor": + return FoldBinaryEre(list, codec, Ere.Xnor, minArity: 2); + default: + throw new FormatException($"Unknown ERE head '{head}'."); + } + } + + private static Ere FoldBinaryEre( + SList list, IPredicateCodec codec, + Func, Ere, Ere> combine, + int minArity) + { + int arity = list.Items.Count - 1; + if (arity < minArity) + throw new FormatException( + $"'{((SAtom)list.Items[0]).Value}' expects at least {minArity} operand(s), got {arity}."); + var acc = ParseEreS(list.Items[1], codec); + for (int i = 2; i < list.Items.Count; i++) + acc = combine(acc, ParseEreS(list.Items[i], codec)); + return acc; + } + + // ───────────────────────────────────────────────────────────────── + // RLTL → SExpr + // ───────────────────────────────────────────────────────────────── + + public static SExpr ToSExpr(Rltl phi, IPredicateCodec codec) + { + if (phi == null) throw new ArgumentNullException(nameof(phi)); + if (codec == null) throw new ArgumentNullException(nameof(codec)); + return RltlTo(phi, codec); + } + + private static SExpr RltlTo(Rltl phi, IPredicateCodec codec) + { + switch (phi) + { + case RltlTrue _: return new SAtom("true"); + case RltlFalse _: return new SAtom("false"); + case RltlAtom a: return new SList(new SAtom("atom"), codec.Print(a.Predicate)); + case RltlNext n: return new SList(new SAtom("X"), RltlTo(n.Inner, codec)); + case RltlUntil u when u.Left is RltlTrue: + return new SList(new SAtom("F"), RltlTo(u.Right, codec)); + case RltlRelease r when r.Left is RltlFalse: + return new SList(new SAtom("G"), RltlTo(r.Right, codec)); + case RltlUntil u: + return new SList(new SAtom("U"), RltlTo(u.Left, codec), RltlTo(u.Right, codec)); + case RltlRelease r: + return new SList(new SAtom("R"), RltlTo(r.Left, codec), RltlTo(r.Right, codec)); + case RltlAnd a: return RltlNary("and", a.Operands, codec); + case RltlOr o: return RltlNary("or", o.Operands, codec); + case RltlSeqPrefix s: + return new SList(new SAtom("seq"), EreTo(s.Regex, codec), RltlTo(s.Phi, codec)); + case RltlOvlPrefix v: + return new SList(new SAtom("ovl"), EreTo(v.Regex, codec), RltlTo(v.Phi, codec)); + case RltlTrigger t: + return new SList(new SAtom("trig"), EreTo(t.Regex, codec), RltlTo(t.Phi, codec)); + case RltlMatch m: + return new SList(new SAtom("match"), EreTo(m.Regex, codec), RltlTo(m.Phi, codec)); + case RltlWeakClosure w: + return new SList(new SAtom("wcl"), EreTo(w.Regex, codec)); + case RltlNegWeakClosure nw: + return new SList(new SAtom("nwcl"), EreTo(nw.Regex, codec)); + case RltlOmegaClosure oc: + return new SList(new SAtom("ocl"), EreTo(oc.Regex, codec)); + default: + throw new ArgumentException($"Unknown RLTL node type: {phi.GetType().Name}"); + } + } + + private static SExpr RltlNary(string head, IReadOnlyList> ops, IPredicateCodec codec) + { + var items = new SExpr[ops.Count + 1]; + items[0] = new SAtom(head); + for (int i = 0; i < ops.Count; i++) items[i + 1] = RltlTo(ops[i], codec); + return new SList(items); + } + + // ───────────────────────────────────────────────────────────────── + // SExpr → RLTL + // ───────────────────────────────────────────────────────────────── + + public static Rltl RltlFromSExpr(SExpr expr, IPredicateCodec codec) + { + if (expr == null) throw new ArgumentNullException(nameof(expr)); + if (codec == null) throw new ArgumentNullException(nameof(codec)); + return ParseRltlS(expr, codec); + } + + private static Rltl ParseRltlS(SExpr expr, IPredicateCodec codec) + { + if (expr is SAtom a) + { + switch (a.Value) + { + case "true": return RltlTrue.Instance; + case "false": return RltlFalse.Instance; + default: + throw new FormatException( + $"Unknown RLTL atom '{a.Value}'. Expected 'true', 'false', or a parenthesised form."); + } + } + var list = (SList)expr; + if (list.Items.Count == 0) + throw new FormatException("Empty list is not a valid RLTL form."); + var head = ExpectAtom(list.Items[0], "RLTL head"); + switch (head) + { + case "atom": + Expect(list, 2, "atom"); + return Rltl.Atom(codec.Parse(list.Items[1])); + case "X": + case "next": + Expect(list, 2, head); + return Rltl.Next(ParseRltlS(list.Items[1], codec)); + case "U": + case "until": + Expect(list, 3, head); + return Rltl.Until( + ParseRltlS(list.Items[1], codec), + ParseRltlS(list.Items[2], codec)); + case "R": + case "release": + Expect(list, 3, head); + return Rltl.Release( + ParseRltlS(list.Items[1], codec), + ParseRltlS(list.Items[2], codec)); + case "F": + Expect(list, 2, head); + return Rltl.Eventually(ParseRltlS(list.Items[1], codec)); + case "G": + Expect(list, 2, head); + return Rltl.Globally(ParseRltlS(list.Items[1], codec)); + case "and": + return BuildRltlAnd(ParseRltlOperands(list, codec)); + case "or": + return BuildRltlOr(ParseRltlOperands(list, codec)); + case "seq": + Expect(list, 3, head); + return Rltl.SeqPrefix( + ParseEreS(list.Items[1], codec), + ParseRltlS(list.Items[2], codec)); + case "ovl": + Expect(list, 3, head); + return Rltl.OvlPrefix( + ParseEreS(list.Items[1], codec), + ParseRltlS(list.Items[2], codec)); + case "trig": + Expect(list, 3, head); + return Rltl.Trigger( + ParseEreS(list.Items[1], codec), + ParseRltlS(list.Items[2], codec)); + case "match": + Expect(list, 3, head); + return Rltl.Match( + ParseEreS(list.Items[1], codec), + ParseRltlS(list.Items[2], codec)); + case "wcl": + Expect(list, 2, head); + return Rltl.WeakClosure(ParseEreS(list.Items[1], codec)); + case "nwcl": + Expect(list, 2, head); + return Rltl.NegWeakClosure(ParseEreS(list.Items[1], codec)); + case "ocl": + Expect(list, 2, head); + return Rltl.OmegaClosure(ParseEreS(list.Items[1], codec)); + default: + throw new FormatException($"Unknown RLTL head '{head}'."); + } + } + + private static Rltl[] ParseRltlOperands(SList list, IPredicateCodec codec) + { + var ops = new Rltl[list.Items.Count - 1]; + for (int i = 1; i < list.Items.Count; i++) + ops[i - 1] = ParseRltlS(list.Items[i], codec); + return ops; + } + + // ACI-normalized RLTL And construction without atom fusion. The DSL + // is a faithful syntactic surface; semantic atom fusion is the job of + // when the caller has an EBA in hand. + private static Rltl BuildRltlAnd(IReadOnlyList> ops) + { + var set = new SortedSet>(RltlComparer.Instance); + foreach (var op in ops) + { + if (op is RltlFalse) return RltlFalse.Instance; + if (op is RltlTrue) continue; + if (op is RltlAnd a) foreach (var sub in a.Operands) set.Add(sub); + else set.Add(op); + } + if (set.Count == 0) return RltlTrue.Instance; + if (set.Count == 1) + { + foreach (var only in set) return only; + } + var arr = new Rltl[set.Count]; + set.CopyTo(arr); + return Rltl.DefaultBuilder.Intern(new RltlAnd(arr)); + } + + private static Rltl BuildRltlOr(IReadOnlyList> ops) + { + var set = new SortedSet>(RltlComparer.Instance); + foreach (var op in ops) + { + if (op is RltlTrue) return RltlTrue.Instance; + if (op is RltlFalse) continue; + if (op is RltlOr o) foreach (var sub in o.Operands) set.Add(sub); + else set.Add(op); + } + if (set.Count == 0) return RltlFalse.Instance; + if (set.Count == 1) + { + foreach (var only in set) return only; + } + var arr = new Rltl[set.Count]; + set.CopyTo(arr); + return Rltl.DefaultBuilder.Intern(new RltlOr(arr)); + } + + // ───────────────────────────────────────────────────────────────── + // String convenience facade + // ───────────────────────────────────────────────────────────────── + + public static string PrintEre(Ere ere, IPredicateCodec codec) + => SExprPrinter.Print(ToSExpr(ere, codec)); + + public static string PrintRltl(Rltl phi, IPredicateCodec codec) + => SExprPrinter.Print(ToSExpr(phi, codec)); + + public static Ere ParseEre(string text, IPredicateCodec codec) + => EreFromSExpr(SExprParser.Parse(text), codec); + + public static Rltl ParseRltl(string text, IPredicateCodec codec) + => RltlFromSExpr(SExprParser.Parse(text), codec); + + // ───────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────── + + private static void Expect(SList list, int expectedCount, string head) + { + if (list.Items.Count != expectedCount) + throw new FormatException( + $"'{head}' expects {expectedCount - 1} operand(s), got {list.Items.Count - 1}."); + } + + private static string ExpectAtom(SExpr expr, string ctx) + { + if (expr is SAtom a && !a.Quoted) return a.Value; + throw new FormatException($"Expected {ctx} (bare atom), got: {expr}"); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/SExpr.cs b/Accordant.ModelChecking/Symbolic/SExpr.cs new file mode 100644 index 0000000..3dd7ba6 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/SExpr.cs @@ -0,0 +1,245 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// + /// S-expression abstract syntax. Either an atomic token + /// () or a parenthesised list of sub-expressions + /// (). The shared base supports a simple writer-style + /// printer; richer formatting lives in . + /// + public abstract class SExpr + { + /// Parses an S-expression from a string. Throws . + public static SExpr Parse(string text) => SExprParser.Parse(text); + + /// Single-line canonical print. + public override string ToString() => SExprPrinter.Print(this); + + public abstract bool DeepEquals(SExpr other); + } + + /// An atomic S-expression token (identifier, number, or quoted string). + public sealed class SAtom : SExpr + { + /// The token text exactly as it should be emitted (unquoted form). + public string Value { get; } + /// When true the atom must be emitted as a quoted string literal. + public bool Quoted { get; } + + public SAtom(string value, bool quoted = false) + { + Value = value ?? throw new ArgumentNullException(nameof(value)); + Quoted = quoted || NeedsQuoting(value); + } + + public override bool DeepEquals(SExpr other) + => other is SAtom a && a.Value == Value && a.Quoted == Quoted; + + internal static bool NeedsQuoting(string s) + { + if (s.Length == 0) return true; + foreach (var c in s) + { + if (char.IsWhiteSpace(c) || c == '(' || c == ')' || c == '"' || c == ';' || c == '\\') + return true; + } + return false; + } + } + + /// A parenthesised list of S-expressions. + public sealed class SList : SExpr + { + public IReadOnlyList Items { get; } + public SList(params SExpr[] items) + : this((IReadOnlyList)items ?? throw new ArgumentNullException(nameof(items))) { } + public SList(IReadOnlyList items) + { + Items = items ?? throw new ArgumentNullException(nameof(items)); + } + public override bool DeepEquals(SExpr other) + { + if (!(other is SList l) || l.Items.Count != Items.Count) return false; + for (int i = 0; i < Items.Count; i++) + if (!Items[i].DeepEquals(l.Items[i])) return false; + return true; + } + } + + /// + /// Hand-written recursive-descent S-expression parser. Supports + /// identifiers, signed integers, double-quoted strings with C-style + /// escapes (\\, \", \n, \t, \r), + /// and semicolon-to-EOL comments. + /// + public static class SExprParser + { + public static SExpr Parse(string text) + { + if (text == null) throw new ArgumentNullException(nameof(text)); + int pos = 0; + SkipWs(text, ref pos); + var expr = ParseExpr(text, ref pos); + SkipWs(text, ref pos); + if (pos != text.Length) + throw new FormatException( + $"Unexpected trailing input at position {pos}: '{text.Substring(pos, Math.Min(20, text.Length - pos))}'"); + return expr; + } + + /// Parses a sequence of S-expressions (zero or more). + public static IReadOnlyList ParseMany(string text) + { + if (text == null) throw new ArgumentNullException(nameof(text)); + var results = new List(); + int pos = 0; + while (true) + { + SkipWs(text, ref pos); + if (pos >= text.Length) break; + results.Add(ParseExpr(text, ref pos)); + } + return results; + } + + private static SExpr ParseExpr(string text, ref int pos) + { + SkipWs(text, ref pos); + if (pos >= text.Length) + throw new FormatException("Unexpected end of input."); + var c = text[pos]; + if (c == '(') return ParseList(text, ref pos); + if (c == ')') throw new FormatException($"Unexpected ')' at position {pos}."); + if (c == '"') return ParseString(text, ref pos); + return ParseAtom(text, ref pos); + } + + private static SList ParseList(string text, ref int pos) + { + pos++; // consume '(' + var items = new List(); + while (true) + { + SkipWs(text, ref pos); + if (pos >= text.Length) + throw new FormatException("Unclosed '(': end of input."); + if (text[pos] == ')') { pos++; return new SList(items); } + items.Add(ParseExpr(text, ref pos)); + } + } + + private static SAtom ParseString(string text, ref int pos) + { + pos++; // consume opening " + var sb = new StringBuilder(); + while (pos < text.Length) + { + var c = text[pos++]; + if (c == '"') return new SAtom(sb.ToString(), quoted: true); + if (c == '\\') + { + if (pos >= text.Length) + throw new FormatException("Unterminated escape sequence at end of input."); + var esc = text[pos++]; + switch (esc) + { + case '\\': sb.Append('\\'); break; + case '"': sb.Append('"'); break; + case 'n': sb.Append('\n'); break; + case 't': sb.Append('\t'); break; + case 'r': sb.Append('\r'); break; + default: sb.Append(esc); break; + } + } + else sb.Append(c); + } + throw new FormatException("Unterminated string literal."); + } + + private static SAtom ParseAtom(string text, ref int pos) + { + int start = pos; + while (pos < text.Length) + { + var c = text[pos]; + if (char.IsWhiteSpace(c) || c == '(' || c == ')' || c == '"' || c == ';') break; + pos++; + } + return new SAtom(text.Substring(start, pos - start)); + } + + private static void SkipWs(string text, ref int pos) + { + while (pos < text.Length) + { + var c = text[pos]; + if (char.IsWhiteSpace(c)) { pos++; continue; } + if (c == ';') + { + while (pos < text.Length && text[pos] != '\n') pos++; + continue; + } + break; + } + } + } + + /// + /// Canonical single-line printer. Atoms that contain whitespace, parens, + /// quotes, semicolons or backslashes, or that are flagged + /// , are emitted as double-quoted strings with + /// the inverse C-style escapes accepted by . + /// + public static class SExprPrinter + { + public static string Print(SExpr expr) + { + var sb = new StringBuilder(); + PrintInto(expr, sb); + return sb.ToString(); + } + + private static void PrintInto(SExpr expr, StringBuilder sb) + { + switch (expr) + { + case SAtom a: + if (a.Quoted) PrintQuoted(a.Value, sb); + else sb.Append(a.Value); + break; + case SList l: + sb.Append('('); + for (int i = 0; i < l.Items.Count; i++) + { + if (i > 0) sb.Append(' '); + PrintInto(l.Items[i], sb); + } + sb.Append(')'); + break; + default: + throw new ArgumentException("Unknown SExpr subtype.", nameof(expr)); + } + } + + private static void PrintQuoted(string value, StringBuilder sb) + { + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '\\': sb.Append("\\\\"); break; + case '"': sb.Append("\\\""); break; + case '\n': sb.Append("\\n"); break; + case '\t': sb.Append("\\t"); break; + case '\r': sb.Append("\\r"); break; + default: sb.Append(c); break; + } + } + sb.Append('"'); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/SccProductCheck.cs b/Accordant.ModelChecking/Symbolic/SccProductCheck.cs new file mode 100644 index 0000000..c379d30 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/SccProductCheck.cs @@ -0,0 +1,448 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Generic SCC-based emptiness check for the product of a model program + /// () and a symbolic NBW. Tarjan's SCC + /// algorithm on the on-the-fly product graph; an SCC is a counterexample + /// when it has a real cycle and contains an accepting NBW state. + /// + /// + /// When a constraint is supplied, each candidate + /// (cyclic, accepting) SCC is further filtered through + /// : only SCCs satisfying every required + /// weak/strong fairness condition count as counterexamples. The check + /// operates directly on the product SCC, reading enabled from + /// and taken from the + /// step-function annotation on product edges (). + /// + /// + public static class SccProductCheck + { + /// + /// Check emptiness of the product (System × NBW). + /// + public static PropertyCheckingResult Check( + StateGraphNode root, + SymbolicNBW nbw, + int maxDepth = 0, + IEqualityComparer nbwStateComparer = null, + Fairness fairness = null) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (nbw == null) throw new ArgumentNullException(nameof(nbw)); + + nbwStateComparer ??= EqualityComparer.Default; + var registry = nbw.Registry; + + // + // Build product graph: BFS over (system node, NBW state) pairs. + // Each product node remembers (a) how it was reached (parent + + // step function on the incoming edge) and (b) all of its + // outgoing product edges annotated with the system step + // function that produced them. The annotation is what makes a + // sound fairness check possible later. + // + + var nodesBySys = new Dictionary>>(); + var allNodes = new List>(); + var worklist = new Queue>(); + + ProductNode GetOrCreate( + StateGraphNode sysNode, + TNbwState nbwState, + int depth, + IStepFunction incomingStep, + ProductNode parent) + { + var fp = sysNode.GetNodeFingerprint(); + if (!nodesBySys.TryGetValue(fp, out var byState)) + { + byState = new Dictionary>(nbwStateComparer); + nodesBySys[fp] = byState; + } + if (byState.TryGetValue(nbwState, out var existing)) return existing; + + var info = new ProductNode( + sysNode, nbwState, fp, depth, incomingStep, parent); + byState[nbwState] = info; + allNodes.Add(info); + worklist.Enqueue(info); + return info; + } + + foreach (var nbwInit in nbw.InitialStates) + { + GetOrCreate(root, nbwInit, 0, null, null); + } + + while (worklist.Count > 0) + { + var current = worklist.Dequeue(); + + if (maxDepth > 0 && current.Depth >= maxDepth) + { + // At the depth frontier we cannot explore further system + // edges, so the only continuation we admit is a system + // stutter (sys stays the same). The NBW, however, must + // make a real transition on the current state's label — + // pretending it can self-loop unconditionally would + // fabricate accepting cycles for properties the NBW + // cannot actually satisfy here. Aligns with the + // frontier handling. + var frontierNbw = nbw.GetTransition(current.NbwState); + var frontierSuccs = EvaluateNbwTransitions( + frontierNbw, current.SystemNode.State, registry, nbwStateComparer); + foreach (var succNbw in frontierSuccs) + { + var succ = GetOrCreate( + current.SystemNode, succNbw, current.Depth + 1, null, current); + current.Successors.Add(new ProductEdge(null, succ)); + } + continue; + } + + var nbwTrans = nbw.GetTransition(current.NbwState); + var sysNode = current.SystemNode; + var sysEdges = sysNode.Edges; + + if (sysEdges == null || sysEdges.Count == 0) + { + var stutterSuccs = EvaluateNbwTransitions( + nbwTrans, sysNode.State, registry, nbwStateComparer); + foreach (var succNbw in stutterSuccs) + { + var succ = GetOrCreate(sysNode, succNbw, current.Depth + 1, null, current); + current.Successors.Add(new ProductEdge(null, succ)); + } + continue; + } + + // NBW transitions depend only on the source system state, + // so evaluate once and reuse for all outgoing edges. + var nbwSuccsAll = EvaluateNbwTransitions( + nbwTrans, sysNode.State, registry, nbwStateComparer); + + foreach (var edge in sysEdges) + { + foreach (var succNbw in nbwSuccsAll) + { + var succ = GetOrCreate( + edge.Target, succNbw, current.Depth + 1, edge.StepFunction, current); + current.Successors.Add( + new ProductEdge(edge.StepFunction, succ)); + } + } + } + + // + // Tarjan SCC + accepting check + (optional) fairness filter. + // + var sccs = FindSCCs(allNodes); + + foreach (var scc in sccs) + { + if (!scc.HasCycle) continue; + if (!scc.Nodes.Any(n => nbw.IsAccepting(n.NbwState))) continue; + if (fairness != null && !ReferenceEquals(fairness, Fairness.None) && + !IsFairProductCycle(scc, fairness)) + continue; + + var trace = BuildCounterexample(scc, allNodes, nbw); + trace = TraceInstantiation.AttachValuations(trace, registry); + var badCycle = StronglyConnectedComponent.FromSystemNodes( + scc.Nodes.Select(pn => pn.SystemNode)); + return PropertyCheckingResult.Failure(trace, badCycle); + } + + return PropertyCheckingResult.Success(); + } + + #region Transition evaluation + + private static HashSet EvaluateNbwTransitions( + IReadOnlyList>> transitions, + IState systemState, + ConditionRegistry registry, + IEqualityComparer cmp) + { + var result = new HashSet(cmp); + foreach (var term in transitions) + { + var leaf = EvaluateTerm(term, systemState, registry); + if (leaf == null) continue; + foreach (var s in leaf) result.Add(s); + } + return result; + } + + private static StateSet EvaluateTerm( + TransitionTerm> term, + IState systemState, + ConditionRegistry registry) + { + while (true) + { + if (term is TransitionTermLeaf> leaf) + return leaf.Value; + var ite = (TransitionTermIte>)term; + var pred = registry.GetPredicate(ite.ConditionIndex); + term = pred.Eval(systemState) ? ite.Hi : ite.Lo; + } + } + + #endregion + + #region Fairness on product SCCs + + /// + /// Returns true iff the cycle inhabited by + /// satisfies every required fairness condition. Mirrors + /// but operates on the product + /// SCC: per-system-node "enabled" is derived from the system node's + /// outgoing edges (the explorer records an edge only for step + /// functions whose Apply succeeded), and taken comes + /// from the step-function annotation on outgoing product edges + /// that stay inside the SCC. + /// + private static bool IsFairProductCycle( + ProductScc scc, Fairness fairness) + { + // Unique system nodes (a product SCC may contain multiple + // NBW-variants of the same system state; collapse them). + var systemNodes = new Dictionary(); + foreach (var pn in scc.Nodes) + { + var fp = pn.SystemNode.GetNodeFingerprint(); + if (!systemNodes.ContainsKey(fp)) + systemNodes[fp] = pn.SystemNode; + } + + var sccNodes = new HashSet>(scc.Nodes); + + IEnumerable EnabledAt(StateGraphNode sys) + => sys.Edges.Select(e => e.StepFunction); + + IEnumerable Taken() + { + foreach (var n in scc.Nodes) + foreach (var pe in n.Successors) + if (sccNodes.Contains(pe.Target)) + yield return pe.StepFunction; + } + + var analysis = CycleFairness.Compute(systemNodes.Values, EnabledAt, Taken()); + return CycleFairness.IsFair(analysis, fairness); + } + + #endregion + + #region Tarjan SCC on the product graph + + private static List> FindSCCs( + List> nodes) + { + var result = new List>(); + var indexOf = new Dictionary, int>(); + var lowOf = new Dictionary, int>(); + var onStack = new HashSet>(); + var stack = new Stack>(); + int idx = 0; + + // Iterative Tarjan to avoid stack overflow on deep product graphs. + foreach (var root in nodes) + { + if (indexOf.ContainsKey(root)) continue; + + var callStack = new Stack<(ProductNode node, int i)>(); + callStack.Push((root, 0)); + indexOf[root] = idx; + lowOf[root] = idx; + idx++; + stack.Push(root); + onStack.Add(root); + + while (callStack.Count > 0) + { + var (node, i) = callStack.Pop(); + var succs = node.Successors; + + if (i < succs.Count) + { + callStack.Push((node, i + 1)); + var w = succs[i].Target; + if (!indexOf.ContainsKey(w)) + { + indexOf[w] = idx; + lowOf[w] = idx; + idx++; + stack.Push(w); + onStack.Add(w); + callStack.Push((w, 0)); + } + else if (onStack.Contains(w)) + { + lowOf[node] = Math.Min(lowOf[node], indexOf[w]); + } + } + else + { + // Post-order: propagate lowlink to parent (if any). + if (callStack.Count > 0) + { + var parent = callStack.Peek().node; + lowOf[parent] = Math.Min(lowOf[parent], lowOf[node]); + } + + if (lowOf[node] == indexOf[node]) + { + var scc = new ProductScc(); + ProductNode w; + do + { + w = stack.Pop(); + onStack.Remove(w); + scc.Nodes.Add(w); + } while (!ReferenceEquals(w, node)); + + scc.HasCycle = scc.Nodes.Count > 1 || + scc.Nodes[0].Successors.Any( + s => ReferenceEquals(s.Target, scc.Nodes[0])); + result.Add(scc); + } + } + } + } + + return result; + } + + #endregion + + #region Counterexample reconstruction + + private static List BuildCounterexample( + ProductScc scc, + List> allNodes, + SymbolicNBW nbw) + { + var trace = new List(); + var cycleEntry = scc.Nodes.FirstOrDefault(n => nbw.IsAccepting(n.NbwState)) + ?? scc.Nodes[0]; + + // BFS from initial product nodes to the cycle entry. + var initial = allNodes.Where(n => n.Depth == 0).ToList(); + var parentOf = new Dictionary, ProductNode>(); + var seen = new HashSet>(initial); + var q = new Queue>(initial); + foreach (var n in initial) parentOf[n] = null; + + ProductNode hit = null; + while (q.Count > 0) + { + var cur = q.Dequeue(); + if (ReferenceEquals(cur, cycleEntry)) { hit = cur; break; } + foreach (var pe in cur.Successors) + { + if (seen.Add(pe.Target)) + { + parentOf[pe.Target] = cur; + q.Enqueue(pe.Target); + } + } + } + hit ??= cycleEntry; + + var prefix = new List>(); + for (var n = hit; n != null; n = parentOf.TryGetValue(n, out var p) ? p : null) + prefix.Add(n); + prefix.Reverse(); + + foreach (var n in prefix) + trace.Add(new TraceItem(n.IncomingStepFunction, n.SystemNode, isInCycle: false)); + + // Walk one lap around the SCC. Prefer unvisited successors; close with cycleEntry. + var sccSet = new HashSet>(scc.Nodes); + var visitedInCycle = new HashSet> { cycleEntry }; + var node2 = cycleEntry; + for (int i = 0; i < scc.Nodes.Count; i++) + { + var fresh = node2.Successors.FirstOrDefault( + e => sccSet.Contains(e.Target) && visitedInCycle.Add(e.Target)); + if (fresh != null) + { + trace.Add(new TraceItem(fresh.StepFunction, fresh.Target.SystemNode, isInCycle: true)); + node2 = fresh.Target; + continue; + } + // Try to close the cycle. + var closer = node2.Successors.FirstOrDefault( + e => ReferenceEquals(e.Target, cycleEntry)); + if (closer != null) + { + trace.Add(new TraceItem(closer.StepFunction, closer.Target.SystemNode, isInCycle: true)); + } + break; + } + + return trace; + } + + #endregion + + #region Internal types + + private sealed class ProductNode + { + public StateGraphNode SystemNode { get; } + public TNbwState NbwState { get; } + public string SysFingerprint { get; } + public int Depth { get; } + public IStepFunction IncomingStepFunction { get; } + public ProductNode Parent { get; } + public List> Successors { get; } = + new List>(); + + public ProductNode( + StateGraphNode systemNode, + TNbwState nbwState, + string sysFingerprint, + int depth, + IStepFunction incomingStepFunction, + ProductNode parent) + { + SystemNode = systemNode; + NbwState = nbwState; + SysFingerprint = sysFingerprint; + Depth = depth; + IncomingStepFunction = incomingStepFunction; + Parent = parent; + } + } + + private sealed class ProductEdge + { + public IStepFunction StepFunction { get; } + public ProductNode Target { get; } + + public ProductEdge(IStepFunction stepFunction, ProductNode target) + { + StepFunction = stepFunction; + Target = target; + } + } + + private sealed class ProductScc + { + public List> Nodes { get; } = + new List>(); + public bool HasCycle { get; set; } + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Symbolic/StateProp.cs b/Accordant.ModelChecking/Symbolic/StateProp.cs new file mode 100644 index 0000000..41d4b30 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/StateProp.cs @@ -0,0 +1,311 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// A named atomic proposition over model program states. + /// Each proposition has a unique integer Id for identity/ordering + /// and a Name for display. The evaluation function tests the proposition + /// against a concrete . + /// + public sealed class StateProp : IEquatable, IComparable + { + private static int _nextId; + + /// Unique identity for equality and ordering. + public int Id { get; } + + /// Display name. + public string Name { get; } + + /// Evaluation: tests whether the proposition holds in a state. + public Func Evaluate { get; } + + public StateProp(string name, Func evaluate) + { + Id = System.Threading.Interlocked.Increment(ref _nextId); + Name = name ?? throw new ArgumentNullException(nameof(name)); + Evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate)); + } + + public bool Equals(StateProp other) => other != null && Id == other.Id; + public override bool Equals(object obj) => Equals(obj as StateProp); + public override int GetHashCode() => Id; + public int CompareTo(StateProp other) => other == null ? 1 : Id.CompareTo(other.Id); + public override string ToString() => Name; + } + + /// + /// Effective Boolean algebra over with + /// as the element type. + /// + /// Compound predicates are represented structurally for proper equality + /// in the condition registry. is precise for + /// the propositional fragment built from , + /// , and : + /// each is treated as an independent Boolean + /// variable and a brute-force truth-table decides the formula. Formulas + /// with more than 20 distinct atoms fall back to a conservative + /// true answer. + /// + public sealed class StatePropEba : IEffectiveBooleanAlgebra + { + public static readonly StatePropEba Instance = new StatePropEba(); + + public IStatePredicate Top => StatePredTrue.Instance; + public IStatePredicate Bottom => StatePredFalse.Instance; + + public IStatePredicate And(IStatePredicate a, IStatePredicate b) + { + if (a is StatePredTrue) return b; + if (b is StatePredTrue) return a; + if (a is StatePredFalse || b is StatePredFalse) return Bottom; + if (a.Equals(b)) return a; + if (IsNegationOf(a, b) || IsNegationOf(b, a)) return Bottom; + return new StatePredAnd(a, b); + } + + public IStatePredicate Or(IStatePredicate a, IStatePredicate b) + { + if (a is StatePredFalse) return b; + if (b is StatePredFalse) return a; + if (a is StatePredTrue || b is StatePredTrue) return Top; + if (a.Equals(b)) return a; + if (IsNegationOf(a, b) || IsNegationOf(b, a)) return Top; + return new StatePredOr(a, b); + } + + /// + /// Returns true iff is structurally + /// ¬. Used to collapse complementary + /// literals at the EBA level. + /// + private static bool IsNegationOf(IStatePredicate neg, IStatePredicate pos) + => neg is StatePredNot n && n.Inner.Equals(pos); + + public IStatePredicate Not(IStatePredicate a) + { + if (a is StatePredTrue) return Bottom; + if (a is StatePredFalse) return Top; + if (a is StatePredNot neg) return neg.Inner; + return new StatePredNot(a); + } + + public bool IsSatisfiable(IStatePredicate predicate) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (predicate is StatePredFalse) return false; + if (predicate is StatePredTrue) return true; + if (predicate is StatePredAtom) return true; // a single atom can always be made true + if (predicate is StatePredNot n0 && n0.Inner is StatePredAtom) return true; + + // Propositional decision over the atomic StateProps occurring in + // the formula. Each is treated as an + // independent Boolean variable (its Evaluate callback is opaque + // to the algebra), and brute-force enumerates 2^|atoms| + // assignments. This is precise for the propositional fragment we + // build via // + // and detects contradictions such as p ∧ ¬p, + // (p ∧ q) ∧ ¬p, etc. + // + // For larger formulas (more than + // distinct atoms) we fall back to the conservative answer + // true, matching the previous behaviour and keeping the + // common case fast. + var atomIds = new List(); + var seen = new HashSet(); + CollectAtomIds(predicate, atomIds, seen); + + if (atomIds.Count == 0) + { + // No atoms — the formula reduces to a constant. Evaluate once + // under an empty assignment. + return EvalProp(predicate, null); + } + + if (atomIds.Count > MaxAtomsForBruteForce) + return true; + + int total = 1 << atomIds.Count; + var assignment = new bool[atomIds.Count]; + var idToIndex = new Dictionary(atomIds.Count); + for (int i = 0; i < atomIds.Count; i++) idToIndex[atomIds[i]] = i; + + for (int mask = 0; mask < total; mask++) + { + for (int i = 0; i < atomIds.Count; i++) + assignment[i] = (mask & (1 << i)) != 0; + if (EvalPropFast(predicate, assignment, idToIndex)) + return true; + } + return false; + } + + // Limit beyond which brute-force enumeration is bypassed. 2^20 ≈ 10^6 + // assignments is cheap; beyond that we fall back to "conservative + // true" for unsatisfiability decisions. + private const int MaxAtomsForBruteForce = 20; + + private static void CollectAtomIds(IStatePredicate p, List ids, HashSet seen) + { + switch (p) + { + case StatePredTrue _: + case StatePredFalse _: + return; + case StatePredAtom a: + if (seen.Add(a.Prop.Id)) ids.Add(a.Prop.Id); + return; + case StatePredNot n: + CollectAtomIds(n.Inner, ids, seen); + return; + case StatePredAnd andN: + CollectAtomIds(andN.Left, ids, seen); + CollectAtomIds(andN.Right, ids, seen); + return; + case StatePredOr orN: + CollectAtomIds(orN.Left, ids, seen); + CollectAtomIds(orN.Right, ids, seen); + return; + default: + // Foreign IStatePredicate implementations have opaque + // semantics; the brute-force decision cannot inspect + // them, so we treat the whole sub-tree as a fresh + // unconstrained atom by skipping it. The outer + // IsSatisfiable falls back to "true" via the assignment + // loop because EvalProp will treat unknown predicates as + // true (see EvalPropFast). + return; + } + } + + private static bool EvalPropFast( + IStatePredicate p, bool[] assignment, Dictionary idToIndex) + { + switch (p) + { + case StatePredTrue _: return true; + case StatePredFalse _: return false; + case StatePredAtom a: + return idToIndex.TryGetValue(a.Prop.Id, out var i) && assignment[i]; + case StatePredNot n: + return !EvalPropFast(n.Inner, assignment, idToIndex); + case StatePredAnd andN: + return EvalPropFast(andN.Left, assignment, idToIndex) + && EvalPropFast(andN.Right, assignment, idToIndex); + case StatePredOr orN: + return EvalPropFast(orN.Left, assignment, idToIndex) + || EvalPropFast(orN.Right, assignment, idToIndex); + default: + // Unknown predicate type: optimistically assume it can be + // true under some assignment (conservative-for-SAT). + return true; + } + } + + private static bool EvalProp(IStatePredicate p, bool[] assignment) + { + switch (p) + { + case StatePredTrue _: return true; + case StatePredFalse _: return false; + case StatePredNot n: return !EvalProp(n.Inner, assignment); + case StatePredAnd andN: return EvalProp(andN.Left, assignment) && EvalProp(andN.Right, assignment); + case StatePredOr orN: return EvalProp(orN.Left, assignment) || EvalProp(orN.Right, assignment); + default: return true; + } + } + + public bool Models(State element, IStatePredicate predicate) + => predicate.Eval(element); + } + + /// + /// Structural predicate over states. Supports proper equality + /// for use in the condition registry. + /// + public interface IStatePredicate : IEquatable + { + bool Eval(IState state); + } + + public sealed class StatePredTrue : IStatePredicate + { + public static readonly StatePredTrue Instance = new StatePredTrue(); + public bool Eval(IState state) => true; + public bool Equals(IStatePredicate other) => other is StatePredTrue; + public override bool Equals(object obj) => obj is StatePredTrue; + public override int GetHashCode() => 1; + public override string ToString() => "⊤"; + } + + public sealed class StatePredFalse : IStatePredicate + { + public static readonly StatePredFalse Instance = new StatePredFalse(); + public bool Eval(IState state) => false; + public bool Equals(IStatePredicate other) => other is StatePredFalse; + public override bool Equals(object obj) => obj is StatePredFalse; + public override int GetHashCode() => 0; + public override string ToString() => "⊥"; + } + + public sealed class StatePredAtom : IStatePredicate + { + public StateProp Prop { get; } + public StatePredAtom(StateProp prop) { Prop = prop; } + public bool Eval(IState state) => Prop.Evaluate((State)state); + public bool Equals(IStatePredicate other) + => other is StatePredAtom a && Prop.Id == a.Prop.Id; + public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); + public override int GetHashCode() => Prop.Id * 397 + 2; + public override string ToString() => Prop.Name; + } + + public sealed class StatePredNot : IStatePredicate + { + public IStatePredicate Inner { get; } + public StatePredNot(IStatePredicate inner) { Inner = inner; } + public bool Eval(IState state) => !Inner.Eval(state); + public bool Equals(IStatePredicate other) + => other is StatePredNot n && Inner.Equals(n.Inner); + public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); + public override int GetHashCode() => Inner.GetHashCode() * 31 + 3; + public override string ToString() => $"¬({Inner})"; + } + + public sealed class StatePredAnd : IStatePredicate + { + public IStatePredicate Left { get; } + public IStatePredicate Right { get; } + public StatePredAnd(IStatePredicate left, IStatePredicate right) + { Left = left; Right = right; } + public bool Eval(IState state) => Left.Eval(state) && Right.Eval(state); + public bool Equals(IStatePredicate other) + => other is StatePredAnd a && Left.Equals(a.Left) && Right.Equals(a.Right); + public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); + public override int GetHashCode() + { + unchecked { return Left.GetHashCode() * 31 + Right.GetHashCode() + 4; } + } + public override string ToString() => $"({Left} ∧ {Right})"; + } + + public sealed class StatePredOr : IStatePredicate + { + public IStatePredicate Left { get; } + public IStatePredicate Right { get; } + public StatePredOr(IStatePredicate left, IStatePredicate right) + { Left = left; Right = right; } + public bool Eval(IState state) => Left.Eval(state) || Right.Eval(state); + public bool Equals(IStatePredicate other) + => other is StatePredOr o && Left.Equals(o.Left) && Right.Equals(o.Right); + public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); + public override int GetHashCode() + { + unchecked { return Left.GetHashCode() * 31 + Right.GetHashCode() + 5; } + } + public override string ToString() => $"({Left} ∨ {Right})"; + } +} diff --git a/Accordant.ModelChecking/Symbolic/StatePropEbaProvider.cs b/Accordant.ModelChecking/Symbolic/StatePropEbaProvider.cs new file mode 100644 index 0000000..72b9173 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/StatePropEbaProvider.cs @@ -0,0 +1,61 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Threading; + + /// + /// Process-wide registration seam for the default + /// + /// used by the symbolic LTL/RLTL pipelines. + /// + /// + /// The fallback default is the brute-force-propositional + /// . More capable backends (for + /// example the BDD-based one in BraggerSpecs.ModelChecking.Bdd, + /// or the SMT-based one in BraggerSpecs.ModelChecking.Z3) can + /// self-register at assembly load via a module initializer by calling + /// . + /// + /// + /// + /// The provider exposes the algebra through both the basic + /// + /// interface and the structural + /// interface — call + /// sites in this assembly use the same instance under both views, so + /// upgrading the backend upgrades both at once. + /// + /// + public static class StatePropEbaProvider + { + private static IEffectiveBooleanAlgebra _default + = StatePropEba.Instance; + + /// + /// The currently registered default EBA over + /// . + /// + public static IEffectiveBooleanAlgebra Default + => Volatile.Read(ref _default); + + /// + /// Registers as the process-wide default. + /// Intended to be called once, early (e.g. from a module + /// initializer in an opt-in backend assembly). Later registrations + /// silently win. + /// + public static void SetDefault(IEffectiveBooleanAlgebra eba) + { + if (eba == null) throw new ArgumentNullException(nameof(eba)); + Volatile.Write(ref _default, eba); + } + + /// + /// Restores the fallback as the + /// default. Primarily intended for tests that want to pin the toy + /// backend regardless of what other assemblies have registered. + /// + public static void ResetToFallback() + => Volatile.Write(ref _default, StatePropEba.Instance); + } +} diff --git a/Accordant.ModelChecking/Symbolic/StateSet.cs b/Accordant.ModelChecking/Symbolic/StateSet.cs new file mode 100644 index 0000000..b4a899b --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/StateSet.cs @@ -0,0 +1,231 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + + /// + /// An immutable sorted set of states with value-based equality. + /// Used as successor sets P(Q) in NBW transitions and as + /// conjunction clauses in (B⁺(Q) representation). + /// + /// States are ordered by the provided . + /// Equality is structural: two sets are equal iff they contain the + /// same elements (per the comparer). + /// + public sealed class StateSet : IEquatable>, + IComparable>, IReadOnlyCollection + { + private readonly TState[] _states; // sorted by _comparer, no duplicates + private readonly IComparer _comparer; + private int? _hash; + + /// + /// Creates a state set from the given states, sorting and deduplicating. + /// + /// The states to include. + /// + /// Comparer for ordering. Must be consistent with GetHashCode of TState: + /// if Compare(a,b)==0 then a.GetHashCode()==b.GetHashCode(). + /// + public StateSet(IEnumerable states, IComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + var list = new List(states); + list.Sort(comparer); + // Deduplicate (sorted, so duplicates are adjacent) + int write = 0; + for (int i = 0; i < list.Count; i++) + { + if (i == 0 || comparer.Compare(list[i], list[i - 1]) != 0) + list[write++] = list[i]; + } + if (write < list.Count) + list.RemoveRange(write, list.Count - write); + _states = list.ToArray(); + } + + /// + /// Internal constructor for pre-sorted, pre-deduplicated arrays. + /// + internal StateSet(TState[] sortedUniqueStates, IComparer comparer) + { + _states = sortedUniqueStates ?? throw new ArgumentNullException(nameof(sortedUniqueStates)); + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + } + + /// Creates an empty state set. + public static StateSet Empty(IComparer comparer) + => new StateSet(Array.Empty(), comparer); + + /// Creates a singleton state set. + public static StateSet Singleton(TState state, IComparer comparer) + => new StateSet(new[] { state }, comparer); + + /// The comparer used for ordering states. + public IComparer Comparer => _comparer; + + /// Number of states in the set. + public int Count => _states.Length; + + /// True if the set is empty. + public bool IsEmpty => _states.Length == 0; + + /// Gets the state at the given index (0-based, sorted order). + public TState this[int index] => _states[index]; + + /// Returns true if the set contains the given state. + public bool Contains(TState state) + => Array.BinarySearch(_states, state, _comparer) >= 0; + + /// Returns the union of this set with another. + public StateSet Union(StateSet other) + { + if (other == null) throw new ArgumentNullException(nameof(other)); + if (_states.Length == 0) return other; + if (other._states.Length == 0) return this; + + var result = new List(_states.Length + other._states.Length); + int i = 0, j = 0; + while (i < _states.Length && j < other._states.Length) + { + int cmp = _comparer.Compare(_states[i], other._states[j]); + if (cmp < 0) result.Add(_states[i++]); + else if (cmp > 0) result.Add(other._states[j++]); + else { result.Add(_states[i++]); j++; } + } + while (i < _states.Length) result.Add(_states[i++]); + while (j < other._states.Length) result.Add(other._states[j++]); + return new StateSet(result.ToArray(), _comparer); + } + + /// Returns the intersection of this set with another. + public StateSet Intersect(StateSet other) + { + if (other == null) throw new ArgumentNullException(nameof(other)); + var result = new List(Math.Min(_states.Length, other._states.Length)); + int i = 0, j = 0; + while (i < _states.Length && j < other._states.Length) + { + int cmp = _comparer.Compare(_states[i], other._states[j]); + if (cmp < 0) i++; + else if (cmp > 0) j++; + else { result.Add(_states[i++]); j++; } + } + return new StateSet(result.ToArray(), _comparer); + } + + /// Returns this set minus the other set. + public StateSet Except(StateSet other) + { + if (other == null) throw new ArgumentNullException(nameof(other)); + var result = new List(); + int j = 0; + for (int i = 0; i < _states.Length; i++) + { + while (j < other._states.Length && _comparer.Compare(other._states[j], _states[i]) < 0) + j++; + if (j >= other._states.Length || _comparer.Compare(other._states[j], _states[i]) != 0) + result.Add(_states[i]); + } + return new StateSet(result.ToArray(), _comparer); + } + + /// Returns true if this set is a subset of the other. + public bool IsSubsetOf(StateSet other) + { + if (other == null) throw new ArgumentNullException(nameof(other)); + if (_states.Length > other._states.Length) return false; + int j = 0; + for (int i = 0; i < _states.Length; i++) + { + while (j < other._states.Length && _comparer.Compare(other._states[j], _states[i]) < 0) + j++; + if (j >= other._states.Length || _comparer.Compare(other._states[j], _states[i]) != 0) + return false; + j++; + } + return true; + } + + /// Returns true if this set is a proper subset of the other. + public bool IsProperSubsetOf(StateSet other) + => other != null && _states.Length < other._states.Length && IsSubsetOf(other); + + #region Equality and Comparison + + /// + /// Structural equality: two sets are equal iff they contain the same + /// elements in the same order (per the comparer). + /// + public bool Equals(StateSet other) + { + if (other == null) return false; + if (ReferenceEquals(this, other)) return true; + if (_states.Length != other._states.Length) return false; + for (int i = 0; i < _states.Length; i++) + if (_comparer.Compare(_states[i], other._states[i]) != 0) + return false; + return true; + } + + public override bool Equals(object obj) => Equals(obj as StateSet); + + public override int GetHashCode() + { + if (_hash == null) + { + unchecked + { + int h = 17; + foreach (var s in _states) + h = h * 31 + (s != null ? s.GetHashCode() : 0); + _hash = h; + } + } + return _hash.Value; + } + + /// + /// Lexicographic comparison: shorter sets first, then element-wise. + /// Used for canonical ordering in . + /// + public int CompareTo(StateSet other) + { + if (other == null) return 1; + int lenCmp = _states.Length.CompareTo(other._states.Length); + if (lenCmp != 0) return lenCmp; + for (int i = 0; i < _states.Length; i++) + { + int cmp = _comparer.Compare(_states[i], other._states[i]); + if (cmp != 0) return cmp; + } + return 0; + } + + public static bool operator ==(StateSet left, StateSet right) + { + if (ReferenceEquals(left, right)) return true; + if (left is null || right is null) return false; + return left.Equals(right); + } + + public static bool operator !=(StateSet left, StateSet right) + => !(left == right); + + #endregion + + #region IReadOnlyCollection + + public IEnumerator GetEnumerator() + => ((IEnumerable)_states).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _states.GetEnumerator(); + + #endregion + + public override string ToString() + => "{" + string.Join(", ", _states) + "}"; + } +} diff --git a/Accordant.ModelChecking/Symbolic/StringFreeAlgebra.cs b/Accordant.ModelChecking/Symbolic/StringFreeAlgebra.cs new file mode 100644 index 0000000..4664274 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/StringFreeAlgebra.cs @@ -0,0 +1,20 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + /// + /// Free predicate algebra over strings: no simplification, boolean + /// combinations are rendered structurally. Useful for the + /// serializer where predicates are opaque + /// labels and we just need a syntactic Not/And/Or. + /// + public sealed class StringFreeAlgebra : IPredicateAlgebra + { + public static readonly StringFreeAlgebra Instance = new StringFreeAlgebra(); + + public string Top => "⊤"; + public string Bottom => "⊥"; + public string And(string a, string b) => $"({a} ∧ {b})"; + public string Or(string a, string b) => $"({a} ∨ {b})"; + public string Not(string a) => a.StartsWith("¬") ? a.Substring(1) : "¬" + a; + public bool IsSatisfiable(string predicate) => predicate != "⊥"; + } +} diff --git a/Accordant.ModelChecking/Symbolic/SymbolicABW.cs b/Accordant.ModelChecking/Symbolic/SymbolicABW.cs new file mode 100644 index 0000000..c90df2a --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/SymbolicABW.cs @@ -0,0 +1,265 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// A Symbolic Alternating Büchi Word automaton modulo an EBA A. + /// (Definition 4.1 in the paper: "Symbolic Automata: Omega-Regularity Modulo Theories") + /// + /// ABW_A = (Q, φ₀, δ, F) where: + /// + /// Q: finite set of states (discovered lazily via transitions) + /// φ₀ ∈ B⁺(Q): initial positive Boolean formula over states + /// (in DNF; a single atom q is the special case φ₀ = {{q}}) + /// δ: Q → TTerm⟨A, B⁺(Q)⟩: transition function + /// F ⊆ Q: accepting (final) states + /// + /// + /// Transitions are computed lazily via a delegate and cached. + /// States are discovered incrementally as transitions are computed. + /// The transition term leaves are formulas + /// representing positive Boolean combinations of successor states. + /// + /// + /// Generalising φ₀ to rather than a single + /// makes the natural Boolean closure of ABWs + /// (union, intersection, lifting δ to B⁺(Q)) immediate, since both + /// operations only need to combine the initial formulas: + /// + /// + /// L(A) ∪ L(B) ↦ initial = φ₀ᴬ ∨ φ₀ᴮ (disjunction in B⁺(Q)) + /// L(A) ∩ L(B) ↦ initial = φ₀ᴬ ∧ φ₀ᴮ (conjunction in B⁺(Q)) + /// δ lifted homomorphically: δ̂(⋁ⱼ ⋀ᵢ qᵢⱼ) = ⋁ⱼ ⋀ᵢ δ(qᵢⱼ) + /// using 's + /// And/Or over the term structure. + /// + /// + /// Predicate type in the condition EBA. + /// Element type in the alphabet universe Σ. + /// State type Q. + public class SymbolicABW + { + private readonly Func>> _delta; + private readonly Dictionary>> _transitionCache; + private readonly HashSet _states; + private readonly IEqualityComparer _stateEqualityComparer; + private TransitionTermAlgebra> _termAlgebra; + + /// + /// Creates a symbolic ABW with an initial positive-Boolean formula over states. + /// + /// The effective Boolean algebra over predicates. + /// The condition registry (shared with transition terms). + /// The B⁺(Q) leaf algebra for transition term operations. + /// The initial formula φ₀ ∈ B⁺(Q). + /// Predicate determining if a state is in F. + /// + /// The symbolic transition function: given a state, returns its transition + /// term TTerm⟨A, B⁺(Q)⟩. Called at most once per state and cached. + /// + /// + /// Equality comparer for states. If null, uses default. + /// + public SymbolicABW( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + DnfAlgebra dnfAlgebra, + Dnf initialFormula, + Func isAccepting, + Func>> delta, + IEqualityComparer stateEqualityComparer = null) + { + Eba = eba ?? throw new ArgumentNullException(nameof(eba)); + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + DnfAlgebra = dnfAlgebra ?? throw new ArgumentNullException(nameof(dnfAlgebra)); + InitialState = initialFormula ?? throw new ArgumentNullException(nameof(initialFormula)); + IsAccepting = isAccepting ?? throw new ArgumentNullException(nameof(isAccepting)); + _delta = delta ?? throw new ArgumentNullException(nameof(delta)); + + _stateEqualityComparer = stateEqualityComparer ?? EqualityComparer.Default; + _transitionCache = new Dictionary>>(_stateEqualityComparer); + _states = new HashSet(_stateEqualityComparer); + foreach (var s in initialFormula.GetAllStates()) + _states.Add(s); + } + + /// + /// Convenience constructor for the common case where the initial formula + /// is a single atom {{initialState}}. + /// + public SymbolicABW( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + DnfAlgebra dnfAlgebra, + TState initialState, + Func isAccepting, + Func>> delta, + IEqualityComparer stateEqualityComparer = null) + : this(eba, registry, dnfAlgebra, + (dnfAlgebra ?? throw new ArgumentNullException(nameof(dnfAlgebra))).Atom(initialState), + isAccepting, delta, stateEqualityComparer) + { + } + + /// The effective Boolean algebra over predicates. + public IEffectiveBooleanAlgebra Eba { get; } + + /// The condition registry for transition term conditions. + public ConditionRegistry Registry { get; } + + /// The B⁺(Q) leaf algebra. + public DnfAlgebra DnfAlgebra { get; } + + /// The initial state formula φ₀ ∈ B⁺(Q). + public Dnf InitialState { get; } + + /// Predicate that determines if a state is accepting (in F). + public Func IsAccepting { get; } + + /// The underlying symbolic transition function δ on atomic states. + public Func>> Delta => _delta; + + /// The equality comparer used for states. + public IEqualityComparer StateEqualityComparer => _stateEqualityComparer; + + /// All discovered states so far. + public IReadOnlyCollection States => _states; + + /// All cached transitions. + public IReadOnlyDictionary>> CachedTransitions + => _transitionCache; + + /// + /// Gets the transition term for a state, computing and caching if necessary. + /// Newly discovered states (from transition leaves) are added to . + /// + public TransitionTerm> GetTransition(TState state) + { + if (_transitionCache.TryGetValue(state, out var cached)) + return cached; + + _states.Add(state); + var transition = _delta(state); + _transitionCache[state] = transition; + + // Discover successor states from transition leaves + foreach (var leaf in transition.GetDistinctLeaves()) + foreach (var s in leaf.GetAllStates()) + _states.Add(s); + + return transition; + } + + /// + /// Lifts the transition function δ homomorphically to any positive + /// Boolean combination of states: + /// δ̂(⋁ⱼ ⋀ᵢ qᵢⱼ) = ⋁ⱼ ⋀ᵢ δ(qᵢⱼ), with the disjunction and + /// conjunction taken in the transition term algebra + /// . + /// Edge cases: ⊤ ↦ leaf(⊤), ⊥ ↦ leaf(⊥). + /// + public TransitionTerm> GetTransition(Dnf formula) + { + if (formula == null) throw new ArgumentNullException(nameof(formula)); + var alg = GetTermAlgebra(); + if (formula.IsFalse) return alg.Leaf(DnfAlgebra.Bottom); + if (formula.IsTrue) return alg.Leaf(DnfAlgebra.Top); + + TransitionTerm> result = null; + foreach (var clause in formula.Clauses) + { + TransitionTerm> clauseTerm = null; + foreach (var state in clause) + { + var sigma = GetTransition(state); + clauseTerm = clauseTerm == null ? sigma : alg.And(clauseTerm, sigma); + } + if (clauseTerm == null) + clauseTerm = alg.Leaf(DnfAlgebra.Top); // empty conjunction + result = result == null ? clauseTerm : alg.Or(result, clauseTerm); + } + return result ?? alg.Leaf(DnfAlgebra.Bottom); + } + + /// + /// Gets the transition term algebra for operating on ABW transitions. + /// Uses the B⁺(Q) leaf algebra for And/Or on transition term leaves. + /// + public TransitionTermAlgebra> GetTermAlgebra() + { + if (_termAlgebra == null) + _termAlgebra = new TransitionTermAlgebra>( + Eba, Registry, DnfAlgebra); + return _termAlgebra; + } + + /// + /// Constructs an ABW recognising L(a) ∪ L(b) from two compatible + /// ABWs and : + /// the new initial formula is φ₀ᴬ ∨ φ₀ᴮ, the transition function + /// is dispatched via , and F is the union of + /// the two accepting sets. + /// + /// Left ABW. + /// Right ABW. + /// + /// Routing predicate: returns true iff a given state belongs to A's state + /// space (and thus should be dispatched to A's δ / F). Required because + /// the two ABWs share the same TState type and the combined automaton + /// must know which subautomaton owns each state. + /// + public static SymbolicABW Union( + SymbolicABW a, + SymbolicABW b, + Func isInA) + { + RequireCompatible(a, b); + if (isInA == null) throw new ArgumentNullException(nameof(isInA)); + var initial = a.DnfAlgebra.Or(a.InitialState, b.InitialState); + return new SymbolicABW( + a.Eba, a.Registry, a.DnfAlgebra, + initial, + q => isInA(q) ? a.IsAccepting(q) : b.IsAccepting(q), + q => isInA(q) ? a.Delta(q) : b.Delta(q), + a.StateEqualityComparer); + } + + /// + /// Constructs an ABW recognising L(a) ∩ L(b) from two compatible + /// ABWs: the new initial formula is φ₀ᴬ ∧ φ₀ᴮ; transitions and + /// acceptance are dispatched via . Each branch + /// of the conjunction is checked independently against its own Büchi + /// condition (which is sound for alternating Büchi automata). + /// + public static SymbolicABW Intersect( + SymbolicABW a, + SymbolicABW b, + Func isInA) + { + RequireCompatible(a, b); + if (isInA == null) throw new ArgumentNullException(nameof(isInA)); + var initial = a.DnfAlgebra.And(a.InitialState, b.InitialState); + return new SymbolicABW( + a.Eba, a.Registry, a.DnfAlgebra, + initial, + q => isInA(q) ? a.IsAccepting(q) : b.IsAccepting(q), + q => isInA(q) ? a.Delta(q) : b.Delta(q), + a.StateEqualityComparer); + } + + private static void RequireCompatible( + SymbolicABW a, + SymbolicABW b) + { + if (a == null) throw new ArgumentNullException(nameof(a)); + if (b == null) throw new ArgumentNullException(nameof(b)); + if (!ReferenceEquals(a.Eba, b.Eba)) + throw new ArgumentException("ABWs must share the same EBA."); + if (!ReferenceEquals(a.Registry, b.Registry)) + throw new ArgumentException("ABWs must share the same ConditionRegistry."); + if (!ReferenceEquals(a.DnfAlgebra, b.DnfAlgebra)) + throw new ArgumentException("ABWs must share the same DnfAlgebra."); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs b/Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs new file mode 100644 index 0000000..c02535d --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs @@ -0,0 +1,525 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Symbolic LTL model checking via product construction with the NBW + /// from alternation elimination. Uses the standard approach: + /// negate the property, build NBW(¬φ), find accepting cycle = violation. + /// + /// Supports bounded depth exploration for infinite-state model programs. + /// + public static class SymbolicLtlCheck + { + /// + /// Check an LTL property against a model program. + /// + /// Algorithm: + /// 1. Negate the property: ¬φ + /// 2. Construct ABW for ¬φ via symbolic derivatives + /// 3. Run incremental Æ to get lazy NBW + /// 4. Product construction: explore (SystemNode × NBWState) pairs + /// 5. Detect accepting cycles via Tarjan's SCC + /// 6. If found: return violation with counterexample + /// + /// Root of the state graph (model program). + /// The LTL property φ to check (should hold). + /// Maximum exploration depth (0 = unlimited). + /// Optional fairness constraint. When supplied + /// (non-null and not ), emptiness + /// is checked with so only fair + /// accepting cycles count as counterexamples. + /// Result with counterexample if property is violated. + public static PropertyCheckingResult Check( + StateGraphNode root, + Ltl property, + int maxDepth = 0, + Fairness fairness = null) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (property == null) throw new ArgumentNullException(nameof(property)); + + // 1. Negate the property + var negPhi = LtlAlgebra.Default.Not(property); + + // 2. Build ABW for ¬φ + var eba = StatePropEbaProvider.Default; + var registry = new ConditionRegistry( + EqualityComparer.Default); + var derivative = new LtlDerivative(eba, registry); + var abw = derivative.ToABW(negPhi); + + // 3. Incremental Æ → lazy NBW + var incAE = new IncrementalAE>(abw); + var nbw = incAE.ToNBW(); + + // 4. Pick the emptiness check. With fairness we need an SCC-level + // analysis to evaluate enabled/taken; without it we keep the + // existing inline product+SCC path for backwards compatibility. + if (fairness != null && !ReferenceEquals(fairness, Fairness.None)) + { + var bpComparer = BreakpointState>.GetEqualityComparer(); + return SccProductCheck.Check(root, nbw, maxDepth, bpComparer, fairness); + } + + return ExploreProduct(root, nbw, registry, maxDepth); + } + + /// + /// Check an LTL property against a model program using Nested DFS + /// (Algorithm B from Courcoubetis–Vardi–Wolper–Yannakakis, FMSD 1992). + /// + /// Equivalent semantics to but uses on-the-fly + /// nested depth-first cycle detection instead of building the full + /// product graph and running Tarjan's SCC algorithm. Suitable for + /// large or implicit product graphs where memory is a concern. + /// + /// Root of the state graph (model program). + /// The LTL property φ to check (should hold). + /// Maximum exploration depth (0 = unlimited). + public static PropertyCheckingResult CheckNDFS( + StateGraphNode root, + Ltl property, + int maxDepth = 0) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (property == null) throw new ArgumentNullException(nameof(property)); + + var negPhi = LtlAlgebra.Default.Not(property); + + var eba = StatePropEbaProvider.Default; + var registry = new ConditionRegistry( + EqualityComparer.Default); + var derivative = new LtlDerivative(eba, registry); + var abw = derivative.ToABW(negPhi); + + var incAE = new IncrementalAE>(abw); + var nbw = incAE.ToNBW(); + + var bpComparer = BreakpointState>.GetEqualityComparer(); + return NestedDfsCheck.Check(root, nbw, maxDepth, bpComparer); + } + + /// + /// Explores the product graph (System × NBW) looking for accepting cycles. + /// Uses iterative Tarjan's SCC algorithm on the product graph. + /// + private static PropertyCheckingResult ExploreProduct( + StateGraphNode root, + SymbolicNBW>> nbw, + ConditionRegistry registry, + int maxDepth) + { + // Product state: (system fingerprint, NBW state) + var bpComparer = BreakpointState>.GetEqualityComparer(); + + // Product node tracking + var productNodes = new Dictionary(); + var worklist = new Queue(); + + // Initialize: system root × each NBW initial state + foreach (var nbwInit in nbw.InitialStates) + { + var key = MakeProductKey(root, nbwInit); + if (!productNodes.ContainsKey(key)) + { + var info = new ProductNodeInfo(root, nbwInit, key, 0, null, null); + productNodes[key] = info; + worklist.Enqueue(info); + } + } + + // BFS exploration of product graph, building adjacency + while (worklist.Count > 0) + { + var current = worklist.Dequeue(); + + if (maxDepth > 0 && current.Depth >= maxDepth) + { + // At the depth frontier we admit only a system stutter + // (sys stays put); the NBW must make a real transition + // on the current state's label rather than a fake + // unconditional self-loop. The previous unconditional + // self-loop fabricated accepting cycles for properties + // the NBW could not actually satisfy at the frontier + // state (e.g. F p when p is true at the + // frontier and the NBW for G ¬p has no + // outgoing transition there). Now consistent with the + // frontier handling. + var nbwTransitionsFr = nbw.GetTransition(current.NbwState); + var frontierSuccs = EvaluateNbwTransitions( + nbwTransitionsFr, current.SystemNode.State, registry); + foreach (var succNbwState in frontierSuccs) + { + var succKey = MakeProductKey(current.SystemNode, succNbwState); + if (!productNodes.TryGetValue(succKey, out var succInfo)) + { + succInfo = new ProductNodeInfo( + current.SystemNode, succNbwState, succKey, + current.Depth + 1, null, current); + productNodes[succKey] = succInfo; + worklist.Enqueue(succInfo); + } + current.Successors.Add(succInfo); + } + continue; + } + + var sysNode = current.SystemNode; + var nbwState = current.NbwState; + + // Get NBW transitions for current NBW state + var nbwTransitions = nbw.GetTransition(nbwState); + + // If system node is terminal (no outgoing edges): stutter self-loop + var sysEdges = sysNode.Edges; + if (sysEdges == null || sysEdges.Count == 0) + { + // Stutter: stay in same system state, advance NBW + var successorNbwStates = EvaluateNbwTransitions( + nbwTransitions, sysNode.State, registry); + + foreach (var succNbwState in successorNbwStates) + { + var succKey = MakeProductKey(sysNode, succNbwState); + if (!productNodes.TryGetValue(succKey, out var succInfo)) + { + succInfo = new ProductNodeInfo( + sysNode, succNbwState, succKey, current.Depth + 1, + null, current); + productNodes[succKey] = succInfo; + worklist.Enqueue(succInfo); + } + current.Successors.Add(succInfo); + } + continue; + } + + // Normal transitions + foreach (var edge in sysEdges) + { + var nextSysNode = edge.Target; + + // Evaluate NBW transitions against the CURRENT system state + // (the label is consumed at the source) + var successorNbwStates = EvaluateNbwTransitions( + nbwTransitions, sysNode.State, registry); + + foreach (var succNbwState in successorNbwStates) + { + var succKey = MakeProductKey(nextSysNode, succNbwState); + if (!productNodes.TryGetValue(succKey, out var succInfo)) + { + succInfo = new ProductNodeInfo( + nextSysNode, succNbwState, succKey, current.Depth + 1, + edge.StepFunction, current); + productNodes[succKey] = succInfo; + worklist.Enqueue(succInfo); + } + current.Successors.Add(succInfo); + } + } + } + + // 5. Find SCCs in product graph using Tarjan's + var sccs = FindProductSCCs(productNodes.Values); + + // 6. Check for accepting cycles + foreach (var scc in sccs) + { + if (!scc.HasCycle) continue; + + // An SCC is accepting if it contains at least one product node + // whose NBW state is accepting (obligation = ∅) + bool hasAccepting = scc.Nodes.Any(n => nbw.IsAccepting(n.NbwState)); + if (!hasAccepting) continue; + + // Found a violation! Build counterexample trace. + var trace = BuildCounterexample(scc, productNodes, root, nbw); + trace = TraceInstantiation.AttachValuations(trace, registry); + return PropertyCheckingResult.Failure(trace); + } + + return PropertyCheckingResult.Success(); + } + + /// + /// Evaluates NBW transitions (Antimirov form) against a concrete system state. + /// Returns the set of successor NBW states. + /// + private static HashSet>> EvaluateNbwTransitions( + IReadOnlyList>>>> transitions, + IState systemState, + ConditionRegistry registry) + { + var result = new HashSet>>( + BreakpointState>.GetEqualityComparer()); + + foreach (var term in transitions) + { + var successorSet = EvaluateTerm(term, systemState, registry); + if (successorSet != null) + { + foreach (var s in successorSet) + result.Add(s); + } + } + + return result; + } + + /// + /// Evaluates a single transition term (ADD) against a concrete state, + /// following the unique path through the ITE tree. + /// + private static StateSet>> EvaluateTerm( + TransitionTerm>>> term, + IState systemState, + ConditionRegistry registry) + { + while (true) + { + if (term is TransitionTermLeaf>>> leaf) + return leaf.Value; + + var ite = (TransitionTermIte>>>)term; + var pred = registry.GetPredicate(ite.ConditionIndex); + term = pred.Eval(systemState) ? ite.Hi : ite.Lo; + } + } + + private static string MakeProductKey( + StateGraphNode sysNode, + BreakpointState> nbwState) + { + return $"{sysNode.GetNodeFingerprint()}|{nbwState.GetHashCode():X8}|{nbwState}"; + } + + #region Tarjan SCC on Product Graph + + private static List FindProductSCCs( + IEnumerable nodes) + { + var result = new List(); + var indexMap = new Dictionary(); + var lowLinkMap = new Dictionary(); + var onStack = new HashSet(); + var stack = new Stack(); + int index = 0; + + foreach (var node in nodes) + { + if (!indexMap.ContainsKey(node.Key)) + StrongConnect(node); + } + + void StrongConnect(ProductNodeInfo node) + { + indexMap[node.Key] = index; + lowLinkMap[node.Key] = index; + index++; + stack.Push(node); + onStack.Add(node.Key); + + foreach (var succ in node.Successors) + { + if (!indexMap.ContainsKey(succ.Key)) + { + StrongConnect(succ); + lowLinkMap[node.Key] = Math.Min( + lowLinkMap[node.Key], lowLinkMap[succ.Key]); + } + else if (onStack.Contains(succ.Key)) + { + lowLinkMap[node.Key] = Math.Min( + lowLinkMap[node.Key], indexMap[succ.Key]); + } + } + + if (lowLinkMap[node.Key] == indexMap[node.Key]) + { + var scc = new ProductSCC(); + ProductNodeInfo w; + do + { + w = stack.Pop(); + onStack.Remove(w.Key); + scc.Nodes.Add(w); + } while (!w.Key.Equals(node.Key)); + + // SCC has a real cycle if it has >1 node, or a self-loop + scc.HasCycle = scc.Nodes.Count > 1 || + scc.Nodes[0].Successors.Any(s => s.Key == scc.Nodes[0].Key); + + result.Add(scc); + } + } + + return result; + } + + #endregion + + #region Counterexample Construction + + private static List BuildCounterexample( + ProductSCC scc, + Dictionary allNodes, + StateGraphNode systemRoot, + SymbolicNBW>> nbw) + { + var trace = new List(); + + // Find a node in the SCC to serve as the cycle entry + var cycleEntry = scc.Nodes.First(n => nbw.IsAccepting(n.NbwState)) + ?? scc.Nodes[0]; + + // BFS from initial nodes to the cycle entry (prefix) + var path = BfsToNode(allNodes, systemRoot, nbw, cycleEntry); + + // Add prefix (non-cycle portion) + foreach (var node in path) + { + trace.Add(new TraceItem( + node.IncomingStepFunction, + node.SystemNode, + isInCycle: false)); + } + + // Add cycle portion (walk around the SCC) + var sccKeys = new HashSet(scc.Nodes.Select(n => n.Key)); + var visited = new HashSet(); + var cycleNode = cycleEntry; + visited.Add(cycleNode.Key); + + // Walk one step around the cycle + for (int i = 0; i < scc.Nodes.Count && i < 10; i++) + { + var next = cycleNode.Successors + .FirstOrDefault(s => sccKeys.Contains(s.Key) && !visited.Contains(s.Key)); + if (next == null) + { + // Try to close the cycle + next = cycleNode.Successors + .FirstOrDefault(s => s.Key == cycleEntry.Key); + if (next != null) + { + trace.Add(new TraceItem( + next.IncomingStepFunction, + next.SystemNode, + isInCycle: true)); + } + break; + } + + visited.Add(next.Key); + trace.Add(new TraceItem( + next.IncomingStepFunction, + next.SystemNode, + isInCycle: true)); + cycleNode = next; + } + + return trace; + } + + private static List BfsToNode( + Dictionary allNodes, + StateGraphNode systemRoot, + SymbolicNBW>> nbw, + ProductNodeInfo target) + { + // BFS from initial product nodes to target + var visited = new Dictionary(); + var parent = new Dictionary(); + var queue = new Queue(); + + // Find initial product nodes + foreach (var kvp in allNodes) + { + var node = kvp.Value; + if (node.Depth == 0) + { + visited[node.Key] = node; + parent[node.Key] = null; + queue.Enqueue(node); + } + } + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current.Key == target.Key) + { + // Reconstruct path + var path = new List(); + var n = current; + while (n != null) + { + path.Add(n); + parent.TryGetValue(n.Key, out n); + } + path.Reverse(); + return path; + } + + foreach (var succ in current.Successors) + { + if (!visited.ContainsKey(succ.Key)) + { + visited[succ.Key] = succ; + parent[succ.Key] = current; + queue.Enqueue(succ); + } + } + } + + // Fallback: return just the target + return new List { target }; + } + + #endregion + + #region Internal Types + + private sealed class ProductNodeInfo + { + public StateGraphNode SystemNode { get; } + public BreakpointState> NbwState { get; } + public string Key { get; } + public int Depth { get; } + public IStepFunction IncomingStepFunction { get; } + public ProductNodeInfo Parent { get; } + public List Successors { get; } = new List(); + + public ProductNodeInfo( + StateGraphNode systemNode, + BreakpointState> nbwState, + string key, + int depth, + IStepFunction incomingStepFunction, + ProductNodeInfo parent) + { + SystemNode = systemNode; + NbwState = nbwState; + Key = key; + Depth = depth; + IncomingStepFunction = incomingStepFunction; + Parent = parent; + } + } + + private sealed class ProductSCC + { + public List Nodes { get; } = new List(); + public bool HasCycle { get; set; } + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/Symbolic/SymbolicNBW.cs b/Accordant.ModelChecking/Symbolic/SymbolicNBW.cs new file mode 100644 index 0000000..3cf325f --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/SymbolicNBW.cs @@ -0,0 +1,137 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// A Symbolic Nondeterministic Büchi Word automaton modulo an EBA A. + /// (Definition 4.2 in the paper: "Symbolic Automata: Omega-Regularity Modulo Theories") + /// + /// NBW_A = (Q, I, δ, F) where: + /// + /// Q: finite set of states (discovered lazily) + /// I ⊆ Q: initial states + /// δ: Q → ⟨TTerm⟨A, P(Q)⟩⟩: transition in Antimirov form + /// F ⊆ Q: accepting (final) states + /// + /// + /// Transitions use Antimirov normal form: each state maps to a list + /// of transition terms whose leaves are + /// (successor sets). The list represents nondeterministic choice + /// (top-level disjunction that is not propagated into ITEs). + /// + /// Predicate type in the condition EBA. + /// Element type in the alphabet universe Σ. + /// State type Q. + public class SymbolicNBW + { + private readonly Func>>> _delta; + private readonly Dictionary>>> _transitionCache; + private readonly HashSet _states; + + /// + /// Creates a symbolic NBW with a lazy transition function. + /// + public SymbolicNBW( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + IReadOnlyCollection initialStates, + Func isAccepting, + Func>>> delta, + IEqualityComparer stateEqualityComparer = null) + { + Eba = eba ?? throw new ArgumentNullException(nameof(eba)); + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + IsAccepting = isAccepting ?? throw new ArgumentNullException(nameof(isAccepting)); + _delta = delta ?? throw new ArgumentNullException(nameof(delta)); + + var comparer = stateEqualityComparer ?? EqualityComparer.Default; + _transitionCache = new Dictionary>>>(comparer); + _states = new HashSet(comparer); + + InitialStates = new List(initialStates); + foreach (var s in initialStates) + _states.Add(s); + } + + /// + /// Creates a symbolic NBW with eagerly provided transitions. + /// + public SymbolicNBW( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + IReadOnlyCollection initialStates, + Func isAccepting, + IDictionary>>> transitions, + IEqualityComparer stateEqualityComparer = null) + { + Eba = eba ?? throw new ArgumentNullException(nameof(eba)); + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + IsAccepting = isAccepting ?? throw new ArgumentNullException(nameof(isAccepting)); + _delta = null; + + var comparer = stateEqualityComparer ?? EqualityComparer.Default; + _transitionCache = new Dictionary>>>(comparer); + _states = new HashSet(comparer); + + InitialStates = new List(initialStates); + foreach (var s in initialStates) + _states.Add(s); + + foreach (var kvp in transitions) + { + _transitionCache[kvp.Key] = kvp.Value; + _states.Add(kvp.Key); + foreach (var term in kvp.Value) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var s in leaf) + _states.Add(s); + } + } + + /// The effective Boolean algebra over predicates. + public IEffectiveBooleanAlgebra Eba { get; } + + /// The condition registry. + public ConditionRegistry Registry { get; } + + /// The initial states I. + public IReadOnlyList InitialStates { get; } + + /// Predicate that determines if a state is accepting (in F). + public Func IsAccepting { get; } + + /// All discovered states so far. + public IReadOnlyCollection States => _states; + + /// All cached transitions. + public IReadOnlyDictionary>>> CachedTransitions + => _transitionCache; + + /// + /// Gets the transitions for a state in Antimirov form, + /// computing and caching if necessary. + /// + public IReadOnlyList>> GetTransition(TState state) + { + if (_transitionCache.TryGetValue(state, out var cached)) + return cached; + + if (_delta == null) + throw new InvalidOperationException( + $"No transition function provided and state '{state}' not in cache."); + + _states.Add(state); + var transition = _delta(state); + _transitionCache[state] = transition; + + // Discover successor states + foreach (var term in transition) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var s in leaf) + _states.Add(s); + + return transition; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/SymbolicNbwEmptiness.cs b/Accordant.ModelChecking/Symbolic/SymbolicNbwEmptiness.cs new file mode 100644 index 0000000..9065b22 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/SymbolicNbwEmptiness.cs @@ -0,0 +1,176 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Standalone language-emptiness check for a + /// — no model program, no product. Used by + /// and similar formula-level + /// checkers to decide L(NBW) = ∅ modulo the precision of + /// . + /// + /// + /// Algorithm: Courcoubetis–Vardi–Wolper–Yannakakis nested DFS, but the + /// successor enumerator walks transition terms by branch instead of by + /// concrete state. For each leaf, the accumulated path condition is + /// checked with eba.IsSatisfiable; leaves with an unsatisfiable + /// path are pruned. + /// + /// + /// + /// Soundness/completeness depends on IsSatisfiable: + /// + /// + /// Precise IsSatisfiable (e.g. a decision procedure): result is + /// sound and complete. + /// Conservative-true IsSatisfiable (e.g. 's + /// opaque-predicate default): equivalence reduces to symbolic + /// equivalence over independent predicate symbols — every distinct + /// predicate is assumed satisfiable. This is the standard semantics + /// for formula equivalence at the symbolic-automaton level. + /// + /// + internal static class SymbolicNbwEmptiness + { + /// + /// Returns true iff the NBW's language is empty modulo + /// the precision of eba.IsSatisfiable. + /// + public static bool IsEmpty( + SymbolicNBW nbw, + IEffectiveBooleanAlgebra eba, + IEqualityComparer stateComparer = null) + { + if (nbw == null) throw new ArgumentNullException(nameof(nbw)); + if (eba == null) throw new ArgumentNullException(nameof(eba)); + + var cmp = stateComparer ?? EqualityComparer.Default; + var visited1 = new HashSet(cmp); + var visited2 = new HashSet(cmp); + var registry = nbw.Registry; + + // Enumerate symbolic successors: all states reachable along any + // satisfiable ITE branch in any of the NBW transitions for q. + IEnumerable Successors(TState q) + { + var seen = new HashSet(cmp); + foreach (var term in nbw.GetTransition(q)) + { + foreach (var (leaf, guard) in EnumerateLeaves(term, eba.Top, eba, registry)) + { + if (!eba.IsSatisfiable(guard)) continue; + foreach (var s in leaf) + if (seen.Add(s)) + yield return s; + } + } + } + + foreach (var init in nbw.InitialStates) + { + if (visited1.Contains(init)) continue; + if (OuterDfs(init, nbw.IsAccepting, Successors, visited1, visited2)) + return false; // accepting lasso found + } + return true; + } + + // Outer DFS with iterative frame stack. On post-order of an accepting + // state we launch the inner DFS; if it finds a cycle, language is + // non-empty. Returns true iff an accepting lasso was found. + private static bool OuterDfs( + TState seed, + Func isAccepting, + Func> successors, + HashSet visited1, + HashSet visited2) + { + var stack = new Stack<(TState node, IEnumerator enumerator)>(); + visited1.Add(seed); + stack.Push((seed, successors(seed).GetEnumerator())); + + while (stack.Count > 0) + { + var top = stack.Peek(); + if (top.enumerator.MoveNext()) + { + var child = top.enumerator.Current; + if (visited1.Add(child)) + stack.Push((child, successors(child).GetEnumerator())); + } + else + { + top.enumerator.Dispose(); + stack.Pop(); + if (isAccepting(top.node)) + { + if (InnerDfs(top.node, successors, visited2)) + return true; + } + } + } + return false; + } + + // Inner DFS: from seed, look for a path back to seed. + // visited2 is shared across all inner-DFS invocations (NDFS invariant). + private static bool InnerDfs( + TState seed, + Func> successors, + HashSet visited2) + { + if (!visited2.Add(seed)) return false; + var stack = new Stack>(); + stack.Push(successors(seed).GetEnumerator()); + + var cmp = visited2.Comparer; + while (stack.Count > 0) + { + var top = stack.Peek(); + if (top.MoveNext()) + { + var child = top.Current; + if (cmp.Equals(child, seed)) + return true; + if (visited2.Add(child)) + stack.Push(successors(child).GetEnumerator()); + } + else + { + top.Dispose(); + stack.Pop(); + } + } + return false; + } + + // Enumerate (leaf, accumulated-path-condition) pairs by traversing an + // ITE transition term. Prunes branches whose path is unsatisfiable. + private static IEnumerable<(StateSet leaf, TPredicate guard)> + EnumerateLeaves( + TransitionTerm> term, + TPredicate path, + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry) + { + if (term is TransitionTermLeaf> leaf) + { + yield return (leaf.Value, path); + yield break; + } + + var ite = (TransitionTermIte>)term; + var cond = registry.GetPredicate(ite.ConditionIndex); + var hiPath = eba.And(path, cond); + if (eba.IsSatisfiable(hiPath)) + foreach (var t in EnumerateLeaves(ite.Hi, hiPath, eba, registry)) + yield return t; + + var loPath = eba.And(path, eba.Not(cond)); + if (eba.IsSatisfiable(loPath)) + foreach (var t in EnumerateLeaves(ite.Lo, loPath, eba, registry)) + yield return t; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/SymbolicRltlCheck.cs b/Accordant.ModelChecking/Symbolic/SymbolicRltlCheck.cs new file mode 100644 index 0000000..654be56 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/SymbolicRltlCheck.cs @@ -0,0 +1,186 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Symbolic RLTL model checking. Mirrors + /// but accepts an RLTL property (Section 7 of the POPL'25 paper: LTL + /// extended with extended-regular-expression prefix operators + /// R;φ, R:φ, R⊳φ, R⊳⊳φ). + /// + /// Algorithm: + /// + /// Negate the property: ¬φ (RLTL + /// keeps NNF using the regex-operator duals). + /// Build a symbolic ABW for ¬φ via + /// . + /// Eliminate alternation with the incremental Miyano-Hayashi + /// construction to obtain a lazy NBW whose states are + /// over . + /// + /// Check emptiness of the product (System × NBW). Two strategies + /// are available: (linear-space nested + /// DFS, used when no fairness is requested) and + /// (Tarjan SCCs, used whenever a + /// constraint is supplied — fairness is + /// inherently an SCC-level property). + /// + /// + public static class SymbolicRltlCheck + { + /// + /// Check an RLTL property against a model program. + /// + /// Root of the state graph (model program). + /// The RLTL property φ to check (should hold). + /// Maximum exploration depth (0 = unlimited). + /// Optional fairness constraint. When + /// non-null and not , emptiness + /// is checked with so that only + /// fair accepting cycles count as counterexamples. + /// When true, enables runtime + /// ERE-equivalence-based deduplication of RLTL closure / tableau + /// nodes. Residual regexes appearing in derivative leaves (e.g. + /// the R' in R';φ) are canonicalised by language + /// equivalence; combined with RLTL hash-consing, two RLTL atoms + /// that differ only by an equivalent embedded regex collapse to + /// the same ABW state. Off by default so existing baselines are + /// unchanged. + /// When true, additionally enables + /// RLTL-language-equivalence-based canonicalisation of every RLTL + /// atom emitted by the symbolic derivative. Two ABW / NBW states + /// whose underlying RLTL formula has the same ω-language collapse + /// to a single canonical representative (state minimisation by + /// precise equivalence via ). + /// This is strictly more powerful — and considerably more expensive + /// — than ; off by default. When + /// set, is implicitly enabled + /// alongside. + /// When true, every freshly + /// produced breakpoint state (S,O) in the alternation + /// elimination is canonicalised by weak language equivalence + /// against the breakpoint states already discovered: two breakpoints + /// whose macrostate conjunctions are language-equivalent and whose + /// obligation conjunctions are language-equivalent are merged. This + /// is the JACM Example 5.1 state-reduction step (e.g. it collapses + /// G(Fa∧F¬a)'s NBW from 8 to 3 reachable states). When set, + /// both and + /// are implicitly enabled alongside — + /// without the per-atom canonicaliser, macrostate elements would + /// themselves differ syntactically and defeat the merge. Off by + /// default; very expensive (one NBW emptiness check per existing + /// representative, per new breakpoint, for both S and O). + /// When true, applies a + /// bisimulation-style minimisation to the fully-constructed + /// breakpoint NBW (JACM Lemma 5.x, state-reduction): two NBW + /// states are merged iff they share acceptance status and, after + /// recursively rewriting successor leaves to class + /// representatives, have structurally equal transition lists. + /// This is the structural counterpart of + /// — it works on the + /// constructed graph rather than per-pair language-equivalence + /// queries, and stays tractable on inputs (e.g. + /// GFa ∧ GFb ∧ GFc) where the language-level merge + /// times out. Off by default. + /// When true, applies the + /// structural macrostate-merge rule + /// + /// (JACM Example 5.1 state-reduction lemma): in every + /// freshly-produced macrostate S, universal copies + /// q₁, q₂ with identical transition terms + /// δ(q₁) = δ(q₂) and matching colour + /// () are collapsed to a single + /// representative; obligation membership is forwarded onto the + /// representative. Per-macrostate cost is O(|S|) with ADD-node + /// reference equality. Off by default. + /// Result with counterexample if the property is violated. + public static PropertyCheckingResult Check( + StateGraphNode root, + Rltl property, + int maxDepth = 0, + Fairness fairness = null, + bool dedupTableau = false, + bool minimizeNbw = false, + bool mergeWeakEquivalent = false, + bool subsumeMacrostate = false, + bool mergeWeakEquivalentBp = false) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (property == null) throw new ArgumentNullException(nameof(property)); + + // Weak-equivalence merging implies the cheaper canonicalisations. + if (mergeWeakEquivalent) + { + minimizeNbw = true; + dedupTableau = true; + } + + var eba = StatePropEbaProvider.Default; + var registry = new ConditionRegistry( + EqualityComparer.Default); + + IEreCanonicalizer ereCanon = null; + IRltlCanonicalizer rltlCanon = null; + RltlAlgebra algebra = RltlAlgebra.Default; + if (dedupTableau || minimizeNbw) + { + var ereDeriv = new EreDerivative(eba, registry); + var checker = new EreEquivalenceChecker(ereDeriv); + ereCanon = new EreCanonicalizer(checker); + algebra = new RltlAlgebra(eba, ereCanon); + } + if (minimizeNbw) + { + rltlCanon = new RltlCanonicalizer(eba, algebra); + } + + // 1. Negate (keeps NNF via De Morgan + regex-operator duals). + var negPhi = algebra.Not(property); + + // 2. ABW for ¬φ via the RLTL symbolic derivative. + var derivative = new RltlDerivative( + eba, registry, ereCanon, rltlCanon); + var abw = derivative.ToABW(negPhi); + + // 3. Incremental Æ → lazy NBW (IncrementalAE is generic in TState). + Func>, BreakpointState>> bpCanon = null; + if (mergeWeakEquivalent) + { + var merger = new RltlBreakpointCanonicalizer(eba, algebra); + bpCanon = merger.Canonicalize; + } + Func>, MacroReduction>> macroReducer = null; + if (subsumeMacrostate) + { + var merger = new RltlMacrostateTransitionMerge( + abw.GetTransition); + macroReducer = merger.Reduce; + } + var incAE = new IncrementalAE>( + abw, bpCanon, macroReducer); + var nbw = incAE.ToNBW(); + + // Lightweight on-the-fly leaf dedup (JACM Lemma 5.x-flavoured but + // shallow — not a partition fixpoint). Replaces a freshly- + // discovered breakpoint with an existing one whenever their + // transition terms are structurally identical and they share the + // same accepting colour. Lazy: drives the underlying + // IncrementalAE on demand, never expanding aliased BPs further. + if (mergeWeakEquivalentBp) + { + var bpEq = BreakpointState>.GetEqualityComparer(); + var bpOrd = BreakpointState>.GetComparer( + Comparer>.Default); + nbw = BpWeakEquivalenceMinimizer.DedupOnTheFly(nbw, bpEq, bpOrd); + } + + // 4. Pick the emptiness check based on fairness. + var bpComparer = BreakpointState>.GetEqualityComparer(); + bool useSCC = fairness != null && !ReferenceEquals(fairness, Fairness.None); + return useSCC + ? SccProductCheck.Check(root, nbw, maxDepth, bpComparer, fairness) + : NestedDfsCheck.Check(root, nbw, maxDepth, bpComparer); + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/TraceInstantiation.cs b/Accordant.ModelChecking/Symbolic/TraceInstantiation.cs new file mode 100644 index 0000000..7f444c5 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/TraceInstantiation.cs @@ -0,0 +1,74 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System.Collections.Generic; + + /// + /// Post-processes a counterexample trace produced by the symbolic LTL/RLTL + /// backends so that each carries a concrete + /// valuation of every registered with the + /// NBW's . + /// + /// The valuation is computed by re-evaluating each registered + /// predicate against the trace item's concrete . Because + /// every transition in the symbolic NBW only branches on registered + /// predicates, the valuations attached here are exactly the assignments + /// that selected the leaves taken along the lasso — i.e. a concrete + /// witness path through the property's symbolic transition relation. + /// + /// The original step-function and cycle-flag annotations are + /// preserved verbatim; only the predicate valuation field is filled in. + /// Compound derived predicates (, + /// , ) introduced by + /// the RLTL → NBW translation are evaluated and emitted alongside the + /// underlying atoms; this keeps the witness self-contained for downstream + /// pretty-printing without forcing the consumer to recompute conjunctions + /// of atoms. + /// + internal static class TraceInstantiation + { + /// + /// Returns a new list of trace items, structurally identical to + /// but with each item augmented by a + /// concrete derived from + /// . + /// + public static List AttachValuations( + List trace, + ConditionRegistry registry) + { + if (trace == null) return null; + if (registry == null || registry.Count == 0) return trace; + + var preds = registry.Predicates; + var result = new List(trace.Count); + foreach (var item in trace) + { + var state = item.StateGraphNode?.State; + IReadOnlyDictionary valuation; + if (state == null) + { + valuation = null; + } + else + { + var map = new Dictionary(preds.Count); + foreach (var p in preds) + { + // Skip the trivial constants; they don't carry useful + // information for a counterexample reader. + if (p is StatePredTrue || p is StatePredFalse) continue; + map[p] = p.Eval(state); + } + valuation = map; + } + + result.Add(new TraceItem( + item.StepFunction, + item.StateGraphNode, + item.IsInCycle, + valuation)); + } + return result; + } + } +} diff --git a/Accordant.ModelChecking/Symbolic/TransitionTerm.cs b/Accordant.ModelChecking/Symbolic/TransitionTerm.cs new file mode 100644 index 0000000..7bc07f2 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/TransitionTerm.cs @@ -0,0 +1,281 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// A transition term (TTerm⟨A, B⟩) represented as an Algebraic Decision Diagram (ADD). + /// + /// Conditions from the EBA A are referenced by integer indices from a + /// . The ordering invariant ensures that + /// in any ITE node (α ? f : g), inner ITEs have strictly larger condition indices + /// than outer ITEs, producing a canonical representation. + /// + /// + /// This is a pure data structure. All operations that require algebra knowledge + /// (cleaning, leaf simplification, lifting with ACI normalization) are provided + /// by . + /// + /// + /// The leaf type B of the transition term. + public abstract class TransitionTerm : IEquatable> + { + /// Condition index for leaf nodes (no condition). + internal const int LeafLevel = int.MaxValue; + + private int _id = -1; + + /// + /// Unique non-negative identifier within an interning scope (see + /// ). + /// -1 means the term is un-interned (built via the bare + /// / statics rather than through the + /// algebra). Within an algebra, structurally equal terms share an Id. + /// + public int Id => _id; + + internal bool HasId => _id >= 0; + internal void AssignId(int id) { _id = id; } + + /// + /// The condition index of this node. For leaves, this is . + /// For ITE nodes, this is the index from the condition registry. + /// + public abstract int Level { get; } + + /// True if this is a leaf node. + public bool IsLeaf => Level == LeafLevel; + + #region Factory Methods + + /// Creates a leaf transition term. + public static TransitionTerm Leaf(TLeaf value) + { + return new TransitionTermLeaf(value); + } + + /// + /// Creates an ITE transition term (conditionIndex ? hi : lo), + /// enforcing the ordering invariant and applying trivial condition elimination. + /// + /// The condition index from the registry. + /// The then-case (condition is true). + /// The else-case (condition is false). + /// A canonical transition term. + public static TransitionTerm Ite( + int conditionIndex, + TransitionTerm hi, + TransitionTerm lo) + { + if (hi == null) throw new ArgumentNullException(nameof(hi)); + if (lo == null) throw new ArgumentNullException(nameof(lo)); + + // Trivial condition elimination: (_ ? f : f) ≈ f + if (hi.Equals(lo)) + return hi; + + return new TransitionTermIte(conditionIndex, hi, lo); + } + + #endregion + + #region Evaluation + + /// + /// Evaluates the transition term for a concrete element a ∈ Σ. + /// Returns the leaf f[a] by following the ITE branches according to + /// which predicates the element satisfies. + /// + public TLeaf Evaluate( + TElement element, + ConditionRegistry registry, + IEffectiveBooleanAlgebra algebra) + { + var current = this; + while (current is TransitionTermIte ite) + { + var predicate = registry.GetPredicate(ite.ConditionIndex); + current = algebra.Models(element, predicate) ? ite.Hi : ite.Lo; + } + return ((TransitionTermLeaf)current).Value; + } + + #endregion + + #region Traversal + + /// Returns all leaves of this transition term (with duplicates). + public IEnumerable GetLeaves() + { + if (this is TransitionTermLeaf leaf) + { + yield return leaf.Value; + } + else + { + var ite = (TransitionTermIte)this; + foreach (var l in ite.Hi.GetLeaves()) + yield return l; + foreach (var l in ite.Lo.GetLeaves()) + yield return l; + } + } + + /// Returns all distinct leaves of this transition term. + public IEnumerable GetDistinctLeaves() + { + return GetLeaves().Distinct(); + } + + /// Returns all condition indices used in this transition term (with duplicates). + public IEnumerable GetConditionIndices() + { + if (this is TransitionTermIte ite) + { + yield return ite.ConditionIndex; + foreach (var c in ite.Hi.GetConditionIndices()) + yield return c; + foreach (var c in ite.Lo.GetConditionIndices()) + yield return c; + } + } + + /// Returns all distinct condition indices used in this transition term. + public IEnumerable GetDistinctConditionIndices() + { + return GetConditionIndices().Distinct(); + } + + #endregion + + #region Equality + + /// + /// Structural equality. Due to the ordered canonical form, structural + /// equality implies semantic equivalence (modulo leaf equality). + /// + public abstract bool Equals(TransitionTerm other); + + public override bool Equals(object obj) => Equals(obj as TransitionTerm); + + public abstract override int GetHashCode(); + + public abstract override string ToString(); + + public static bool operator ==(TransitionTerm left, TransitionTerm right) + { + if (ReferenceEquals(left, right)) return true; + if (left is null || right is null) return false; + return left.Equals(right); + } + + public static bool operator !=(TransitionTerm left, TransitionTerm right) + => !(left == right); + + #endregion + } + + /// + /// A leaf node ℓ ∈ B of a transition term. + /// + public sealed class TransitionTermLeaf : TransitionTerm + { + public TLeaf Value { get; } + + public override int Level => LeafLevel; + + public TransitionTermLeaf(TLeaf value) + { + Value = value; + } + + public override bool Equals(TransitionTerm other) + { + return other is TransitionTermLeaf leaf + && EqualityComparer.Default.Equals(Value, leaf.Value); + } + + public override int GetHashCode() + { + return Value == null ? 0 : Value.GetHashCode(); + } + + public override string ToString() => Value?.ToString() ?? "null"; + } + + /// + /// An ITE node (α ? hi : lo) of a transition term. + /// The ordering invariant guarantees that Hi and Lo nodes (if ITEs) + /// have strictly larger condition indices than this node. + /// + public sealed class TransitionTermIte : TransitionTerm + { + /// Index into the condition registry. + public int ConditionIndex { get; } + + /// The then-case: taken when the condition is satisfied. + public TransitionTerm Hi { get; } + + /// The else-case: taken when the condition is not satisfied. + public TransitionTerm Lo { get; } + + public override int Level => ConditionIndex; + + private int? _cachedHash; + + public TransitionTermIte( + int conditionIndex, + TransitionTerm hi, + TransitionTerm lo) + { + // Negative indices are permitted: they denote propositions + // (EREQ Phase-1 D1) and sort outermost under our + // inner-larger ordering. int.MaxValue is the leaf marker + // and may not appear as a real condition. + if (conditionIndex == int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(conditionIndex)); + + // Enforce ordering invariant: children must have strictly larger levels + if (!hi.IsLeaf && hi.Level <= conditionIndex) + throw new ArgumentException( + $"Ordering violation: hi child level {hi.Level} must be > {conditionIndex}", + nameof(hi)); + if (!lo.IsLeaf && lo.Level <= conditionIndex) + throw new ArgumentException( + $"Ordering violation: lo child level {lo.Level} must be > {conditionIndex}", + nameof(lo)); + + ConditionIndex = conditionIndex; + Hi = hi; + Lo = lo; + } + + public override bool Equals(TransitionTerm other) + { + return other is TransitionTermIte ite + && ConditionIndex == ite.ConditionIndex + && Hi.Equals(ite.Hi) + && Lo.Equals(ite.Lo); + } + + public override int GetHashCode() + { + if (_cachedHash == null) + { + unchecked + { + int hash = 17; + hash = hash * 31 + ConditionIndex; + hash = hash * 31 + Hi.GetHashCode(); + hash = hash * 31 + Lo.GetHashCode(); + _cachedHash = hash; + } + } + return _cachedHash.Value; + } + + public override string ToString() + => $"({ConditionIndex} ? {Hi} : {Lo})"; + } +} diff --git a/Accordant.ModelChecking/Symbolic/TransitionTermAlgebra.cs b/Accordant.ModelChecking/Symbolic/TransitionTermAlgebra.cs new file mode 100644 index 0000000..48a4348 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/TransitionTermAlgebra.cs @@ -0,0 +1,569 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System; + using System.Collections.Generic; + + /// + /// Provides operations on transition terms with built-in cleaning and + /// leaf simplification. Combines three algebras: + /// + /// The condition EBA A (predicates over the alphabet) + /// The condition registry (ordering of conditions) + /// The leaf algebra B (Boolean operations on leaves with ACI) + /// + /// + /// All operations aggressively clean transition terms by: + /// + /// Tracking path conditions and pruning unreachable branches via SAT(A) + /// Applying trivial condition elimination: (α ? f : f) → f + /// Simplifying leaves using the leaf algebra's Boolean laws + /// + /// + /// Predicate type in the condition EBA. + /// Element type in the alphabet universe Σ. + /// Leaf type B of transition terms. + public class TransitionTermAlgebra + { + private readonly IEffectiveBooleanAlgebra _eba; + private readonly ConditionRegistry _registry; + private readonly ILeafAlgebra _leafAlgebra; + + // Hash-cons table for transition terms produced by this algebra. + // _byId: dense storage; Id i → canonical term at _byId[i]. + // _intern: dedup table used only at construction; keyed on structural + // equality (the existing TransitionTerm.Equals/GetHashCode). + // Bottom / Top are interned on first access; some leaf algebras (e.g. + // StateSetLeafAlgebra in NBW context) don't define Top, so eager + // construction would fail there. + private readonly List> _byId = + new List>(); + private readonly Dictionary, TransitionTerm> _intern = + new Dictionary, TransitionTerm>(); + private TransitionTerm _bottom; + private TransitionTerm _top; + + public TransitionTermAlgebra( + IEffectiveBooleanAlgebra eba, + ConditionRegistry registry, + ILeafAlgebra leafAlgebra) + { + _eba = eba ?? throw new ArgumentNullException(nameof(eba)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _leafAlgebra = leafAlgebra ?? throw new ArgumentNullException(nameof(leafAlgebra)); + } + + /// The condition EBA. + public IEffectiveBooleanAlgebra Eba => _eba; + + /// The condition registry (ordering). + public ConditionRegistry Registry => _registry; + + /// The leaf algebra. + public ILeafAlgebra LeafAlgebra => _leafAlgebra; + + /// Number of distinct canonical transition terms interned so far. + public int InternedCount => _byId.Count; + + /// + /// Total order on condition predicates within this algebra. Two + /// predicates compare equal iff they are registered at the same + /// index; otherwise the order is given by registration order in + /// . Unregistered predicates are registered + /// on the fly. This is the strict order that + /// enforces between an + /// outer ITE's condition and the conditions of its child ITEs. + /// + public int Compare(TPredicate a, TPredicate b) + => _registry.Register(a).CompareTo(_registry.Register(b)); + + /// + /// Return the canonical instance equal to , + /// allocating a fresh if this + /// shape has not been seen yet. + /// + public TransitionTerm Intern(TransitionTerm candidate) + { + if (candidate == null) return null; + if (candidate.HasId && candidate.Id < _byId.Count + && ReferenceEquals(_byId[candidate.Id], candidate)) + return candidate; + if (_intern.TryGetValue(candidate, out var existing)) return existing; + candidate.AssignId(_byId.Count); + _byId.Add(candidate); + _intern[candidate] = candidate; + return candidate; + } + + #region Smart Constructors + + /// Creates a (canonical, interned) leaf transition term. + public TransitionTerm Leaf(TLeaf value) + => Intern(TransitionTerm.Leaf(value)); + + /// The bottom leaf ⊥ (canonical, interned). + public TransitionTerm Bottom + => _bottom ?? (_bottom = Intern(TransitionTerm.Leaf(_leafAlgebra.Bottom))); + + /// The top leaf ⊤ (canonical, interned). + public TransitionTerm Top + => _top ?? (_top = Intern(TransitionTerm.Leaf(_leafAlgebra.Top))); + + /// + /// Creates an ITE (α ? hi : lo) with built-in cleaning. + /// Applies trivial condition elimination and checks feasibility. + /// + /// Condition index from the registry. + /// Then-case. + /// Else-case. + /// + /// The accumulated path condition for cleaning. + /// Pass null to skip path-based cleaning. + /// + public TransitionTerm MkIte( + int conditionIndex, + TransitionTerm hi, + TransitionTerm lo, + TPredicate pathCondition = default) + { + // Trivial condition elimination: (α ? f : f) → f + if (hi.Equals(lo)) + return hi; + + // Path-condition-based cleaning. + // + // Proposition splits (negative indices) are free Booleans: + // both branches are always reachable, the path condition is + // unchanged. Skip cleaning for them. See EREQ Phase-0 D5. + if (!ConditionRegistry.IsProposition(conditionIndex) + && pathCondition != null + && !EqualityComparer.Default.Equals(pathCondition, default)) + { + var condition = _registry.GetPredicate(conditionIndex); + + var thenPath = _eba.And(pathCondition, condition); + if (!_eba.IsSatisfiable(thenPath)) + return lo; // (⊥ ? _ : g) → g + + var elsePath = _eba.And(pathCondition, _eba.Not(condition)); + if (!_eba.IsSatisfiable(elsePath)) + return hi; // (⊤ ? f : _) → f + } + + return Intern(TransitionTerm.Ite(conditionIndex, hi, lo)); + } + + /// + /// Creates (α ? f) with implicit else-case ⊥. + /// The paper's shorthand when ⊥ ∈ B. + /// + public TransitionTerm MkGuard(int conditionIndex, TransitionTerm hi) + => MkIte(conditionIndex, hi, Bottom); + + #endregion + + #region Binary Operations (Apply with built-in cleaning + leaf simplification) + + /// + /// Disjunction of transition terms: f ∨ g. + /// Lifted via ITE propagation with ACI leaf normalization. + /// ⊥ is eliminated (unit of ∨). ⊤ short-circuits. + /// + public TransitionTerm Or( + TransitionTerm left, + TransitionTerm right) + { + return ApplyBinary(left, right, _leafAlgebra.Or, _eba.Top); + } + + /// + /// Conjunction of transition terms: f ∧ g. + /// Lifted via ITE propagation with ACI leaf normalization. + /// ⊤ is eliminated (unit of ∧). ⊥ short-circuits. + /// + public TransitionTerm And( + TransitionTerm left, + TransitionTerm right) + { + return ApplyBinary(left, right, _leafAlgebra.And, _eba.Top); + } + + /// + /// Complement of a transition term: ¬f. + /// Lifted via ITE propagation: ¬(α ? f : g) = (α ? ¬f : ¬g). + /// + public TransitionTerm Not(TransitionTerm term) + { + return MapUnary(term, _leafAlgebra.Not); + } + + /// + /// Symmetric difference of transition terms: f ⊕ g. + /// Lifted via ITE propagation with leaf-level XOR. Used by the + /// bisimulation-based equivalence algorithm (CAV'26 §6), where + /// δ(p ⊕ q) = δp ⊕ δq. + /// + public TransitionTerm Xor( + TransitionTerm left, + TransitionTerm right) + { + return ApplyBinary(left, right, _leafAlgebra.Xor, _eba.Top); + } + + /// + /// Top-level disjunction (Antimirov normal form). + /// Instead of propagating ∨ into ITE branches, maintains + /// a list of disjuncts. Useful for nondeterministic representations + /// where it is irrelevant how conditions in separate disjuncts + /// relate to each other. + /// + /// The disjuncts to combine. + /// + /// A list of transition terms representing the disjunction, + /// with duplicates removed and ⊥-disjuncts eliminated. + /// + public IReadOnlyList> DisjunctiveForm( + IEnumerable> disjuncts) + { + var result = new List>(); + var seen = new HashSet>(); + + foreach (var d in disjuncts) + { + // Skip ⊥ disjuncts (unit of ∨) + if (d is TransitionTermLeaf leaf && _leafAlgebra.IsBottom(leaf.Value)) + continue; + + // ⊤ short-circuit + if (d is TransitionTermLeaf topLeaf && _leafAlgebra.IsTop(topLeaf.Value)) + return new List> { d }; + + // Idempotency: skip duplicates + if (seen.Add(d)) + result.Add(d); + } + + if (result.Count == 0) + result.Add(Bottom); + + return result; + } + + /// + /// General Apply: lifts a binary operation ⋄ : B × B → B to TTerm. + /// Merges the ordered ITE structures with built-in cleaning. + /// + public TransitionTerm ApplyBinary( + TransitionTerm left, + TransitionTerm right, + Func operation, + TPredicate pathCondition) + { + var cache = new Dictionary>(); + return ApplyCore(left, right, operation, pathCondition, cache); + } + + private TransitionTerm ApplyCore( + TransitionTerm left, + TransitionTerm right, + Func operation, + TPredicate pathCondition, + Dictionary> cache) + { + // Memoization + long key = CombineIds( + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(left), + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(right)); + if (cache.TryGetValue(key, out var cached)) + return cached; + + TransitionTerm result; + + if (left.IsLeaf && right.IsLeaf) + { + var leftVal = ((TransitionTermLeaf)left).Value; + var rightVal = ((TransitionTermLeaf)right).Value; + result = Leaf(operation(leftVal, rightVal)); + } + else + { + int splitLevel; + TransitionTerm leftHi, leftLo, rightHi, rightLo; + + DecomposePair(left, right, out splitLevel, out leftHi, out leftLo, out rightHi, out rightLo); + + // Proposition splits (EREQ Phase-0 D5): no path tightening, + // both branches always reachable. + if (ConditionRegistry.IsProposition(splitLevel)) + { + var hi0 = ApplyCore(leftHi, rightHi, operation, pathCondition, cache); + var lo0 = ApplyCore(leftLo, rightLo, operation, pathCondition, cache); + result = MkIte(splitLevel, hi0, lo0); + } + else + { + var condition = _registry.GetPredicate(splitLevel); + + // Clean: check then-branch reachability + var thenPath = _eba.And(pathCondition, condition); + var elsePath = _eba.And(pathCondition, _eba.Not(condition)); + + bool thenReachable = _eba.IsSatisfiable(thenPath); + bool elseReachable = _eba.IsSatisfiable(elsePath); + + if (!thenReachable && !elseReachable) + { + // Shouldn't normally happen; fallback to lo + result = ApplyCore(leftLo, rightLo, operation, pathCondition, cache); + } + else if (!thenReachable) + { + result = ApplyCore(leftLo, rightLo, operation, elsePath, cache); + } + else if (!elseReachable) + { + result = ApplyCore(leftHi, rightHi, operation, thenPath, cache); + } + else + { + var hi = ApplyCore(leftHi, rightHi, operation, thenPath, cache); + var lo = ApplyCore(leftLo, rightLo, operation, elsePath, cache); + result = MkIte(splitLevel, hi, lo); + } + } + } + + cache[key] = result; + return result; + } + + /// + /// Decomposes a pair of terms at the top-most condition level. + /// If one term has a lower level, the other is passed through unchanged. + /// + private static void DecomposePair( + TransitionTerm left, + TransitionTerm right, + out int splitLevel, + out TransitionTerm leftHi, + out TransitionTerm leftLo, + out TransitionTerm rightHi, + out TransitionTerm rightLo) + { + int leftLevel = left.Level; + int rightLevel = right.Level; + + if (leftLevel == rightLevel) + { + // Same condition: decompose both + splitLevel = leftLevel; + var li = (TransitionTermIte)left; + var ri = (TransitionTermIte)right; + leftHi = li.Hi; leftLo = li.Lo; + rightHi = ri.Hi; rightLo = ri.Lo; + } + else if (leftLevel < rightLevel) + { + // Left has smaller level: split on left, right passes through + splitLevel = leftLevel; + var li = (TransitionTermIte)left; + leftHi = li.Hi; leftLo = li.Lo; + rightHi = right; rightLo = right; + } + else + { + // Right has smaller level: split on right, left passes through + splitLevel = rightLevel; + var ri = (TransitionTermIte)right; + leftHi = left; leftLo = left; + rightHi = ri.Hi; rightLo = ri.Lo; + } + } + + #endregion + + #region Unary Operations (Map with leaf simplification) + + /// + /// General Map: lifts a unary operation ♦ : B → B to TTerm. + /// From the paper equation (2): ♦(α ? f : g) = (α ? ♦f : ♦g) + /// + public TransitionTerm MapUnary( + TransitionTerm term, + Func operation) + { + if (term is TransitionTermLeaf leaf) + return Leaf(operation(leaf.Value)); + + var ite = (TransitionTermIte)term; + var hi = MapUnary(ite.Hi, operation); + var lo = MapUnary(ite.Lo, operation); + return MkIte(ite.ConditionIndex, hi, lo); + } + + /// + /// Cross-type Map: lifts ♦ : B → B' to TTerm, producing a new leaf type. + /// + public TransitionTerm MapUnary( + TransitionTerm term, + Func operation) + { + if (term is TransitionTermLeaf leaf) + return TransitionTerm.Leaf(operation(leaf.Value)); + + var ite = (TransitionTermIte)term; + var hi = MapUnary(ite.Hi, operation); + var lo = MapUnary(ite.Lo, operation); + return TransitionTerm.Ite(ite.ConditionIndex, hi, lo); + } + + /// + /// Cross-type Apply: lifts ⋄ : B₁ × B₂ → B' to TTerm. + /// Used when the leaf types differ (e.g., in alternation elimination + /// and RLTL derivative rules per Sections 5 and 7.3 of the paper). + /// + public TransitionTerm ApplyCross( + TransitionTerm left, + TransitionTerm right, + Func operation, + TPredicate pathCondition) + { + var cache = new Dictionary>(); + return ApplyCrossCore(left, right, operation, pathCondition, cache); + } + + private TransitionTerm ApplyCrossCore( + TransitionTerm left, + TransitionTerm right, + Func operation, + TPredicate pathCondition, + Dictionary> cache) + { + long key = CombineIds( + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(left), + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(right)); + if (cache.TryGetValue(key, out var cached)) + return cached; + + TransitionTerm result; + + if (left.IsLeaf && right.IsLeaf) + { + var leftVal = ((TransitionTermLeaf)left).Value; + var rightVal = ((TransitionTermLeaf)right).Value; + result = TransitionTerm.Leaf(operation(leftVal, rightVal)); + } + else + { + int splitLevel; + TransitionTerm leftHi, leftLo; + TransitionTerm rightHi, rightLo; + + DecomposePairCross(left, right, out splitLevel, out leftHi, out leftLo, out rightHi, out rightLo); + + if (ConditionRegistry.IsProposition(splitLevel)) + { + var hi0 = ApplyCrossCore(leftHi, rightHi, operation, pathCondition, cache); + var lo0 = ApplyCrossCore(leftLo, rightLo, operation, pathCondition, cache); + result = TransitionTerm.Ite(splitLevel, hi0, lo0); + } + else + { + var condition = _registry.GetPredicate(splitLevel); + var thenPath = _eba.And(pathCondition, condition); + var elsePath = _eba.And(pathCondition, _eba.Not(condition)); + + bool thenReachable = _eba.IsSatisfiable(thenPath); + bool elseReachable = _eba.IsSatisfiable(elsePath); + + if (!thenReachable && !elseReachable) + { + result = ApplyCrossCore(leftLo, rightLo, operation, pathCondition, cache); + } + else if (!thenReachable) + { + result = ApplyCrossCore(leftLo, rightLo, operation, elsePath, cache); + } + else if (!elseReachable) + { + result = ApplyCrossCore(leftHi, rightHi, operation, thenPath, cache); + } + else + { + var hi = ApplyCrossCore(leftHi, rightHi, operation, thenPath, cache); + var lo = ApplyCrossCore(leftLo, rightLo, operation, elsePath, cache); + result = TransitionTerm.Ite(splitLevel, hi, lo); + } + } + } + + cache[key] = result; + return result; + } + + private static void DecomposePairCross( + TransitionTerm left, + TransitionTerm right, + out int splitLevel, + out TransitionTerm leftHi, + out TransitionTerm leftLo, + out TransitionTerm rightHi, + out TransitionTerm rightLo) + { + int leftLevel = left.Level; + int rightLevel = right.Level; + + if (leftLevel == rightLevel) + { + splitLevel = leftLevel; + var li = (TransitionTermIte)left; + var ri = (TransitionTermIte)right; + leftHi = li.Hi; leftLo = li.Lo; + rightHi = ri.Hi; rightLo = ri.Lo; + } + else if (leftLevel < rightLevel) + { + splitLevel = leftLevel; + var li = (TransitionTermIte)left; + leftHi = li.Hi; leftLo = li.Lo; + rightHi = right; rightLo = right; + } + else + { + splitLevel = rightLevel; + var ri = (TransitionTermIte)right; + leftHi = left; leftLo = left; + rightHi = ri.Hi; rightLo = ri.Lo; + } + } + + #endregion + + #region Alternation Product (@) + + /// + /// The alternation product f @ g from the paper (Section 5.1, equation 6). + /// Used in the Æ alternation elimination algorithm. + /// Lifted to transition terms via ITE propagation with built-in cleaning. + /// + /// The concrete @ operation on DNF leaves is defined by the caller. + /// + public TransitionTerm AlternationProduct( + TransitionTerm left, + TransitionTerm right, + Func atOperation) + { + return ApplyCross(left, right, atOperation, _eba.Top); + } + + #endregion + + #region Utilities + + private static long CombineIds(int a, int b) + { + return ((long)a << 32) | (uint)b; + } + + #endregion + } +} diff --git a/Accordant.ModelChecking/TarjanSCC.cs b/Accordant.ModelChecking/TarjanSCC.cs new file mode 100644 index 0000000..741b9ff --- /dev/null +++ b/Accordant.ModelChecking/TarjanSCC.cs @@ -0,0 +1,200 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// Represents a Strongly Connected Component (SCC) in the state graph. + /// An SCC is a maximal set of nodes where every node is reachable from every other node. + /// + public class StronglyConnectedComponent + { + /// + /// The nodes in this SCC. + /// + public List Nodes { get; } = new List(); + + /// + /// Returns true if this SCC has a real cycle (more than one node, or a self-loop). + /// + public bool HasCycle { get; internal set; } + + /// + /// Returns true if any node in this SCC satisfies the given predicate. + /// + public bool Any(Func predicate) + { + return Nodes.Any(predicate); + } + + /// + /// Returns true if all nodes in this SCC satisfy the given predicate. + /// + public bool All(Func predicate) + { + return Nodes.All(predicate); + } + + /// + /// Project an arbitrary collection of system s + /// (deduplicated by ) + /// into a . Used by the + /// symbolic backends (, + /// ) to attach a system-level + /// SCC to so the + /// enabled-but-not-taken fairness hint fires consistently with + /// the explicit-LTL backend. + /// + internal static StronglyConnectedComponent FromSystemNodes(IEnumerable nodes, bool hasCycle = true) + { + var scc = new StronglyConnectedComponent(); + var seen = new HashSet(); + foreach (var n in nodes) + { + if (n == null) continue; + var fp = n.GetNodeFingerprint(); + if (seen.Add(fp)) scc.Nodes.Add(n); + } + scc.HasCycle = hasCycle; + return scc; + } + } + + /// + /// Implements Tarjan's algorithm for finding Strongly Connected Components. + /// Time complexity: O(V + E) + /// + public static class TarjanSCC + { + /// + /// Finds all SCCs in the state graph reachable from the given root. + /// SCCs are returned in reverse topological order (leaf SCCs first). + /// + public static List FindSCCs(StateGraphNode root) + { + var result = new List(); + var indexMap = new Dictionary(); + var lowLinkMap = new Dictionary(); + var onStack = new HashSet(); + var stack = new Stack(); + int index = 0; + + void StrongConnect(StateGraphNode node) + { + var nodeId = node.GetNodeFingerprint(); + indexMap[nodeId] = index; + lowLinkMap[nodeId] = index; + index++; + stack.Push(node); + onStack.Add(nodeId); + + foreach (var edge in node.Edges) + { + var successor = edge.Target; + var successorId = successor.GetNodeFingerprint(); + + if (!indexMap.ContainsKey(successorId)) + { + // Successor not yet visited + StrongConnect(successor); + lowLinkMap[nodeId] = Math.Min(lowLinkMap[nodeId], lowLinkMap[successorId]); + } + else if (onStack.Contains(successorId)) + { + // Successor is on stack, hence in current SCC + lowLinkMap[nodeId] = Math.Min(lowLinkMap[nodeId], indexMap[successorId]); + } + } + + // If node is a root node, pop the stack and generate an SCC + if (lowLinkMap[nodeId] == indexMap[nodeId]) + { + var scc = new StronglyConnectedComponent(); + StateGraphNode w; + do + { + w = stack.Pop(); + onStack.Remove(w.GetNodeFingerprint()); + scc.Nodes.Add(w); + } while (w.GetNodeFingerprint() != nodeId); + + // Determine if SCC has a real cycle + scc.HasCycle = DetermineHasCycle(scc); + result.Add(scc); + } + } + + StrongConnect(root); + return result; + } + + private static bool DetermineHasCycle(StronglyConnectedComponent scc) + { + // More than one node means there's definitely a cycle + if (scc.Nodes.Count > 1) + { + return true; + } + + // Single node - check for self-loop + if (scc.Nodes.Count == 1) + { + var node = scc.Nodes[0]; + var nodeId = node.GetNodeFingerprint(); + return node.Edges.Any(e => e.Target.GetNodeFingerprint() == nodeId); + } + + return false; + } + + /// + /// Gets all SCCs that are reachable from any node in the given starting set. + /// + public static HashSet GetReachableSCCs( + StateGraphNode startNode, + List allSCCs) + { + // Build a map from node fingerprint to SCC + var nodeToSCC = new Dictionary(); + foreach (var scc in allSCCs) + { + foreach (var node in scc.Nodes) + { + nodeToSCC[node.GetNodeFingerprint()] = scc; + } + } + + // BFS/DFS to find all reachable SCCs + var reachable = new HashSet(); + var visited = new HashSet(); + var queue = new Queue(); + + queue.Enqueue(startNode); + visited.Add(startNode.GetNodeFingerprint()); + + while (queue.Count > 0) + { + var node = queue.Dequeue(); + var nodeId = node.GetNodeFingerprint(); + + if (nodeToSCC.TryGetValue(nodeId, out var scc)) + { + reachable.Add(scc); + } + + foreach (var edge in node.Edges) + { + var successorId = edge.Target.GetNodeFingerprint(); + if (!visited.Contains(successorId)) + { + visited.Add(successorId); + queue.Enqueue(edge.Target); + } + } + } + + return reachable; + } + } +} diff --git a/Accordant.ModelChecking/Testing/LtlMultiBackendCrossCheck.cs b/Accordant.ModelChecking/Testing/LtlMultiBackendCrossCheck.cs new file mode 100644 index 0000000..2fc1622 --- /dev/null +++ b/Accordant.ModelChecking/Testing/LtlMultiBackendCrossCheck.cs @@ -0,0 +1,245 @@ +namespace Microsoft.Accordant.ModelChecking.Testing +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Rltl; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// One backend's verdict in a multi-backend differential check. + /// + public sealed class BackendVerdict + { + public string BackendName { get; } + public PropertyCheckingResult Result { get; } + public bool Skipped { get; } + public string SkipReason { get; } + + public BackendVerdict(string name, PropertyCheckingResult result) + { + BackendName = name; + Result = result; + } + + public BackendVerdict(string name, string skipReason) + { + BackendName = name; + Skipped = true; + SkipReason = skipReason; + } + } + + /// + /// Aggregate result of running the same LTL property through every + /// available model-checking backend. + /// + public sealed class MultiBackendCrossCheckResult + { + public string Label { get; } + public IReadOnlyList Verdicts { get; } + + public MultiBackendCrossCheckResult(string label, IReadOnlyList verdicts) + { + Label = label; + Verdicts = verdicts; + } + + /// + /// True iff every non-skipped backend returned the same + /// . + /// + public bool Unanimous + { + get + { + var active = Verdicts.Where(v => !v.Skipped).ToList(); + if (active.Count <= 1) return true; + var first = active[0].Result.Valid; + return active.All(v => v.Result.Valid == first); + } + } + + /// + /// Throws when + /// any pair of active backends disagrees on validity. + /// + public MultiBackendCrossCheckResult ThrowIfDisagree() + { + if (!Unanimous) throw new MultiBackendDisagreementException(this); + return this; + } + } + + /// + /// Raised when the multi-backend oracle detects a disagreement. + /// The message lists every backend's verdict and trace so that the + /// failing run is self-contained for triage. + /// + public sealed class MultiBackendDisagreementException : Exception + { + public MultiBackendCrossCheckResult Result { get; } + + public MultiBackendDisagreementException(MultiBackendCrossCheckResult result) + : base(BuildMessage(result)) + { + Result = result; + } + + private static string BuildMessage(MultiBackendCrossCheckResult r) + { + var sb = new StringBuilder(); + var label = string.IsNullOrEmpty(r.Label) ? "(unlabeled)" : r.Label; + sb.AppendLine($"Multi-backend disagreement on '{label}':"); + foreach (var v in r.Verdicts) + { + if (v.Skipped) + sb.AppendLine($" {v.BackendName}: SKIPPED ({v.SkipReason})"); + else + sb.AppendLine($" {v.BackendName}: Valid={v.Result.Valid}"); + } + foreach (var v in r.Verdicts.Where(x => !x.Skipped)) + { + sb.AppendLine(); + sb.AppendLine($"---- {v.BackendName} trace ----"); + sb.AppendLine(v.Result.GetTraceString()); + } + return sb.ToString(); + } + } + + /// + /// Differential model-checking oracle: runs the same LTL property + /// through every available backend + /// (, + /// , + /// , + /// on the lifted formula) and asserts they + /// all return the same verdict. + /// + /// + /// Disagreement is by definition a bug in at least one backend. The + /// oracle is the highest-leverage tool for surfacing such bugs + /// because the four backends share almost no implementation: the + /// explicit uses on-the-fly product+SCC over + /// the legacy , symbolic-LTL goes + /// ABW→NBW→product+SCC, NDFS uses nested-DFS on the same NBW, and + /// RLTL goes through the regex/derivative pipeline via + /// . + /// + /// + /// + /// Backends that don't accept a fairness parameter + /// () are skipped — and + /// recorded as such — whenever a non-trivial fairness is supplied. + /// Skipped backends do not contribute to the unanimity test. + /// + /// + public static class LtlMultiBackendCrossCheck + { + /// + /// Runs every backend and returns their verdicts. Does not + /// throw; use + /// or test the + /// property directly. + /// + public static MultiBackendCrossCheckResult Run( + StateGraphNode root, + LtlFormula ltl, + Fairness fairness = null, + string label = null) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (ltl == null) throw new ArgumentNullException(nameof(ltl)); + + var fair = fairness ?? Fairness.None; + bool fairnessIsTrivial = ReferenceEquals(fair, Fairness.None); + + var verdicts = new List(); + + // 1) Explicit LtlCheck on the legacy LtlFormula DSL. + verdicts.Add(new BackendVerdict( + "LtlCheck", + LtlCheck.Check(root, ltl, fair))); + + // 2) Symbolic LTL (Tarjan product+SCC) on the symbolic + // Ltl conversion. + // Each conversion creates fresh StateProp atoms; that's + // intentional — each backend gets an independent atom + // namespace and cannot accidentally share state. + var symLtl1 = LtlFormulaToSymbolic.Convert(ltl); + verdicts.Add(new BackendVerdict( + "SymbolicLtlCheck.Check", + SymbolicLtlCheck.Check(root, symLtl1, maxDepth: 0, fairness: fair))); + + // 3) Symbolic LTL via Nested DFS. Does not accept fairness; + // only meaningful when fairness is trivial. + if (fairnessIsTrivial) + { + var symLtl2 = LtlFormulaToSymbolic.Convert(ltl); + verdicts.Add(new BackendVerdict( + "SymbolicLtlCheck.CheckNDFS", + SymbolicLtlCheck.CheckNDFS(root, symLtl2, maxDepth: 0))); + } + else + { + verdicts.Add(new BackendVerdict( + "SymbolicLtlCheck.CheckNDFS", + "fairness not supported by NDFS")); + } + + // 4) RLTL via the mechanical Lift, exercising the regex + // derivative pipeline. + var rltl = LtlToRltl.Lift(ltl); + verdicts.Add(new BackendVerdict( + "RltlCheck", + RltlCheck.Check(root, rltl, maxDepth: 0, fairness: fair))); + + return new MultiBackendCrossCheckResult(label, verdicts); + } + } + + /// + /// Mechanical conversion from the legacy + /// DSL into the symbolic representation + /// over . Mirrors + /// but targets the symbolic LTL tree + /// directly so that the symbolic-LTL backends can be exercised + /// with the same author-facing formula authors write today. + /// + public static class LtlFormulaToSymbolic + { + public static Ltl Convert(LtlFormula phi) + { + var alg = LtlAlgebra.Default; + switch (phi) + { + case LtlTrue _: return alg.True; + case LtlFalse _: return alg.False; + case LtlProp p: return alg.Atom( + new StatePredAtom(new StateProp(p.ToString(), p.Predicate))); + case LtlNot n: return alg.Not(Convert(n.Inner)); + case LtlAnd a: + { + Ltl acc = alg.True; + foreach (var c in a.Children) acc = alg.And(acc, Convert(c)); + return acc; + } + case LtlOr o: + { + Ltl acc = alg.False; + foreach (var c in o.Children) acc = alg.Or(acc, Convert(c)); + return acc; + } + case LtlNext n: return alg.Next(Convert(n.Inner)); + case LtlUntil u: return alg.Until(Convert(u.Hold), Convert(u.Goal)); + case LtlRelease r: return alg.Release(Convert(r.Release_), Convert(r.Hold)); + default: + throw new NotSupportedException( + $"LtlFormulaToSymbolic.Convert: unsupported LTL node {phi.GetType().Name}"); + } + } + } +} diff --git a/Accordant.ModelChecking/Testing/LtlRltlCrossCheck.cs b/Accordant.ModelChecking/Testing/LtlRltlCrossCheck.cs new file mode 100644 index 0000000..949ecbe --- /dev/null +++ b/Accordant.ModelChecking/Testing/LtlRltlCrossCheck.cs @@ -0,0 +1,153 @@ +using System; +using Microsoft.Accordant.ModelChecking.Ltl; +using Microsoft.Accordant.ModelChecking.Rltl; + +namespace Microsoft.Accordant.ModelChecking.Testing +{ + /// + /// Result of running an and an + /// on the same model under the same fairness and comparing their verdicts. + /// + public sealed class CrossCheckResult + { + public PropertyCheckingResult Ltl { get; } + public PropertyCheckingResult Rltl { get; } + public bool Agree => Ltl.Valid == Rltl.Valid; + public string Label { get; } + + public CrossCheckResult(string label, PropertyCheckingResult ltl, PropertyCheckingResult rltl) + { + Label = label; + Ltl = ltl; + Rltl = rltl; + } + + /// + /// Throws if the two + /// checkers disagree on validity. + /// + public CrossCheckResult ThrowIfDisagree() + { + if (!Agree) + throw new LtlRltlDisagreementException(this); + return this; + } + } + + /// + /// Raised by when the LTL and RLTL + /// checkers return different verdicts on the same property. The message + /// embeds both traces to aid debugging. + /// + public sealed class LtlRltlDisagreementException : Exception + { + public CrossCheckResult Result { get; } + + public LtlRltlDisagreementException(CrossCheckResult result) + : base(BuildMessage(result)) + { + Result = result; + } + + private static string BuildMessage(CrossCheckResult r) + { + var label = string.IsNullOrEmpty(r.Label) ? "(unlabeled)" : r.Label; + return $"LTL/RLTL disagreement on '{label}': LTL.Valid={r.Ltl.Valid}, RLTL.Valid={r.Rltl.Valid}.\n" + + $"---- LTL trace ----\n{r.Ltl.GetTraceString()}\n" + + $"---- RLTL trace ----\n{r.Rltl.GetTraceString()}"; + } + } + + /// + /// Runs an LTL formula and its RLTL counterpart against the same state + /// graph + fairness combination and reports whether they agree. Intended + /// for sample test suites where a property is naturally expressible in + /// both logics. + /// + /// + /// Use for the common case of mechanically + /// lifting an LTL formula into an equivalent RLTL formula, or supply + /// both formulas directly to the + /// overload. + /// + /// + public static class LtlRltlCrossCheck + { + /// + /// Checks with and + /// with on the + /// same under . + /// Returns a with both verdicts; + /// does not throw on disagreement. + /// + public static CrossCheckResult Run( + StateGraphNode root, + LtlFormula ltl, + RltlFormula rltl, + Fairness fairness = null, + string label = null) + { + if (root == null) throw new ArgumentNullException(nameof(root)); + if (ltl == null) throw new ArgumentNullException(nameof(ltl)); + if (rltl == null) throw new ArgumentNullException(nameof(rltl)); + + var fair = fairness ?? Fairness.None; + var ltlResult = LtlCheck.Check(root, ltl, fair); + var rltlResult = RltlCheck.Check(root, rltl, fairness: fair); + return new CrossCheckResult(label, ltlResult, rltlResult); + } + + /// + /// Convenience overload: lifts to RLTL via + /// and runs the cross-check. + /// + public static CrossCheckResult Run( + StateGraphNode root, + LtlFormula ltl, + Fairness fairness = null, + string label = null) + { + var rltl = LtlToRltl.Lift(ltl); + return Run(root, ltl, rltl, fairness, label); + } + } + + /// + /// Mechanical translation from an LTL formula into the structurally + /// equivalent RLTL formula. The translation is one-to-one on the LTL + /// fragment (constants, atoms, Boolean ops, Next, Until, Release) and + /// preserves derived combinators (◇, □, leads-to, …) because both DSLs + /// expand them to the same Until/Release-based encoding. + /// + public static class LtlToRltl + { + public static RltlFormula Lift(LtlFormula phi) + { + switch (phi) + { + case LtlTrue _: return RltlFormula.True; + case LtlFalse _: return RltlFormula.False; + case LtlProp p: return RltlFormula.Prop(p.Predicate, p.ToString()); + case LtlNot n: return RltlFormula.Not(Lift(n.Inner)); + case LtlAnd a: + { + RltlFormula acc = RltlFormula.True; + foreach (var c in a.Children) acc = RltlFormula.And(acc, Lift(c)); + return acc; + } + case LtlOr o: + { + RltlFormula acc = RltlFormula.False; + foreach (var c in o.Children) acc = RltlFormula.Or(acc, Lift(c)); + return acc; + } + case LtlNext n: return RltlFormula.Next(Lift(n.Inner)); + case LtlUntil u: return RltlFormula.Until(Lift(u.Hold), Lift(u.Goal)); + case LtlRelease r: return RltlFormula.Release(Lift(r.Release_), Lift(r.Hold)); + default: + throw new NotSupportedException( + $"LtlToRltl.Lift: unsupported LTL node {phi.GetType().Name}"); + } + } + } +} diff --git a/Accordant.ModelChecking/Testing/RandomLtlGenerator.cs b/Accordant.ModelChecking/Testing/RandomLtlGenerator.cs new file mode 100644 index 0000000..68a2570 --- /dev/null +++ b/Accordant.ModelChecking/Testing/RandomLtlGenerator.cs @@ -0,0 +1,86 @@ +using Microsoft.Accordant; + +namespace Microsoft.Accordant.ModelChecking.Testing +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Ltl; + + /// + /// Bounded-depth random LTL formula generator over a fixed atomic- + /// proposition vocabulary. Used to drive + /// for differential bug + /// hunting: a disagreement on any single generated formula + /// localizes a real soundness gap in one of the backends. + /// + /// + /// The grammar covers the full LTL fragment shared by the four + /// backends: + /// + /// + /// True | False | p_i | ¬p_i (leaves) + /// ¬φ | φ ∧ ψ | φ ∨ ψ + /// Xφ | φ U ψ | φ R ψ + /// + /// + /// + /// Tree shape is controlled by maxDepth; at depth 0 only + /// leaves are produced. The distribution is mildly biased toward + /// temporal operators so that random formulas stress the + /// derivative / NBW construction rather than degenerating into + /// pure-boolean tautologies. + /// + /// + public sealed class RandomLtlGenerator + { + private readonly Random _rng; + private readonly IReadOnlyList<(Func pred, string name)> _atoms; + + public RandomLtlGenerator(int seed, IReadOnlyList<(Func pred, string name)> atoms) + { + if (atoms == null || atoms.Count == 0) + throw new ArgumentException("At least one atomic proposition required.", nameof(atoms)); + _rng = new Random(seed); + _atoms = atoms; + } + + /// + /// Generates a single random LTL formula whose syntax tree + /// has depth at most . + /// + public LtlFormula Generate(int maxDepth) + { + if (maxDepth < 0) throw new ArgumentOutOfRangeException(nameof(maxDepth)); + return Build(maxDepth); + } + + private LtlFormula Build(int depth) + { + // At depth 0 we must emit a leaf. + if (depth == 0) return RandomLeaf(); + + // Otherwise pick a node weighted toward temporal operators. + // Weights: leaf=2, ¬=1, ∧=2, ∨=2, X=2, U=3, R=3 (sum = 15). + var roll = _rng.Next(15); + if (roll < 2) return RandomLeaf(); + if (roll < 3) return LtlFormula.Not(Build(depth - 1)); + if (roll < 5) return LtlFormula.And(Build(depth - 1), Build(depth - 1)); + if (roll < 7) return LtlFormula.Or(Build(depth - 1), Build(depth - 1)); + if (roll < 9) return LtlFormula.Next(Build(depth - 1)); + if (roll < 12) return LtlFormula.Until(Build(depth - 1), Build(depth - 1)); + return LtlFormula.Release(Build(depth - 1), Build(depth - 1)); + } + + private LtlFormula RandomLeaf() + { + var roll = _rng.Next(_atoms.Count + 2); + if (roll == _atoms.Count) return LtlFormula.True; + if (roll == _atoms.Count + 1) return LtlFormula.False; + var (pred, name) = _atoms[roll]; + // Randomly negate atoms half the time to exercise atom-level NNF. + if (_rng.Next(2) == 0) + return LtlFormula.Prop(pred, name); + return LtlFormula.Not(LtlFormula.Prop(pred, name)); + } + } +} diff --git a/Accordant.ModelChecking/TraceItem.cs b/Accordant.ModelChecking/TraceItem.cs new file mode 100644 index 0000000..614576a --- /dev/null +++ b/Accordant.ModelChecking/TraceItem.cs @@ -0,0 +1,66 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// A trace consists of a sequence of trace items. Each trace item + /// indicates the action that led to the state in the trace item. + /// + public class TraceItem + { + /// + /// The step function that when applied to the state in the previous + /// trace item led to the state graph node in this trace item. + /// + public IStepFunction StepFunction { get; } + + /// + /// The state graph node at this point in the trace. + /// + public StateGraphNode StateGraphNode { get; } + + /// + /// Indicates whether this trace item is part of a cycle (for liveness counterexamples). + /// + public bool IsInCycle { get; } + + /// + /// Concrete valuation of the symbolic predicates that participated + /// in the property being checked, evaluated against this item's + /// state. null for traces produced by the explicit (non-symbolic) + /// checker. For traces produced by the symbolic LTL/RLTL backends, this + /// is populated by with one entry per + /// registered predicate (typically the atoms of the property, plus any + /// derived predicates pulled in through regex closures). + /// + public IReadOnlyDictionary Valuation { get; } + + /// + /// Constructs an instance of this class. + /// + public TraceItem( + IStepFunction stepFunction, + StateGraphNode stateGraphNode, + bool isInCycle = false) + : this(stepFunction, stateGraphNode, isInCycle, valuation: null) + { + } + + /// + /// Constructs an instance of this class with a concrete predicate + /// valuation attached. + /// + public TraceItem( + IStepFunction stepFunction, + StateGraphNode stateGraphNode, + bool isInCycle, + IReadOnlyDictionary valuation) + { + StepFunction = stepFunction; + StateGraphNode = stateGraphNode; + IsInCycle = isInCycle; + Valuation = valuation; + } + } +} diff --git a/Accordant.slnx b/Accordant.slnx index 3afd4cf..ea5797a 100644 --- a/Accordant.slnx +++ b/Accordant.slnx @@ -1,6 +1,7 @@ + @@ -15,6 +16,11 @@ + + + + + @@ -23,4 +29,6 @@ + + diff --git a/Samples/AlternatingBit/AltBit.cs b/Samples/AlternatingBit/AltBit.cs new file mode 100644 index 0000000..54ccf84 --- /dev/null +++ b/Samples/AlternatingBit/AltBit.cs @@ -0,0 +1,266 @@ +namespace AlternatingBit +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant; + + /// + /// Alternating-Bit Protocol (Bartlett, Scantlebury & Wilkinson, 1969) + /// between a single sender and a single receiver, over two capacity-1 + /// lossy channels. Each transmission carries a 1-bit sequence number + /// that flips on every successful delivery, so the receiver can tell + /// fresh data from retransmissions. + /// + /// To keep exploration finite the sender is bounded to + /// distinct payloads. Once + /// NextPayload == MaxMessages and both channels are empty, a + /// self-loop keeps the state graph non-terminating + /// (so every state has an outgoing edge — required by the underlying + /// cycle-detection machinery). + /// + /// + public static class AltBit + { + /// Number of distinct payloads the sender transmits. + public const int MaxMessages = 3; + + // --- Atomic predicates -------------------------------------------- + + /// Delivered is a prefix of [0, 1, …, MaxMessages-1]. + public static bool InOrder(IState s) + { + var st = (AltBitState)s; + for (int i = 0; i < st.Delivered.Length; i++) + if (st.Delivered[i] != i) return false; + return true; + } + + /// All payloads have been delivered. + public static bool AllDelivered(IState s) + => ((AltBitState)s).Delivered.Length == MaxMessages; + + /// Sender currently holds bit 0. + public static bool SenderBit0(IState s) => ((AltBitState)s).SenderBit == 0; + /// Sender currently holds bit 1. + public static bool SenderBit1(IState s) => ((AltBitState)s).SenderBit == 1; + + // --- State-graph construction ------------------------------------ + + /// + /// Initial state: both bits at 0, both channels empty, nothing + /// delivered yet. + /// + public static AltBitState InitialState() => new AltBitState + { + SenderBit = 0, + ReceiverBit = 0, + DataChanHas = false, + AckChanHas = false, + Delivered = Array.Empty(), + NextPayload = 0, + }; + + /// The full list of step functions exercised by the model. + public static IList AllSteps() => new IStepFunction[] + { + new SendStep(), + new LoseDataStep(), + new ReceiveStep(), + new LoseAckStep(), + new ReceiveAckStep(), + new StutterStep(), + }; + + /// + /// Bug-injection variant: replaces with + /// , which delivers every in-flight + /// payload regardless of whether the data-channel bit matches the + /// expected . Duplicates get + /// re-delivered, so Delivered no longer respects the + /// in-order prefix discipline asserted by . + /// + public static IList AllStepsBuggy() => new IStepFunction[] + { + new SendStep(), + new LoseDataStep(), + new BuggyReceiveStep(), + new LoseAckStep(), + new ReceiveAckStep(), + new StutterStep(), + }; + + // --- Step functions ---------------------------------------------- + + /// Common scaffolding for an AltBit step. + public abstract class AltBitStep : BaseStepFunction + { + /// Stable id keyed on the concrete type name — see Peterson sample for rationale. + public override string StepFunctionId => GetType().Name; + + public abstract bool IsEnabled(AltBitState s); + public abstract AltBitState Apply(AltBitState s); + + protected override IList ApplyInternal(IState state) + { + var s = (AltBitState)state; + if (!IsEnabled(s)) return null; + return new[] + { + new StepResult + { + State = Apply(s), + StepFunctions = new IStepFunction[] { this }, + } + }; + } + } + + /// + /// Sender posts (SenderBit, NextPayload) on the empty data + /// channel. Retransmission is modeled by the combination of + /// emptying the channel and this step + /// firing again with the same un-acked payload. + /// + public sealed class SendStep : AltBitStep + { + public override bool IsEnabled(AltBitState s) + => !s.DataChanHas && s.NextPayload < MaxMessages; + + public override AltBitState Apply(AltBitState s) + { + var n = (AltBitState)s.Clone(); + n.DataChanHas = true; + n.DataChanBit = s.SenderBit; + n.DataChanPayload = s.NextPayload; + return n; + } + } + + /// Lossy S→R channel drops the in-flight message. + public sealed class LoseDataStep : AltBitStep + { + public override bool IsEnabled(AltBitState s) => s.DataChanHas; + public override AltBitState Apply(AltBitState s) + { + var n = (AltBitState)s.Clone(); + n.DataChanHas = false; + return n; + } + } + + /// + /// Receiver consumes the in-flight message. If its bit matches the + /// currently-expected the + /// payload is appended to , the + /// receiver flips its expected bit, and an ack carrying the bit + /// just accepted is posted (overwriting any pending ack). If the + /// bit does not match (a duplicate of the previously + /// accepted message), the receiver simply re-acks the previous + /// successful bit. In either case the data channel is emptied. + /// + public sealed class ReceiveStep : AltBitStep + { + public override bool IsEnabled(AltBitState s) => s.DataChanHas; + public override AltBitState Apply(AltBitState s) + { + var n = (AltBitState)s.Clone(); + if (s.DataChanBit == s.ReceiverBit) + { + var d = new int[s.Delivered.Length + 1]; + Array.Copy(s.Delivered, d, s.Delivered.Length); + d[s.Delivered.Length] = s.DataChanPayload; + n.Delivered = d; + n.AckChanHas = true; + n.AckChanBit = s.ReceiverBit; // ack with bit we just accepted + n.ReceiverBit = 1 - s.ReceiverBit; + } + else + { + n.AckChanHas = true; + n.AckChanBit = 1 - s.ReceiverBit; // re-ack the previously accepted bit + } + n.DataChanHas = false; + return n; + } + } + + /// + /// Bug-injection variant of : drops the + /// sequence-number check (DataChanBit == ReceiverBit) and + /// always appends the in-flight payload, flips the receiver bit + /// and acks. Duplicates make it into + /// (e.g. the first payload landing twice), so the + /// safety property fails. + /// + public sealed class BuggyReceiveStep : AltBitStep + { + public override bool IsEnabled(AltBitState s) => s.DataChanHas; + public override AltBitState Apply(AltBitState s) + { + var n = (AltBitState)s.Clone(); + var d = new int[s.Delivered.Length + 1]; + Array.Copy(s.Delivered, d, s.Delivered.Length); + d[s.Delivered.Length] = s.DataChanPayload; + n.Delivered = d; + n.AckChanHas = true; + n.AckChanBit = s.ReceiverBit; + n.ReceiverBit = 1 - s.ReceiverBit; + n.DataChanHas = false; + return n; + } + // Mirror the canonical id so fairness-keyed analyses treat + // this as the same enabling family. + public override string StepFunctionId => nameof(ReceiveStep); + } + + /// Lossy R→S channel drops the in-flight ack. + public sealed class LoseAckStep : AltBitStep + { + public override bool IsEnabled(AltBitState s) => s.AckChanHas; + public override AltBitState Apply(AltBitState s) + { + var n = (AltBitState)s.Clone(); + n.AckChanHas = false; + return n; + } + } + + /// + /// Sender consumes the in-flight ack. A matching ack flips the + /// sender bit and bumps ; + /// a stale ack is silently dropped. Either way the ack channel is + /// emptied. + /// + public sealed class ReceiveAckStep : AltBitStep + { + public override bool IsEnabled(AltBitState s) => s.AckChanHas; + public override AltBitState Apply(AltBitState s) + { + var n = (AltBitState)s.Clone(); + if (s.AckChanBit == s.SenderBit) + { + n.SenderBit = 1 - s.SenderBit; + n.NextPayload = s.NextPayload + 1; + } + n.AckChanHas = false; + return n; + } + } + + /// + /// Self-loop in the absorbing "all done" state. The state graph + /// otherwise has no outgoing edge once the sender has exhausted + /// and both channels are empty; the + /// stutter step makes that state participate in cycles so the + /// cycle-detection machinery treats reaching it as an infinite + /// trace satisfying any property that already holds there. It is + /// not covered by any fairness predicate. + /// + public sealed class StutterStep : AltBitStep + { + public override bool IsEnabled(AltBitState s) + => s.NextPayload == MaxMessages && !s.DataChanHas && !s.AckChanHas; + public override AltBitState Apply(AltBitState s) => (AltBitState)s.Clone(); + } + } +} diff --git a/Samples/AlternatingBit/AltBitAdditionalLtlTests.cs b/Samples/AlternatingBit/AltBitAdditionalLtlTests.cs new file mode 100644 index 0000000..36dc53e --- /dev/null +++ b/Samples/AlternatingBit/AltBitAdditionalLtlTests.cs @@ -0,0 +1,135 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// Additional AltBit properties beyond in-order delivery + eventual + /// delivery: delivery-count monotonicity, no-overshoot, per-payload + /// liveness, and an ack-well-formedness safety invariant. + /// + public class AltBitAdditionalLtlTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph(AltBit.AllSteps(), AltBit.InitialState(), lazy: true); + + // --- Atoms -------------------------------------------------------- + + private static LtlFormula DeliveredAtLeast(int k) => + LtlFormula.Prop(s => ((AltBitState)s).Delivered.Length >= k, $"Delivered>={k}"); + + private static LtlFormula DeliveredAtMost(int k) => + LtlFormula.Prop(s => ((AltBitState)s).Delivered.Length <= k, $"Delivered<={k}"); + + // --- Safety: monotone delivery count ----------------------------- + + /// + /// Once k payloads have been delivered, that count never + /// decreases. Encoded per k ∈ [1, MaxMessages] as + /// □((|D|≥k) → X(|D|≥k)). Catches any future regression + /// where a step accidentally truncates Delivered. + /// + [Test] + public void Safety_DeliveryCountMonotone() + { + for (int k = 1; k <= AltBit.MaxMessages; k++) + { + var phi = LtlFormula.Always(LtlFormula.Implies( + DeliveredAtLeast(k), LtlFormula.Next(DeliveredAtLeast(k)))); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, $"k={k}: {r.GetTraceString()}"); + } + } + + /// + /// |Delivered| ≤ MaxMessages at every reachable state. + /// Catches a sender that ignored its NextPayload < MaxMessages + /// guard, or a receiver that accepted out-of-bound payloads. + /// + [Test] + public void Safety_NoOvershoot() + { + var phi = LtlFormula.Always(DeliveredAtMost(AltBit.MaxMessages)); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + // --- Per-payload liveness ---------------------------------------- + + /// + /// Under strong fairness, each individual payload index is + /// eventually delivered. Stronger than the aggregate "AllDelivered + /// eventually" property: surfaces any per-message starvation that + /// happens to leave the aggregate count high while skipping a + /// specific payload. + /// + [Test] + public void Liveness_EachPayloadEventuallyDelivered_StrongFair() + { + var fair = Fairness.StrongFair(sf => sf is AltBit.AltBitStep && !(sf is AltBit.StutterStep)); + for (int k = 1; k <= AltBit.MaxMessages; k++) + { + var phi = LtlFormula.Eventually(DeliveredAtLeast(k)); + var r = LtlCheck.Check(_root, phi, fairness: fair); + Assert.IsTrue(r.Valid, $"k={k}: {r.GetTraceString()}"); + } + } + + // --- Ack well-formedness ----------------------------------------- + + /// + /// Every ack in flight carries either the bit the receiver just + /// accepted, or the previous bit (the re-ack case). It is never + /// some unrelated value. Encoded as a state predicate: + /// □(AckChanHas → AckChanBit ∈ {0, 1}). Trivially true at + /// the type level but a useful structural invariant to pin. + /// + [Test] + public void Safety_AckWellFormed() + { + var phi = LtlFormula.Always(LtlFormula.Prop(s => + { + var st = (AltBitState)s; + return !st.AckChanHas || st.AckChanBit == 0 || st.AckChanBit == 1; + }, "AckWellFormed")); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + /// + /// Bit-synchronization round-trip invariant: whenever the sender + /// and receiver bits agree (a quiescent moment in the protocol), + /// no data is in flight. Equivalently: a discrepancy between + /// SenderBit and ReceiverBit implies an ack is in + /// flight (the sender is still waiting to learn about the flip). + /// Captures the essential AltBit handshake invariant. + /// + [Test] + public void Safety_BitSyncRoundTripInvariant() + { + var phi = LtlFormula.Always(LtlFormula.Prop(s => + { + var st = (AltBitState)s; + // If bits are equal: no in-flight data with bit == both + // (that would have just been sent and not yet received). + // Encoded conservatively: SenderBit == ReceiverBit implies + // the receiver has consumed every message the sender has + // committed to so far, so |Delivered| == NextPayload. + if (st.SenderBit == st.ReceiverBit) + return st.Delivered.Length == st.NextPayload; + // Otherwise NextPayload is one ahead of |Delivered| would + // be after the pending ack is processed: |Delivered| equals + // NextPayload + 1, because the receiver already accepted + // the latest in-flight message and is now ack-ing it. + return st.Delivered.Length == st.NextPayload + 1; + }, "BitSyncRoundTrip")); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + } +} diff --git a/Samples/AlternatingBit/AltBitBugDemoTests.cs b/Samples/AlternatingBit/AltBitBugDemoTests.cs new file mode 100644 index 0000000..c55211f --- /dev/null +++ b/Samples/AlternatingBit/AltBitBugDemoTests.cs @@ -0,0 +1,64 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// Bug-injection demonstration for the Alternating-Bit protocol: + /// replaces with + /// (drops the bit-match guard, + /// so duplicates are re-delivered) and asserts that the LTL/RLTL + /// in-order safety property reports a counterexample. + /// + public class AltBitBugDemoTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph( + AltBit.AllStepsBuggy(), + AltBit.InitialState(), + stateConstraint: s => + { + // The buggy receiver re-delivers indefinitely under + // ack loss, so bound the explored prefix. + var st = (AltBitState)s; + return st.Delivered.Length <= AltBit.MaxMessages + 1; + }, lazy: true); + + /// + /// LTL □ InOrder: holds in the correct protocol; under + /// the buggy receiver the first payload can be delivered twice, + /// breaking the in-order prefix invariant. + /// + [Test] + public void Bug_LtlInOrder_Fails_WithCounterexample() + { + var inOrder = LtlFormula.Prop(AltBit.InOrder, "InOrder"); + var phi = LtlFormula.Always(inOrder); + var r = LtlCheck.Check(_root, phi); + Assert.IsFalse(r.Valid, "Buggy receiver should violate in-order delivery."); + Assert.That(r.GetTraceString(), Is.Not.Null.And.Not.Empty); + } + + /// + /// RLTL forbidden-prefix form: Σ* · ¬InOrder must be + /// reachable in the buggy state graph. + /// + [Test] + public void Bug_RltlForbiddenOutOfOrder_Matches() + { + var sigmaStar = Regex.Star(Regex.Sigma); + var notInOrder = Regex.Prop(s => !AltBit.InOrder(s), "¬InOrder"); + var bad = Regex.Concat(sigmaStar, notInOrder); + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + var r = RltlCheck.Check(_root, phi); + Assert.IsFalse(r.Valid, "Bad prefix is reachable in the buggy model."); + Assert.That(r.GetTraceString(), Is.Not.Null.And.Not.Empty); + } + } +} diff --git a/Samples/AlternatingBit/AltBitCrossCheckTests.cs b/Samples/AlternatingBit/AltBitCrossCheckTests.cs new file mode 100644 index 0000000..e78a447 --- /dev/null +++ b/Samples/AlternatingBit/AltBitCrossCheckTests.cs @@ -0,0 +1,55 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Testing; + using NUnit.Framework; + + /// + /// Cross-check harness for the Alternating-Bit Protocol — every + /// LTL-expressible property from is + /// lifted to RLTL via and re-checked. + /// + public class AltBitCrossCheckTests + { + private StateGraphNode _root; + + private static readonly Fairness ChannelFairness = Fairness.StrongFair(sf => + sf is AltBit.SendStep || sf is AltBit.ReceiveStep || sf is AltBit.ReceiveAckStep); + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph(AltBit.AllSteps(), AltBit.InitialState(), lazy: true); + + private static LtlFormula InOrder => LtlFormula.Prop(AltBit.InOrder, "InOrder"); + private static LtlFormula AllDelivered => LtlFormula.Prop(AltBit.AllDelivered, "AllDelivered"); + + [Test] + public void Safety_InOrderDelivery_CrossCheck() => + LtlRltlCrossCheck.Run( + _root, + LtlFormula.Always(InOrder), + fairness: Fairness.None, + label: nameof(Safety_InOrderDelivery_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Liveness_EventualDelivery_UnderStrongFairness_CrossCheck() => + LtlRltlCrossCheck.Run( + _root, + LtlFormula.Eventually(AllDelivered), + fairness: ChannelFairness, + label: nameof(Liveness_EventualDelivery_UnderStrongFairness_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Liveness_EventualDelivery_FailsWithoutFairness_CrossCheck() => + LtlRltlCrossCheck.Run( + _root, + LtlFormula.Eventually(AllDelivered), + fairness: Fairness.None, + label: nameof(Liveness_EventualDelivery_FailsWithoutFairness_CrossCheck) + ).ThrowIfDisagree(); + } +} diff --git a/Samples/AlternatingBit/AltBitFusionShowcaseTests.cs b/Samples/AlternatingBit/AltBitFusionShowcaseTests.cs new file mode 100644 index 0000000..dfbf64a --- /dev/null +++ b/Samples/AlternatingBit/AltBitFusionShowcaseTests.cs @@ -0,0 +1,176 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// Fusion showcase for the Alternating-Bit Protocol — exercises + /// on real model states, both as a + /// generic Example-7.1 sanity check and as a handshake-specific + /// property where fusion's "shared boundary letter" semantics + /// yields a strictly different language from naive concatenation. + /// + /// + /// History: this scenario was originally deferred as + /// alt-bit-fusion-handshake because fusion's natural + /// reading talks about a shared transition, and our regex + /// alphabet is over states. The way through is to find a + /// state predicate that fingerprints the moment immediately before + /// the transition of interest (here: the "matching ack pending" + /// state, which is the unique state that must hold one step before + /// a successful flips the + /// sender bit). With that predicate in hand, fusion glues the + /// pre-transition prefix and the post-transition suffix at the + /// shared handshake state. + /// + /// + public class AltBitFusionShowcaseTests + { + private StateGraphNode _root; + + private static readonly Fairness ChannelFairness = Fairness.StrongFair(sf => + sf is AltBit.SendStep || sf is AltBit.ReceiveStep || sf is AltBit.ReceiveAckStep); + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph(AltBit.AllSteps(), AltBit.InitialState(), lazy: true); + + // --- Predicates --------------------------------------------------- + + /// + /// The "handshake-pending" state for sender bit 0: the sender + /// currently holds bit 0 and a matching ack-0 is sitting in the + /// R→S channel. Firing from + /// here transitions to sender bit 1. + /// + private static bool HandshakePending0(IState s) + { + var st = (AltBitState)s; + return st.SenderBit == 0 && st.AckChanHas && st.AckChanBit == 0; + } + + private static bool SenderBit1Pred(IState s) => AltBit.SenderBit1(s); + + private static Regex HSPending0 => Regex.Prop(HandshakePending0, "HandshakePending0"); + private static Regex RSenderBit1 => Regex.Prop(SenderBit1Pred, "SenderBit1"); + private static Regex SigmaStar => Regex.Star(Regex.Sigma); + + // --- Sanity check: Example 7.1 on AltBit predicates -------------- + + /// + /// Verifies the JACM Example 7.1 identity + /// α* : β* ≡ α* · (α∧β) · β* using AltBit-specific atoms: + /// α = , β = + /// . The intersection + /// α∧β is unsatisfiable on AltBit states (the sender bit + /// is exclusively 0 or 1), so both sides should accept the + /// empty language on this model — proven by + /// double-emptiness via RltlCheck's emptiness test. + /// + [Test] + public void Fusion_Example7_1_Identity_Holds_For_AltBit_SenderBit_Atoms() + { + var alpha = Regex.Prop(AltBit.SenderBit0, "SenderBit0"); + var beta = Regex.Prop(AltBit.SenderBit1, "SenderBit1"); + + var R = Regex.Fusion(Regex.Star(alpha), Regex.Star(beta)); + var S = Regex.Concat(Regex.Star(alpha), + Regex.Concat(Regex.Intersect(alpha, beta), Regex.Star(beta))); + + // R \ S empty (i.e., R ∩ ¬S empty) and S \ R empty. + AssertRegexLanguageEmpty(Regex.Intersect(R, Regex.Complement(S)), "R \\ S"); + AssertRegexLanguageEmpty(Regex.Intersect(S, Regex.Complement(R)), "S \\ R"); + } + + // --- Handshake fusion: the originally-deferred scenario ---------- + + /// + /// Handshake property phrased with fusion: + /// (Σ* · HandshakePending0) : (HandshakePending0 · SenderBit1) + /// — a run prefix ending at a handshake-pending-0 state fused + /// with a two-letter continuation that starts at the same + /// handshake-pending-0 state and immediately transitions to a + /// SenderBit=1 state. The boundary letter is shared (it must + /// satisfy ). + /// + /// Under some run must complete + /// the handshake (sender bit must flip to 1), so the + /// emptiness check on this fusion regex must fail — i.e., the + /// language is non-empty on the reachable AltBit state + /// graph. + /// + [Test] + public void Fusion_Handshake_PrecedesSenderBitFlip_IsReachable() + { + var R = Regex.Concat(SigmaStar, HSPending0); + var S = Regex.Concat(HSPending0, RSenderBit1); + var fused = Regex.Fusion(R, S); + + // "There is a run with no prefix in L(fused)" — this should be + // FALSE under channel fairness (handshake must complete). + var noPrefix = !RltlFormula.SeqPrefix(fused, RltlFormula.True); + + var result = RltlCheck.Check(_root, noPrefix, fairness: ChannelFairness); + Assert.That(result.Valid, Is.False, + "Under channel fairness, every run must exhibit a handshake-pending-0 state " + + "immediately followed by a SenderBit=1 state."); + } + + /// + /// Companion check: the naive concatenation + /// (Σ* · HandshakePending0) · (HandshakePending0 · SenderBit1) + /// — same shape but without fusing the boundary — + /// requires two consecutive handshake-pending-0 states + /// followed by SenderBit=1. In AltBit this can happen + /// (a firing while the ack is in + /// flight leaves the ack pending and the sender bit unchanged, + /// so HandshakePending0 persists), but it is strictly more + /// restrictive than the fused version: the test merely confirms + /// the two regexes are not language-equivalent on this + /// model by exhibiting a run prefix that matches the fused form + /// but not the concat form. + /// + [Test] + public void Fusion_Differs_From_Concat_On_Single_Letter_Handshake() + { + var R = Regex.Concat(SigmaStar, HSPending0); + var S = Regex.Concat(HSPending0, RSenderBit1); + + var fused = Regex.Fusion(R, S); + var concat = Regex.Concat(R, S); + + // The fused language minus the concat language should be + // non-empty: any prefix where HandshakePending0 is immediately + // followed by SenderBit1 (with no second HandshakePending0 in + // between) matches the fusion but not the concat. + AssertRegexLanguageNonEmpty( + Regex.Intersect(fused, Regex.Complement(concat)), + "fused \\ concat"); + } + + // --- Helpers ------------------------------------------------------ + + /// + /// Asserts that accepts no prefix of any + /// reachable AltBit run, via the RLTL emptiness encoding: + /// L(r) ∩ reachable-prefixes = ∅ iff ¬(r ; True) holds. + /// + private void AssertRegexLanguageEmpty(Regex r, string label) + { + var phi = !RltlFormula.SeqPrefix(r, RltlFormula.True); + var result = RltlCheck.Check(_root, phi); + Assert.That(result.Valid, Is.True, + $"Expected {label} to be empty on the AltBit state graph. {result.GetTraceString()}"); + } + + private void AssertRegexLanguageNonEmpty(Regex r, string label) + { + var phi = !RltlFormula.SeqPrefix(r, RltlFormula.True); + var result = RltlCheck.Check(_root, phi); + Assert.That(result.Valid, Is.False, + $"Expected {label} to be non-empty on the AltBit state graph."); + } + } +} diff --git a/Samples/AlternatingBit/AltBitLtlTests.cs b/Samples/AlternatingBit/AltBitLtlTests.cs new file mode 100644 index 0000000..15e330f --- /dev/null +++ b/Samples/AlternatingBit/AltBitLtlTests.cs @@ -0,0 +1,93 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// LTL-only verification of the Alternating-Bit Protocol. The + /// safety and liveness checks here are mirrored in + /// via the RLTL DSL — these tests + /// establish the baseline using LtlCheck. + /// + public class AltBitLtlTests + { + private StateGraphNode _root; + + /// + /// Strong fairness on the three "useful" steps — + /// , , + /// . No fairness on the + /// Lose* steps (an infinitely-lossy channel is permitted) + /// and none on the absorbing + /// self-loop. + /// + private static readonly Fairness ChannelFairness = Fairness.StrongFair(sf => + sf is AltBit.SendStep || sf is AltBit.ReceiveStep || sf is AltBit.ReceiveAckStep); + + [SetUp] + public void Setup() + { + _root = StateGraph.ExploreStateGraph(AltBit.AllSteps(), AltBit.InitialState(), lazy: true); + } + + // --- LTL atoms ---------------------------------------------------- + + private static LtlFormula InOrder => LtlFormula.Prop(AltBit.InOrder, "InOrder"); + private static LtlFormula AllDelivered => LtlFormula.Prop(AltBit.AllDelivered, "AllDelivered"); + + // --- Safety ------------------------------------------------------- + + /// + /// In-order, no-duplicates delivery: □ InOrder. The receiver + /// only ever exposes a prefix of the canonical payload sequence + /// [0, 1, …, MaxMessages-1]. Holds regardless of channel + /// behaviour — it is the core safety guarantee of ABP. + /// + [Test] + public void Safety_InOrderDelivery() + { + var phi = LtlFormula.Always(InOrder); + var result = LtlCheck.Check(_root, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + // --- Liveness under strong fairness on the useful steps ---------- + + /// + /// Progress under strong fairness on send/receive/receive-ack: + /// ◇ AllDelivered. Weak fairness is not enough + /// here — the useful steps are repeatedly disabled (Send is + /// disabled while a message sits in the data channel, etc.) so + /// they would not be forced. Strong fairness keys on + /// "enabled infinitely often", which is the right condition. + /// + [Test] + public void Liveness_EventualDelivery_UnderStrongFairness() + { + var phi = LtlFormula.Eventually(AllDelivered); + var result = LtlCheck.Check(_root, phi, fairness: ChannelFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Without fairness the same property fails: the channel may lose + /// every message forever, so AllDelivered need never hold. + /// This is also a regression test for the + /// LtlCheck.FindBadFairSubCycle fix — under + /// any cycle that never reaches + /// AllDelivered is a valid counterexample. + /// + [Test] + public void Liveness_EventualDelivery_FailsWithoutFairness() + { + var phi = LtlFormula.Eventually(AllDelivered); + var result = LtlCheck.Check(_root, phi, fairness: Fairness.None); + Assert.IsFalse(result.Valid, + "Without fairness an infinite-loss cycle exists where the data " + + "channel is repeatedly filled and emptied without a Receive — " + + "AllDelivered is never reached."); + } + } +} diff --git a/Samples/AlternatingBit/AltBitOracleSweepTests.cs b/Samples/AlternatingBit/AltBitOracleSweepTests.cs new file mode 100644 index 0000000..836b5df --- /dev/null +++ b/Samples/AlternatingBit/AltBitOracleSweepTests.cs @@ -0,0 +1,75 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Testing; + using NUnit.Framework; + + /// + /// Drives every LTL-expressible AltBit property through the + /// four-backend differential oracle + /// () under multiple + /// fairness configurations. Disagreement between backends is a + /// bug in at least one of them. + /// + public class AltBitOracleSweepTests + { + private StateGraphNode _root; + + private static readonly Fairness ChannelFairness = Fairness.StrongFair(sf => + sf is AltBit.SendStep || sf is AltBit.ReceiveStep || sf is AltBit.ReceiveAckStep); + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph(AltBit.AllSteps(), AltBit.InitialState(), lazy: true); + + private static LtlFormula InOrder => LtlFormula.Prop(AltBit.InOrder, "InOrder"); + private static LtlFormula AllDelivered => LtlFormula.Prop(AltBit.AllDelivered, "AllDelivered"); + + [Test] + public void Oracle_Safety_InOrderDelivery_NoFairness() + { + var phi = LtlFormula.Always(InOrder); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.None, nameof(Oracle_Safety_InOrderDelivery_NoFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True, "InOrder safety should hold across all backends."); + } + + [Test] + public void Oracle_Safety_InOrderDelivery_WeakFairAll() + { + var phi = LtlFormula.Always(InOrder); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.WeakFairAll, nameof(Oracle_Safety_InOrderDelivery_WeakFairAll)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_Safety_InOrderDelivery_ChannelFairness() + { + var phi = LtlFormula.Always(InOrder); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, ChannelFairness, nameof(Oracle_Safety_InOrderDelivery_ChannelFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_Liveness_EventualDelivery_FailsWithoutFairness() + { + var phi = LtlFormula.Eventually(AllDelivered); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.None, nameof(Oracle_Liveness_EventualDelivery_FailsWithoutFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.False, "Should be falsified across all backends without fairness."); + } + + [Test] + public void Oracle_Liveness_EventualDelivery_HoldsUnderChannelFairness() + { + var phi = LtlFormula.Eventually(AllDelivered); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, ChannelFairness, nameof(Oracle_Liveness_EventualDelivery_HoldsUnderChannelFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + } +} diff --git a/Samples/AlternatingBit/AltBitRltlTests.cs b/Samples/AlternatingBit/AltBitRltlTests.cs new file mode 100644 index 0000000..901adae --- /dev/null +++ b/Samples/AlternatingBit/AltBitRltlTests.cs @@ -0,0 +1,105 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// End-to-end RLTL model-checking of the Alternating-Bit Protocol. + /// Parallels for the LTL-expressible + /// properties and adds a genuinely regex-shaped property over the + /// sender-bit phase sequence. + /// + public class AltBitRltlTests + { + private StateGraphNode _root; + + private static readonly Fairness ChannelFairness = Fairness.StrongFair(sf => + sf is AltBit.SendStep || sf is AltBit.ReceiveStep || sf is AltBit.ReceiveAckStep); + + [SetUp] + public void Setup() + { + _root = StateGraph.ExploreStateGraph(AltBit.AllSteps(), AltBit.InitialState(), lazy: true); + } + + // --- RLTL atoms --------------------------------------------------- + + private static RltlFormula InOrder => RltlFormula.Prop(AltBit.InOrder, "InOrder"); + private static RltlFormula AllDelivered => RltlFormula.Prop(AltBit.AllDelivered, "AllDelivered"); + + // --- Safety (parity with LTL) ------------------------------------- + + /// In-order delivery: □ InOrder, expressed in RLTL. + [Test] + public void Safety_InOrderDelivery() + { + var phi = RltlFormula.Always(InOrder); + var result = RltlCheck.Check(_root, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + // --- Liveness (parity with LTL) ----------------------------------- + + /// Eventual delivery under strong fairness on the useful steps. + [Test] + public void Liveness_EventualDelivery_UnderStrongFairness() + { + var phi = RltlFormula.Eventually(AllDelivered); + var result = RltlCheck.Check(_root, phi, fairness: ChannelFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// Same property fails under . + [Test] + public void Liveness_EventualDelivery_FailsWithoutFairness() + { + var phi = RltlFormula.Eventually(AllDelivered); + var result = RltlCheck.Check(_root, phi, fairness: Fairness.None); + Assert.IsFalse(result.Valid, + "Without fairness an infinite-loss cycle keeps the protocol " + + "from making any progress."); + } + + // --- Genuinely regex-shaped property ------------------------------ + + /// + /// Sender-bit alternation. With + /// = 3 the sender goes through exactly four bit-phases — + /// 0, 1, 0, 1 — and never returns to bit 0 a third time. + /// The forbidden trace shape is therefore + /// + /// Σ* · SB=0 · Σ* · SB=1 · Σ* · SB=0 · Σ* · SB=1 · Σ* · SB=0 + /// + /// — five alternating sightings of the sender bit, which would + /// require a third bit-0 phase. This property is expressible in + /// LTL only by deeply nested Until/Next chains; in RLTL the ERE + /// captures the phase pattern directly. Forbidden via the + /// universal-regex prefix operator R ⊳ False. + /// + [Test] + public void Regex_SenderBit_HasAtMostFourPhases() + { + var sigmaStar = Regex.Star(Regex.Sigma); + var sb0 = Regex.Prop(AltBit.SenderBit0, "SB=0"); + var sb1 = Regex.Prop(AltBit.SenderBit1, "SB=1"); + + // Σ* · SB=0 · Σ* · SB=1 · Σ* · SB=0 · Σ* · SB=1 · Σ* · SB=0 + var bad = Regex.Concat(sigmaStar, + Regex.Concat(sb0, + Regex.Concat(sigmaStar, + Regex.Concat(sb1, + Regex.Concat(sigmaStar, + Regex.Concat(sb0, + Regex.Concat(sigmaStar, + Regex.Concat(sb1, + Regex.Concat(sigmaStar, sb0))))))))); // 5th SB=0 sighting + + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + + var result = RltlCheck.Check(_root, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + } +} diff --git a/Samples/AlternatingBit/AltBitState.cs b/Samples/AlternatingBit/AltBitState.cs new file mode 100644 index 0000000..8a65705 --- /dev/null +++ b/Samples/AlternatingBit/AltBitState.cs @@ -0,0 +1,40 @@ +namespace AlternatingBit +{ + using Microsoft.Accordant; + + /// + /// Global state for the Alternating-Bit Protocol with capacity-1 lossy + /// channels in both directions. + /// + [State] + public partial class AltBitState + { + /// The bit the sender is currently trying to deliver. + public int SenderBit { get; set; } + /// The bit the receiver is currently expecting. + public int ReceiverBit { get; set; } + + /// True iff the S→R channel currently holds a message. + public bool DataChanHas { get; set; } + /// Bit tag of the in-flight message (only meaningful when ). + public int DataChanBit { get; set; } + /// Payload of the in-flight message (only meaningful when ). + public int DataChanPayload { get; set; } + + /// True iff the R→S channel currently holds an ack. + public bool AckChanHas { get; set; } + /// Bit value of the in-flight ack (only meaningful when ). + public int AckChanBit { get; set; } + + /// + /// Sequence of payload identifiers handed up to the application so + /// far, in delivery order. Always a prefix of [0, 1, …, + /// AltBit.MaxMessages-1] in a correct protocol — exactly what + /// the safety property checks. + /// + public int[] Delivered { get; set; } + + /// Next fresh payload id the sender will use (0-indexed). + public int NextPayload { get; set; } + } +} diff --git a/Samples/AlternatingBit/AlternatingBit.csproj b/Samples/AlternatingBit/AlternatingBit.csproj new file mode 100644 index 0000000..e04f378 --- /dev/null +++ b/Samples/AlternatingBit/AlternatingBit.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + latest + Library + AlternatingBit + + + + + + + + + + + + + + + + diff --git a/Samples/DiningPhilosophers/Dining.cs b/Samples/DiningPhilosophers/Dining.cs new file mode 100644 index 0000000..77c5900 --- /dev/null +++ b/Samples/DiningPhilosophers/Dining.cs @@ -0,0 +1,254 @@ +namespace DiningPhilosophers +{ + using System.Collections.Generic; + using Microsoft.Accordant; + + /// + /// Three-philosopher dining table. Each philosopher repeats the cycle + /// + /// Thinking → Hungry → HoldOne → Eating → Thinking + /// + /// and shares two forks with neighbours. Two pickup orders are + /// supported: + /// + /// + /// Naive — every philosopher picks up the left fork first. + /// Admits the textbook deadlock: all three pick up their left fork + /// simultaneously, then each waits for its right fork forever. + /// + /// + /// Asymmetric — philosopher 2 picks up the right fork first + /// (Chandy/Misra-style ordering). Breaks the circular wait so the + /// system is deadlock-free. + /// + /// + /// A single is included to give the + /// classical "all-stuck" state an outgoing self-loop, so the + /// cycle-detection machinery can flag the absence of progress. + /// + public static class Dining + { + public const int N = 3; + + // --- Atomic predicates -------------------------------------------- + + public static bool Eating0(IState s) => ((DiningState)s).PC0 == PhilPC.Eating; + public static bool Eating1(IState s) => ((DiningState)s).PC1 == PhilPC.Eating; + public static bool Eating2(IState s) => ((DiningState)s).PC2 == PhilPC.Eating; + public static bool Hungry0(IState s) => ((DiningState)s).PC0 == PhilPC.Hungry; + public static bool Hungry1(IState s) => ((DiningState)s).PC1 == PhilPC.Hungry; + public static bool Hungry2(IState s) => ((DiningState)s).PC2 == PhilPC.Hungry; + + /// At least one philosopher is eating. + public static bool SomeEating(IState s) + => Eating0(s) || Eating1(s) || Eating2(s); + + /// Two or more philosophers are eating simultaneously — should never happen. + public static bool TwoEating(IState s) + { + var st = (DiningState)s; + int n = 0; + if (st.PC0 == PhilPC.Eating) n++; + if (st.PC1 == PhilPC.Eating) n++; + if (st.PC2 == PhilPC.Eating) n++; + return n >= 2; + } + + // --- State-graph construction ------------------------------------ + + /// Initial state: everyone thinking, every fork free. + public static DiningState InitialState() => new DiningState + { + PC0 = PhilPC.Thinking, PC1 = PhilPC.Thinking, PC2 = PhilPC.Thinking, + F0 = -1, F1 = -1, F2 = -1, + }; + + /// + /// Build the step list for either the + /// or naive pickup order. Each philosopher contributes the same + /// four steps; only the firstFork/secondFork + /// arguments differ between variants. + /// + public static IList AllSteps(bool asymmetric) + { + var steps = new List(); + for (int i = 0; i < N; i++) + { + int leftFork = i; + int rightFork = (i + 1) % N; + int first, second; + if (asymmetric && i == N - 1) + { + // Reverse pickup order for the last philosopher. + first = rightFork; second = leftFork; + } + else + { + first = leftFork; second = rightFork; + } + steps.Add(new BecomeHungryStep(i)); + steps.Add(new PickupFirstStep(i, first)); + steps.Add(new PickupSecondStep(i, second)); + steps.Add(new ReleaseStep(i, first, second)); + } + steps.Add(new DeadlockStutterStep()); + return steps; + } + + // --- Step functions ---------------------------------------------- + + /// Common per-philosopher step scaffolding. + public abstract class PhilStep : BaseStepFunction + { + public int I { get; } + protected PhilStep(int i) { I = i; } + + public override string StepFunctionId => GetType().Name + "_" + I; + public abstract bool IsEnabled(DiningState s); + public abstract DiningState Apply(DiningState s); + + protected override IList ApplyInternal(IState state) + { + var s = (DiningState)state; + if (!IsEnabled(s)) return null; + return new[] + { + new StepResult + { + State = Apply(s), + StepFunctions = new IStepFunction[] { this }, + } + }; + } + + // Helpers for reading/writing a philosopher's PC and a fork by index. + protected static PhilPC GetPC(DiningState s, int i) + => i == 0 ? s.PC0 : i == 1 ? s.PC1 : s.PC2; + protected static void SetPC(DiningState s, int i, PhilPC pc) + { + if (i == 0) s.PC0 = pc; + else if (i == 1) s.PC1 = pc; + else s.PC2 = pc; + } + protected static int GetFork(DiningState s, int f) + => f == 0 ? s.F0 : f == 1 ? s.F1 : s.F2; + protected static void SetFork(DiningState s, int f, int holder) + { + if (f == 0) s.F0 = holder; + else if (f == 1) s.F1 = holder; + else s.F2 = holder; + } + } + + /// Thinking → Hungry. + public sealed class BecomeHungryStep : PhilStep + { + public BecomeHungryStep(int i) : base(i) { } + public override bool IsEnabled(DiningState s) => GetPC(s, I) == PhilPC.Thinking; + public override DiningState Apply(DiningState s) + { + var n = (DiningState)s.Clone(); + SetPC(n, I, PhilPC.Hungry); + return n; + } + } + + /// + /// Hungry → HoldOne. Picks up this philosopher's "first" fork, + /// requiring it to be free. + /// + public sealed class PickupFirstStep : PhilStep + { + public int FirstFork { get; } + public PickupFirstStep(int i, int firstFork) : base(i) { FirstFork = firstFork; } + public override bool IsEnabled(DiningState s) + => GetPC(s, I) == PhilPC.Hungry && GetFork(s, FirstFork) == -1; + public override DiningState Apply(DiningState s) + { + var n = (DiningState)s.Clone(); + SetPC(n, I, PhilPC.HoldOne); + SetFork(n, FirstFork, I); + return n; + } + } + + /// HoldOne → Eating. Picks up the second fork. + public sealed class PickupSecondStep : PhilStep + { + public int SecondFork { get; } + public PickupSecondStep(int i, int secondFork) : base(i) { SecondFork = secondFork; } + public override bool IsEnabled(DiningState s) + => GetPC(s, I) == PhilPC.HoldOne && GetFork(s, SecondFork) == -1; + public override DiningState Apply(DiningState s) + { + var n = (DiningState)s.Clone(); + SetPC(n, I, PhilPC.Eating); + SetFork(n, SecondFork, I); + return n; + } + } + + /// Eating → Thinking. Releases both forks. + public sealed class ReleaseStep : PhilStep + { + public int FirstFork { get; } + public int SecondFork { get; } + public ReleaseStep(int i, int firstFork, int secondFork) : base(i) + { FirstFork = firstFork; SecondFork = secondFork; } + public override bool IsEnabled(DiningState s) => GetPC(s, I) == PhilPC.Eating; + public override DiningState Apply(DiningState s) + { + var n = (DiningState)s.Clone(); + SetPC(n, I, PhilPC.Thinking); + SetFork(n, FirstFork, -1); + SetFork(n, SecondFork, -1); + return n; + } + } + + /// + /// Self-loop that fires only in states where no per- + /// philosopher step is enabled — i.e., the classical deadlock + /// in the naive ordering. Makes such terminal states participate + /// in cycles so cycle-based liveness checks can flag the absence + /// of progress. Not covered by any fairness predicate. + /// + public sealed class DeadlockStutterStep : BaseStepFunction + { + public override string StepFunctionId => nameof(DeadlockStutterStep); + protected override IList ApplyInternal(IState state) + { + var s = (DiningState)state; + if (HasAnyPhilStepEnabled(s)) return null; + return new[] + { + new StepResult + { + State = (DiningState)s.Clone(), + StepFunctions = new IStepFunction[] { this }, + } + }; + } + + private static bool HasAnyPhilStepEnabled(DiningState s) + { + // Any Thinking philosopher can become hungry. + if (s.PC0 == PhilPC.Thinking || s.PC1 == PhilPC.Thinking || s.PC2 == PhilPC.Thinking) + return true; + // Any Eating philosopher can release. + if (s.PC0 == PhilPC.Eating || s.PC1 == PhilPC.Eating || s.PC2 == PhilPC.Eating) + return true; + // Any Hungry philosopher with a free pickup-first fork can advance. + // Any HoldOne philosopher with a free pickup-second fork can advance. + // Both are captured by "some fork is free AND some philosopher in Hungry/HoldOne". + bool anyFree = s.F0 == -1 || s.F1 == -1 || s.F2 == -1; + if (!anyFree) return false; + // If any fork is free, conservatively report enabled — the explorer + // will re-check the actual step guards before generating an edge. + return s.PC0 == PhilPC.Hungry || s.PC0 == PhilPC.HoldOne + || s.PC1 == PhilPC.Hungry || s.PC1 == PhilPC.HoldOne + || s.PC2 == PhilPC.Hungry || s.PC2 == PhilPC.HoldOne; + } + } + } +} diff --git a/Samples/DiningPhilosophers/DiningAdditionalLtlTests.cs b/Samples/DiningPhilosophers/DiningAdditionalLtlTests.cs new file mode 100644 index 0000000..737eb1c --- /dev/null +++ b/Samples/DiningPhilosophers/DiningAdditionalLtlTests.cs @@ -0,0 +1,138 @@ +namespace DiningPhilosophers +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// Additional DP properties beyond mutex + per-philosopher liveness: + /// fork conservation, deadlock-as-LTL (only on the naive variant), + /// hunger-progress, and a no-self-deadlock check on the asymmetric + /// variant under weak fairness. + /// + public class DiningAdditionalLtlTests + { + private StateGraphNode _naiveRoot; + private StateGraphNode _asymRoot; + + private static readonly Fairness PhilFairness = + Fairness.StrongFair(sf => sf is Dining.PhilStep); + + [SetUp] + public void Setup() + { + _naiveRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: false), Dining.InitialState(), lazy: true); + _asymRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: true), Dining.InitialState(), lazy: true); + } + + // --- Atoms -------------------------------------------------------- + + private static LtlFormula SomeEating => LtlFormula.Prop(Dining.SomeEating, "SomeEating"); + private static LtlFormula Hungry0 => LtlFormula.Prop(Dining.Hungry0, "Hungry0"); + private static LtlFormula Hungry1 => LtlFormula.Prop(Dining.Hungry1, "Hungry1"); + private static LtlFormula Hungry2 => LtlFormula.Prop(Dining.Hungry2, "Hungry2"); + private static LtlFormula Eating0 => LtlFormula.Prop(Dining.Eating0, "Eating0"); + private static LtlFormula Eating1 => LtlFormula.Prop(Dining.Eating1, "Eating1"); + private static LtlFormula Eating2 => LtlFormula.Prop(Dining.Eating2, "Eating2"); + + // --- Fork conservation ------------------------------------------- + + /// + /// Every fork is owned by at most one philosopher, and the + /// holder's PC must indicate it is actually holding a fork + /// (HoldOne or Eating). Encodes the implicit data-structure + /// invariant that the model is supposed to preserve. + /// + [Test] + public void Safety_ForkConservation_Asymmetric() + { + var phi = LtlFormula.Always(LtlFormula.Prop(s => + { + var d = (DiningState)s; + return ForkValid(d.F0) && ForkValid(d.F1) && ForkValid(d.F2) + && ForkConsistent(d, d.F0) && ForkConsistent(d, d.F1) && ForkConsistent(d, d.F2); + }, "ForkConservation")); + + var result = LtlCheck.Check(_asymRoot, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// Same invariant on the naive variant. + [Test] + public void Safety_ForkConservation_Naive() + { + var phi = LtlFormula.Always(LtlFormula.Prop(s => + { + var d = (DiningState)s; + return ForkValid(d.F0) && ForkValid(d.F1) && ForkValid(d.F2) + && ForkConsistent(d, d.F0) && ForkConsistent(d, d.F1) && ForkConsistent(d, d.F2); + }, "ForkConservation")); + + var result = LtlCheck.Check(_naiveRoot, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + private static bool ForkValid(int owner) => owner >= -1 && owner <= 2; + + private static bool ForkConsistent(DiningState d, int owner) + { + if (owner == -1) return true; + var pc = owner == 0 ? d.PC0 : owner == 1 ? d.PC1 : d.PC2; + return pc == PhilPC.HoldOne || pc == PhilPC.Eating; + } + + // --- Deadlock-as-LTL --------------------------------------------- + + /// + /// On the asymmetric variant, somebody eats infinitely often. + /// Pure LTL formulation of the no-deadlock property (which has + /// previously only been observable via SCC probing). + /// + [Test] + public void Liveness_SomebodyAlwaysEats_Asymmetric() + { + var phi = LtlFormula.InfinitelyOften(SomeEating); + var result = LtlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// The naive variant deadlocks (all three philosophers stuck holding + /// one fork each). Under strong fairness only on real philosopher + /// steps, the deadlock cycle (stutter-step self-loop) is unfair + /// to every philosopher's continuously-enabled second-fork + /// pickup attempts... but the second-fork step is NOT continuously + /// enabled in the deadlock state because the required fork is held + /// by another philosopher. So □◇SomeEating must FAIL — + /// the deadlock cycle satisfies vacuous strong fairness. + /// + [Test] + public void Liveness_SomebodyAlwaysEats_Naive_FAILS_WithDeadlock() + { + var phi = LtlFormula.InfinitelyOften(SomeEating); + var result = LtlCheck.Check(_naiveRoot, phi, fairness: PhilFairness); + Assert.IsFalse(result.Valid, + "The naive variant reaches a deadlock state with a stutter " + + "self-loop; no philosopher eats from that point onward."); + } + + // --- Hunger progress (one-shot leads-to) ------------------------- + + /// + /// On the asymmetric variant under strong fairness, every + /// hungry philosopher eventually eats. Stronger than mere + /// "somebody eats" — addresses individual progress. + /// + [Test] + public void Liveness_EveryHungryPhilEats_Asymmetric() + { + var phi = LtlFormula.And( + LtlFormula.LeadsTo(Hungry0, Eating0), + LtlFormula.LeadsTo(Hungry1, Eating1), + LtlFormula.LeadsTo(Hungry2, Eating2)); + var result = LtlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + } +} diff --git a/Samples/DiningPhilosophers/DiningCrossCheckTests.cs b/Samples/DiningPhilosophers/DiningCrossCheckTests.cs new file mode 100644 index 0000000..72d26bc --- /dev/null +++ b/Samples/DiningPhilosophers/DiningCrossCheckTests.cs @@ -0,0 +1,89 @@ +namespace DiningPhilosophers +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Testing; + using NUnit.Framework; + + /// + /// Cross-check harness: every LTL-expressible property tested in + /// is lifted to RLTL via + /// and re-checked. + /// + public class DiningCrossCheckTests + { + private StateGraphNode _naiveRoot; + private StateGraphNode _asymRoot; + + private static readonly Fairness PhilFairness = + Fairness.StrongFair(sf => sf is Dining.PhilStep); + + [SetUp] + public void Setup() + { + _naiveRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: false), Dining.InitialState(), lazy: true); + _asymRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: true), Dining.InitialState(), lazy: true); + } + + private static LtlFormula Eating0 => LtlFormula.Prop(Dining.Eating0, "Eating0"); + private static LtlFormula Eating1 => LtlFormula.Prop(Dining.Eating1, "Eating1"); + private static LtlFormula Eating2 => LtlFormula.Prop(Dining.Eating2, "Eating2"); + private static LtlFormula TwoEating => LtlFormula.Prop(Dining.TwoEating, "TwoEating"); + private static LtlFormula SomeEating => LtlFormula.Prop(Dining.SomeEating, "SomeEating"); + + [Test] + public void Safety_NoTwoEating_Naive_CrossCheck() => + LtlRltlCrossCheck.Run( + _naiveRoot, + LtlFormula.Always(LtlFormula.Not(TwoEating)), + fairness: Fairness.None, + label: nameof(Safety_NoTwoEating_Naive_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Safety_NoTwoEating_Asymmetric_CrossCheck() => + LtlRltlCrossCheck.Run( + _asymRoot, + LtlFormula.Always(LtlFormula.Not(TwoEating)), + fairness: Fairness.None, + label: nameof(Safety_NoTwoEating_Asymmetric_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil0_CrossCheck() => + LtlRltlCrossCheck.Run( + _asymRoot, + LtlFormula.InfinitelyOften(Eating0), + fairness: PhilFairness, + label: nameof(Liveness_EatsInfinitelyOften_Asymmetric_Phil0_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil1_CrossCheck() => + LtlRltlCrossCheck.Run( + _asymRoot, + LtlFormula.InfinitelyOften(Eating1), + fairness: PhilFairness, + label: nameof(Liveness_EatsInfinitelyOften_Asymmetric_Phil1_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil2_CrossCheck() => + LtlRltlCrossCheck.Run( + _asymRoot, + LtlFormula.InfinitelyOften(Eating2), + fairness: PhilFairness, + label: nameof(Liveness_EatsInfinitelyOften_Asymmetric_Phil2_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Liveness_Naive_EventualEating_FailsUnderStrongFairness_CrossCheck() => + LtlRltlCrossCheck.Run( + _naiveRoot, + LtlFormula.Eventually(SomeEating), + fairness: PhilFairness, + label: nameof(Liveness_Naive_EventualEating_FailsUnderStrongFairness_CrossCheck) + ).ThrowIfDisagree(); + } +} diff --git a/Samples/DiningPhilosophers/DiningGraphProbeTests.cs b/Samples/DiningPhilosophers/DiningGraphProbeTests.cs new file mode 100644 index 0000000..8a349fe --- /dev/null +++ b/Samples/DiningPhilosophers/DiningGraphProbeTests.cs @@ -0,0 +1,51 @@ +namespace DiningPhilosophers +{ + using System.Collections.Generic; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using NUnit.Framework; + + /// + /// Sanity-check: confirms the naive variant's classical deadlock state + /// is actually reachable in the explored graph, and the asymmetric + /// variant has no edges. + /// + public class DiningGraphProbeTests + { + private static (int states, int deadlocks) Walk(StateGraphNode root) + { + var seen = new HashSet(); + var stack = new Stack(); + stack.Push(root); seen.Add(root); + int deadlocks = 0; + while (stack.Count > 0) + { + var s = stack.Pop(); + foreach (var e in s.Edges) + { + if (seen.Add(e.Target)) stack.Push(e.Target); + if (e.StepFunction is Dining.DeadlockStutterStep) deadlocks++; + } + } + return (seen.Count, deadlocks); + } + + [Test] + public void Naive_Has_DeadlockState() + { + var root = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: false), Dining.InitialState(), lazy: true); + var (states, deadlocks) = Walk(root); + TestContext.WriteLine($"naive: {states} states, {deadlocks} deadlock edges"); + Assert.Greater(deadlocks, 0, "Naive variant should expose at least one deadlock self-loop."); + } + + [Test] + public void Asymmetric_HasNo_DeadlockState() + { + var root = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: true), Dining.InitialState(), lazy: true); + var (states, deadlocks) = Walk(root); + TestContext.WriteLine($"asymmetric: {states} states, {deadlocks} deadlock edges"); + Assert.AreEqual(0, deadlocks, "Asymmetric variant must be deadlock-free."); + } + } +} diff --git a/Samples/DiningPhilosophers/DiningLtlTests.cs b/Samples/DiningPhilosophers/DiningLtlTests.cs new file mode 100644 index 0000000..4083ec4 --- /dev/null +++ b/Samples/DiningPhilosophers/DiningLtlTests.cs @@ -0,0 +1,129 @@ +namespace DiningPhilosophers +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// LTL-only verification of the three-philosopher dining table for + /// both the naive pickup order (deadlocks) and the asymmetric order + /// (deadlock-free). Companion RLTL versions live in + /// . + /// + public class DiningLtlTests + { + private StateGraphNode _naiveRoot; + private StateGraphNode _asymRoot; + + /// + /// Strong fairness on every per-philosopher step. The + /// is intentionally + /// excluded: in the naive variant's deadlock state it is the only + /// enabled step, and being "fair" to it would defeat the point of + /// detecting starvation there. + /// + private static readonly Fairness PhilFairness = Fairness.StrongFair(sf => sf is Dining.PhilStep); + + [SetUp] + public void Setup() + { + _naiveRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: false), Dining.InitialState(), lazy: true); + _asymRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: true), Dining.InitialState(), lazy: true); + } + + // --- LTL atoms ---------------------------------------------------- + + private static LtlFormula Eating0 => LtlFormula.Prop(Dining.Eating0, "Eating0"); + private static LtlFormula Eating1 => LtlFormula.Prop(Dining.Eating1, "Eating1"); + private static LtlFormula Eating2 => LtlFormula.Prop(Dining.Eating2, "Eating2"); + private static LtlFormula Hungry0 => LtlFormula.Prop(Dining.Hungry0, "Hungry0"); + private static LtlFormula Hungry1 => LtlFormula.Prop(Dining.Hungry1, "Hungry1"); + private static LtlFormula Hungry2 => LtlFormula.Prop(Dining.Hungry2, "Hungry2"); + private static LtlFormula TwoEating => LtlFormula.Prop(Dining.TwoEating, "TwoEating"); + private static LtlFormula SomeEating => LtlFormula.Prop(Dining.SomeEating, "SomeEating"); + + // --- Safety (holds in both variants) ----------------------------- + + /// + /// Mutual exclusion of adjacent philosophers: at most one + /// philosopher is in Eating at any time. In a 3-ring every + /// pair of philosophers shares a fork, so this strengthens to + /// "no two are eating simultaneously". + /// + [Test] + public void Safety_NoTwoEating_Naive() + { + var phi = LtlFormula.Always(LtlFormula.Not(TwoEating)); + var result = LtlCheck.Check(_naiveRoot, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + [Test] + public void Safety_NoTwoEating_Asymmetric() + { + var phi = LtlFormula.Always(LtlFormula.Not(TwoEating)); + var result = LtlCheck.Check(_asymRoot, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + // --- Liveness — asymmetric variant succeeds ---------------------- + + /// + /// In the asymmetric (Chandy/Misra-style) ordering every + /// philosopher eats infinitely often, under strong fairness on + /// the per-philosopher steps. Strong fairness is required: each + /// pickup step is repeatedly disabled by competitors holding the + /// relevant fork, so weak fairness ("eventually always enabled") + /// would not force progress. + /// + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil0() + { + var phi = LtlFormula.InfinitelyOften(Eating0); + var result = LtlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil1() + { + var phi = LtlFormula.InfinitelyOften(Eating1); + var result = LtlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil2() + { + var phi = LtlFormula.InfinitelyOften(Eating2); + var result = LtlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + // --- Liveness — naive variant fails ------------------------------ + + // --- Liveness — naive variant fails even under strong fairness --- + + /// + /// In the naive ordering all three philosophers can simultaneously + /// pick up their left fork and then wait forever for their right + /// fork. The resulting deadlock state has only the + /// self-loop, so + /// ◇ SomeEating fails. The counterexample survives even + /// under strong fairness: in the deadlock SCC no + /// has an outgoing edge, so the + /// strong-fair condition for PhilSteps is vacuously satisfied. + /// + [Test] + public void Liveness_Naive_EventualEating_FailsUnderStrongFairness() + { + var phi = LtlFormula.Eventually(SomeEating); + var result = LtlCheck.Check(_naiveRoot, phi, fairness: PhilFairness); + Assert.IsFalse(result.Valid, + "Naive pickup order admits the classic three-fork deadlock; " + + "no philosopher ever reaches the Eating state on that path " + + "and the deadlock cycle is vacuously fair for PhilStep."); + } + } +} diff --git a/Samples/DiningPhilosophers/DiningOracleSweepTests.cs b/Samples/DiningPhilosophers/DiningOracleSweepTests.cs new file mode 100644 index 0000000..44e6cea --- /dev/null +++ b/Samples/DiningPhilosophers/DiningOracleSweepTests.cs @@ -0,0 +1,112 @@ +namespace DiningPhilosophers +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Testing; + using NUnit.Framework; + + /// + /// Drives every LTL-expressible DiningPhilosophers property + /// through the four-backend differential oracle under several + /// fairness configurations. Any disagreement is a bug. + /// + public class DiningOracleSweepTests + { + private StateGraphNode _naiveRoot; + private StateGraphNode _asymRoot; + + private static readonly Fairness PhilFairness = Fairness.StrongFair(sf => sf is Dining.PhilStep); + + [SetUp] + public void Setup() + { + _naiveRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: false), Dining.InitialState(), lazy: true); + _asymRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: true), Dining.InitialState(), lazy: true); + } + + private static LtlFormula Eating0 => LtlFormula.Prop(Dining.Eating0, "Eating0"); + private static LtlFormula Eating1 => LtlFormula.Prop(Dining.Eating1, "Eating1"); + private static LtlFormula Eating2 => LtlFormula.Prop(Dining.Eating2, "Eating2"); + private static LtlFormula TwoEating => LtlFormula.Prop(Dining.TwoEating, "TwoEating"); + private static LtlFormula SomeEating => LtlFormula.Prop(Dining.SomeEating, "SomeEating"); + + // --- Safety: no two adjacent philosophers eat ------------------ + + [Test] + public void Oracle_Safety_NoTwoEating_Naive() + { + var phi = LtlFormula.Always(LtlFormula.Not(TwoEating)); + var r = LtlMultiBackendCrossCheck.Run(_naiveRoot, phi, Fairness.None, nameof(Oracle_Safety_NoTwoEating_Naive)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_Safety_NoTwoEating_Asym() + { + var phi = LtlFormula.Always(LtlFormula.Not(TwoEating)); + var r = LtlMultiBackendCrossCheck.Run(_asymRoot, phi, Fairness.None, nameof(Oracle_Safety_NoTwoEating_Asym)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_Safety_NoTwoEating_Asym_WeakFair() + { + var phi = LtlFormula.Always(LtlFormula.Not(TwoEating)); + var r = LtlMultiBackendCrossCheck.Run(_asymRoot, phi, Fairness.WeakFairAll, nameof(Oracle_Safety_NoTwoEating_Asym_WeakFair)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + // --- Liveness: every philosopher eats infinitely often (asym) -- + + [Test] + public void Oracle_Liveness_EatsInfinitelyOften_Asym_Phil0() + { + var phi = LtlFormula.InfinitelyOften(Eating0); + var r = LtlMultiBackendCrossCheck.Run(_asymRoot, phi, PhilFairness, nameof(Oracle_Liveness_EatsInfinitelyOften_Asym_Phil0)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_Liveness_EatsInfinitelyOften_Asym_Phil1() + { + var phi = LtlFormula.InfinitelyOften(Eating1); + var r = LtlMultiBackendCrossCheck.Run(_asymRoot, phi, PhilFairness, nameof(Oracle_Liveness_EatsInfinitelyOften_Asym_Phil1)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_Liveness_EatsInfinitelyOften_Asym_Phil2() + { + var phi = LtlFormula.InfinitelyOften(Eating2); + var r = LtlMultiBackendCrossCheck.Run(_asymRoot, phi, PhilFairness, nameof(Oracle_Liveness_EatsInfinitelyOften_Asym_Phil2)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + // --- Naive deadlock: eventual eating fails even under strong fairness + + [Test] + public void Oracle_Liveness_Naive_EventualEating_FailsUnderStrongFairness() + { + var phi = LtlFormula.Eventually(SomeEating); + var r = LtlMultiBackendCrossCheck.Run(_naiveRoot, phi, PhilFairness, nameof(Oracle_Liveness_Naive_EventualEating_FailsUnderStrongFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.False); + } + + [Test] + public void Oracle_Liveness_Naive_EventualEating_FailsWithoutFairness() + { + var phi = LtlFormula.Eventually(SomeEating); + var r = LtlMultiBackendCrossCheck.Run(_naiveRoot, phi, Fairness.None, nameof(Oracle_Liveness_Naive_EventualEating_FailsWithoutFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.False); + } + } +} diff --git a/Samples/DiningPhilosophers/DiningPhilosophers.csproj b/Samples/DiningPhilosophers/DiningPhilosophers.csproj new file mode 100644 index 0000000..8f85f95 --- /dev/null +++ b/Samples/DiningPhilosophers/DiningPhilosophers.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + latest + Library + DiningPhilosophers + + + + + + + + + + + + + + + + diff --git a/Samples/DiningPhilosophers/DiningRltlShowcaseTests.cs b/Samples/DiningPhilosophers/DiningRltlShowcaseTests.cs new file mode 100644 index 0000000..e1e9f29 --- /dev/null +++ b/Samples/DiningPhilosophers/DiningRltlShowcaseTests.cs @@ -0,0 +1,131 @@ +namespace DiningPhilosophers +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// RLTL showcase: properties expressed using regular-expression + /// combinators (, , + /// , …). These complement the + /// pure-temporal tests in by exercising + /// the regex layer of the DSL on the dining-philosophers model. + /// + public class DiningRltlShowcaseTests + { + private StateGraphNode _asymRoot; + + private static readonly Fairness PhilFairness = + Fairness.StrongFair(sf => sf is Dining.PhilStep); + + [SetUp] + public void Setup() => + _asymRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: true), Dining.InitialState(), lazy: true); + + private static Regex REat0 => Regex.Prop(Dining.Eating0, "Eating0"); + private static Regex REat1 => Regex.Prop(Dining.Eating1, "Eating1"); + private static Regex REat2 => Regex.Prop(Dining.Eating2, "Eating2"); + private static Regex RTwoEating => Regex.Prop(Dining.TwoEating, "TwoEating"); + private static Regex SigmaStar => Regex.Star(Regex.Sigma); + + private static RltlFormula NotTwoEating => + RltlFormula.Not(RltlFormula.Prop(Dining.TwoEating, "TwoEating")); + + /// + /// Mutual-exclusion expressed as a regex trigger: + /// the prefix Σ* · TwoEating can never match — i.e., no + /// reachable run hits a state where two philosophers are eating. + /// The same property as + /// but phrased in regex-prefix form so the trigger fires the + /// moment the bad witness is observed. + /// + [Test] + public void Safety_TwoEating_NeverMatches_AsRegexTrigger() + { + var bad = Regex.Concat(SigmaStar, RTwoEating); + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + + var result = RltlCheck.Check(_asymRoot, phi); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// Match-style safety: at every position reached after seeing + /// , the system must not be in a + /// "two philosophers eating" state. Uses the overlapping match + /// combinator R ⊳⊳ φ, which aligns the suffix obligation + /// with the last consumed letter rather than the one after it. + /// + [Test] + public void Safety_AfterEating0_NoTwoEating_AsOverlappingMatch() + { + var prefix = Regex.Concat(SigmaStar, REat0); + var phi = RltlFormula.Match(prefix, NotTwoEating); + + var result = RltlCheck.Check(_asymRoot, phi); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// Round-robin sighting (existential check via negation): show + /// that the asymmetric variant can exhibit a phil0 → + /// phil1 → phil2 eating sequence by negating "no such prefix + /// ever exists" and asserting the negation fails. + /// + /// The regex requires three Eating events in order, separated by + /// arbitrary intermediate states. + /// is existential per run: R ; φ = ∃k. w[0..k] ∈ L(R) ∧ + /// w[k..] ⊨ φ. RltlCheck reports a counterexample when it finds + /// a run that violates a property; here we phrase the dual. + /// + [Test] + public void Witness_PhilZeroOneTwoEatInOrder_Exists() + { + var round = Regex.Concat(SigmaStar, REat0); + round = Regex.Concat(round, SigmaStar); + round = Regex.Concat(round, REat1); + round = Regex.Concat(round, SigmaStar); + round = Regex.Concat(round, REat2); + + // "The 0-1-2 eating sequence never appears" — should FAIL under + // strong PhilFairness because every philosopher eats infinitely + // often in the asymmetric variant. + var noSuchOrder = RltlFormula.Trigger(round, RltlFormula.False); + + var result = RltlCheck.Check(_asymRoot, noSuchOrder, fairness: PhilFairness); + Assert.That(result.Valid, Is.False, + "Asymmetric DP admits runs in which phil0, phil1, phil2 eat in that order."); + } + + /// + /// Bounded-burst constraint: between any two consecutive + /// events, some other philosopher + /// must eat in between. Phrased as a forbidden regex prefix: + /// Σ* · Eating0 · (¬Eating0)* · Eating0 — if this prefix + /// matches, none of the gap states had Eating0 (by construction) + /// but we additionally require that the gap also lacked + /// Eating1 and Eating2 — i.e., a "burst" of phil0-only eating. + /// Should fail in the asymmetric model under : + /// phil0 can transition Hungry → Eating → Hungry → Eating without + /// phil1 or phil2 necessarily eating in between. Surfaced via + /// counterexample. + /// + [Test] + public void Counterexample_TwoEating0WithoutOthersInBetween_Exists() + { + var notOtherEat = Regex.Prop( + (IState s) => !Dining.Eating1(s) && !Dining.Eating2(s), + "¬Eating1 ∧ ¬Eating2"); + var burst = Regex.Concat(SigmaStar, REat0); + burst = Regex.Concat(burst, Regex.Star(notOtherEat)); + burst = Regex.Concat(burst, REat0); + + var noBurst = RltlFormula.Trigger(burst, RltlFormula.False); + + var result = RltlCheck.Check(_asymRoot, noBurst, fairness: PhilFairness); + Assert.That(result.Valid, Is.False, + "Asymmetric DP allows phil0 to eat twice with no Eating1/Eating2 in between."); + } + } +} diff --git a/Samples/DiningPhilosophers/DiningRltlTests.cs b/Samples/DiningPhilosophers/DiningRltlTests.cs new file mode 100644 index 0000000..35e1dab --- /dev/null +++ b/Samples/DiningPhilosophers/DiningRltlTests.cs @@ -0,0 +1,94 @@ +namespace DiningPhilosophers +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// RLTL counterparts of . The safety and + /// liveness properties translate directly; both checkers should agree + /// for these LTL-expressible formulas. + /// + public class DiningRltlTests + { + private StateGraphNode _naiveRoot; + private StateGraphNode _asymRoot; + + private static readonly Fairness PhilFairness = Fairness.StrongFair(sf => sf is Dining.PhilStep); + + [SetUp] + public void Setup() + { + _naiveRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: false), Dining.InitialState(), lazy: true); + _asymRoot = StateGraph.ExploreStateGraph(Dining.AllSteps(asymmetric: true), Dining.InitialState(), lazy: true); + } + + // --- RLTL atoms --------------------------------------------------- + + private static RltlFormula Eating0 => RltlFormula.Prop(Dining.Eating0, "Eating0"); + private static RltlFormula Eating1 => RltlFormula.Prop(Dining.Eating1, "Eating1"); + private static RltlFormula Eating2 => RltlFormula.Prop(Dining.Eating2, "Eating2"); + private static RltlFormula Hungry0 => RltlFormula.Prop(Dining.Hungry0, "Hungry0"); + private static RltlFormula Hungry1 => RltlFormula.Prop(Dining.Hungry1, "Hungry1"); + private static RltlFormula Hungry2 => RltlFormula.Prop(Dining.Hungry2, "Hungry2"); + private static RltlFormula TwoEating => RltlFormula.Prop(Dining.TwoEating, "TwoEating"); + private static RltlFormula SomeEating => RltlFormula.Prop(Dining.SomeEating, "SomeEating"); + + // --- Safety ------------------------------------------------------ + + [Test] + public void Safety_NoTwoEating_Naive() + { + var phi = RltlFormula.Always(RltlFormula.Not(TwoEating)); + var result = RltlCheck.Check(_naiveRoot, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + [Test] + public void Safety_NoTwoEating_Asymmetric() + { + var phi = RltlFormula.Always(RltlFormula.Not(TwoEating)); + var result = RltlCheck.Check(_asymRoot, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + // --- Liveness — asymmetric variant succeeds ---------------------- + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil0() + { + var phi = RltlFormula.InfinitelyOften(Eating0); + var result = RltlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil1() + { + var phi = RltlFormula.InfinitelyOften(Eating1); + var result = RltlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + [Test] + public void Liveness_EatsInfinitelyOften_Asymmetric_Phil2() + { + var phi = RltlFormula.InfinitelyOften(Eating2); + var result = RltlCheck.Check(_asymRoot, phi, fairness: PhilFairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + // --- Liveness — naive variant fails ------------------------------ + + [Test] + public void Liveness_Naive_EventualEating_FailsUnderStrongFairness() + { + var phi = RltlFormula.Eventually(SomeEating); + var result = RltlCheck.Check(_naiveRoot, phi, fairness: PhilFairness); + Assert.IsFalse(result.Valid, + "Naive pickup order deadlocks before anyone reaches the Eating state; " + + "the deadlock cycle is vacuously fair for PhilStep."); + } + } +} diff --git a/Samples/DiningPhilosophers/DiningState.cs b/Samples/DiningPhilosophers/DiningState.cs new file mode 100644 index 0000000..6cf6938 --- /dev/null +++ b/Samples/DiningPhilosophers/DiningState.cs @@ -0,0 +1,42 @@ +namespace DiningPhilosophers +{ + using Microsoft.Accordant; + + /// Program-counter values for one philosopher. + public enum PhilPC + { + /// Idling between meals. + Thinking, + /// Wants to eat but holds no fork yet. + Hungry, + /// Holds the first fork in this philosopher's pickup order. + HoldOne, + /// Holds both forks and is eating. + Eating, + } + + /// + /// Global state for the three-philosopher dining table. + /// + [State] + public partial class DiningState + { + /// Per-philosopher program counters. + public PhilPC PC0 { get; set; } + public PhilPC PC1 { get; set; } + public PhilPC PC2 { get; set; } + + /// + /// Fork ownership. -1 means free; otherwise the index of the + /// philosopher currently holding the fork. Fork i sits between + /// philosophers i and (i-1) mod 3 in the obvious round- + /// table layout — but every philosopher's pickup logic refers to + /// forks by index via , so the geometry is + /// abstracted away here. + /// + public int F0 { get; set; } + public int F1 { get; set; } + public int F2 { get; set; } + + } +} diff --git a/Samples/Paxos/Paxos.cs b/Samples/Paxos/Paxos.cs new file mode 100644 index 0000000..939edb9 --- /dev/null +++ b/Samples/Paxos/Paxos.cs @@ -0,0 +1,338 @@ +namespace Accordant.Samples.Paxos +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant; + + /// + /// Bounded single-decree Paxos model program. + /// + /// + /// Bounds. =2 proposers, =2 + /// acceptors with quorum =2. Proposer p + /// always uses ballot p + 1 (so proposer 0 → ballot 1, + /// proposer 1 → ballot 2) and prefers value p + 1 (proposer + /// 0 → value 1, proposer 1 → value 2). These choices are baked into + /// the steps to minimise state-space size while still exposing the + /// classical Paxos interleavings. + /// + /// + /// + /// Messages. Modelled as per-pair delivery steps. A + /// represents one acceptor + /// processing one proposer's Prepare; an + /// the same for Accept. Quorum + /// detection is performed atomically by + /// / , + /// which also fix the proposer's value per the Paxos rule + /// (carry the highest previously-accepted value seen across + /// the quorum, else the proposer's preference). + /// + /// + public static class Paxos + { + /// + /// Per-proposer phase in single-decree Paxos. + /// + /// Idle ── Phase1 attempts ─→ Promised ── Phase2 attempts ─→ Decided + /// + /// + public enum ProposerPhase + { + Idle, + Promised, + Decided, + } + + /// Op tag for the last step, used by transition-style atoms. + public enum PaxosOp + { + None, + PrepareDeliver, + Phase1Done, + AcceptDeliver, + Phase2Done, + } + + public const int P = 2; + public const int A = 2; + public const int Quorum = 2; + + /// Ballot for proposer (fixed). + public static int Ballot(int p) => p + 1; + /// Preferred value for proposer (fixed, non-zero). + public static int Preferred(int p) => p + 1; + + public static PaxosState InitialState() => new PaxosState + { + Phase = new ProposerPhase[P], + ProposedValue = new int[P], + PreparedMask = new int[P], + AcceptedMask = new int[P], + Promised = new int[A], + AcceptedBallot = new int[A], + AcceptedValue = new int[A], + LastOp = PaxosOp.None, + LastProposer = -1, + LastAcceptor = -1, + }; + + public static IList AllSteps() => BuildSteps(buggyQuorum: false); + + /// + /// Buggy variant: quorum check accepts a single response instead + /// of a majority, breaking the Paxos safety guarantee. Used by + /// the bug-demo to show that the Agreement property fails with a + /// non-trivial counterexample. + /// + public static IList AllStepsBuggyQuorum() => BuildSteps(buggyQuorum: true); + + private static IList BuildSteps(bool buggyQuorum) + { + var steps = new List(); + for (int p = 0; p < P; p++) + { + for (int a = 0; a < A; a++) + { + steps.Add(new PrepareDeliverStep(p, a)); + steps.Add(new AcceptDeliverStep(p, a)); + } + steps.Add(new Phase1DoneStep(p, buggyQuorum)); + steps.Add(new Phase2DoneStep(p, buggyQuorum)); + } + steps.Add(new IdleStep()); + return steps; + } + + // --- Atomic predicates -------------------------------------------- + + public static Func ProposerIs(int p, ProposerPhase ph) + => s => ((PaxosState)s).Phase[p] == ph; + + public static Func Decided(int p) + => s => ((PaxosState)s).Phase[p] == ProposerPhase.Decided; + + public static Func DecidedValue(int p, int v) + => s => + { + var st = (PaxosState)s; + return st.Phase[p] == ProposerPhase.Decided && st.ProposedValue[p] == v; + }; + + /// Some proposer has decided. + public static bool AnyDecided(IState s) + { + var st = (PaxosState)s; + for (int p = 0; p < P; p++) + if (st.Phase[p] == ProposerPhase.Decided) return true; + return false; + } + + /// + /// Agreement predicate: every pair of decided proposers has the + /// same value. The Paxos safety invariant. + /// + public static bool Agreement(IState s) + { + var st = (PaxosState)s; + int v = 0; + for (int p = 0; p < P; p++) + { + if (st.Phase[p] != ProposerPhase.Decided) continue; + if (v == 0) v = st.ProposedValue[p]; + else if (st.ProposedValue[p] != v) return false; + } + return true; + } + + /// + /// Validity: the value decided was proposed by some proposer + /// (one of Preferred(p)). + /// + public static bool Validity(IState s) + { + var st = (PaxosState)s; + for (int p = 0; p < P; p++) + { + if (st.Phase[p] != ProposerPhase.Decided) continue; + var v = st.ProposedValue[p]; + bool ok = false; + for (int q = 0; q < P; q++) + if (Preferred(q) == v) { ok = true; break; } + if (!ok) return false; + } + return true; + } + + // --- Steps -------------------------------------------------------- + + public abstract class PaxosStep : BaseStepFunction + { + public abstract bool IsEnabled(PaxosState s); + public abstract PaxosState ApplyMutate(PaxosState s); + + protected override IList ApplyInternal(IState state) + { + var s = (PaxosState)state; + if (!IsEnabled(s)) return null; + var n = ApplyMutate(s); + return new[] { new StepResult { State = n, StepFunctions = new IStepFunction[] { this } } }; + } + } + + /// + /// Acceptor a processes proposer p's Prepare(b_p). + /// If b_p > a.Promised the acceptor promises and the + /// proposer's bit for + /// a is set; otherwise the message is silently dropped + /// (no NACK in this model). + /// + public sealed class PrepareDeliverStep : PaxosStep + { + public int Pr { get; } + public int Ac { get; } + public PrepareDeliverStep(int pr, int ac) { Pr = pr; Ac = ac; } + public override string StepFunctionId => $"Prepare_{Pr}_{Ac}"; + + public override bool IsEnabled(PaxosState s) + { + if (s.Phase[Pr] != ProposerPhase.Idle) return false; + if ((s.PreparedMask[Pr] & (1 << Ac)) != 0) return false; + return Ballot(Pr) > s.Promised[Ac]; + } + + public override PaxosState ApplyMutate(PaxosState s) + { + var n = (PaxosState)s.Clone(); + n.Promised[Ac] = Ballot(Pr); + n.PreparedMask[Pr] |= (1 << Ac); + n.LastOp = PaxosOp.PrepareDeliver; + n.LastProposer = Pr; + n.LastAcceptor = Ac; + return n; + } + } + + /// + /// Proposer p recognises a quorum of promises and enters + /// . By the Paxos rule, if + /// any acceptor in the quorum reports an earlier accepted value, + /// the proposer adopts that value (highest-ballot wins); else it + /// uses its preferred value. In the buggy variant, the quorum + /// threshold is 1 instead of . + /// + public sealed class Phase1DoneStep : PaxosStep + { + public int Pr { get; } + public bool Buggy { get; } + public Phase1DoneStep(int pr, bool buggy) { Pr = pr; Buggy = buggy; } + public override string StepFunctionId => $"Phase1Done_{Pr}"; + + public override bool IsEnabled(PaxosState s) + { + if (s.Phase[Pr] != ProposerPhase.Idle) return false; + int count = System.Numerics.BitOperations.PopCount((uint)s.PreparedMask[Pr]); + return count >= (Buggy ? 1 : Quorum); + } + + public override PaxosState ApplyMutate(PaxosState s) + { + var n = (PaxosState)s.Clone(); + int bestBallot = 0, bestValue = 0; + for (int a = 0; a < A; a++) + { + if ((n.PreparedMask[Pr] & (1 << a)) == 0) continue; + if (n.AcceptedBallot[a] > bestBallot) + { + bestBallot = n.AcceptedBallot[a]; + bestValue = n.AcceptedValue[a]; + } + } + n.ProposedValue[Pr] = bestBallot > 0 ? bestValue : Preferred(Pr); + n.Phase[Pr] = ProposerPhase.Promised; + n.LastOp = PaxosOp.Phase1Done; + n.LastProposer = Pr; + n.LastAcceptor = -1; + return n; + } + } + + /// + /// Acceptor a processes proposer p's Accept(b_p, v_p). + /// If b_p ≥ a.Promised the acceptor records the accepted + /// proposal and the proposer's + /// bit for a is set; else the message is dropped. + /// + public sealed class AcceptDeliverStep : PaxosStep + { + public int Pr { get; } + public int Ac { get; } + public AcceptDeliverStep(int pr, int ac) { Pr = pr; Ac = ac; } + public override string StepFunctionId => $"Accept_{Pr}_{Ac}"; + + public override bool IsEnabled(PaxosState s) + { + if (s.Phase[Pr] != ProposerPhase.Promised) return false; + if ((s.AcceptedMask[Pr] & (1 << Ac)) != 0) return false; + return Ballot(Pr) >= s.Promised[Ac]; + } + + public override PaxosState ApplyMutate(PaxosState s) + { + var n = (PaxosState)s.Clone(); + n.AcceptedBallot[Ac] = Ballot(Pr); + n.AcceptedValue[Ac] = n.ProposedValue[Pr]; + n.AcceptedMask[Pr] |= (1 << Ac); + n.LastOp = PaxosOp.AcceptDeliver; + n.LastProposer = Pr; + n.LastAcceptor = Ac; + return n; + } + } + + /// + /// Proposer p recognises a quorum of accepts and enters + /// . + /// + public sealed class Phase2DoneStep : PaxosStep + { + public int Pr { get; } + public bool Buggy { get; } + public Phase2DoneStep(int pr, bool buggy) { Pr = pr; Buggy = buggy; } + public override string StepFunctionId => $"Phase2Done_{Pr}"; + + public override bool IsEnabled(PaxosState s) + { + if (s.Phase[Pr] != ProposerPhase.Promised) return false; + int count = System.Numerics.BitOperations.PopCount((uint)s.AcceptedMask[Pr]); + return count >= (Buggy ? 1 : Quorum); + } + + public override PaxosState ApplyMutate(PaxosState s) + { + var n = (PaxosState)s.Clone(); + n.Phase[Pr] = ProposerPhase.Decided; + n.LastOp = PaxosOp.Phase2Done; + n.LastProposer = Pr; + n.LastAcceptor = -1; + return n; + } + } + + /// Self-loop to keep terminal states non-sink and clear last-op. + public sealed class IdleStep : BaseStepFunction + { + public override string StepFunctionId => "Idle"; + + protected override IList ApplyInternal(IState state) + { + var n = (PaxosState)((PaxosState)state).Clone(); + n.LastOp = PaxosOp.None; + n.LastProposer = -1; + n.LastAcceptor = -1; + return new[] { new StepResult { State = n, StepFunctions = new IStepFunction[] { this } } }; + } + } + } +} + diff --git a/Samples/Paxos/Paxos.csproj b/Samples/Paxos/Paxos.csproj new file mode 100644 index 0000000..04bde8f --- /dev/null +++ b/Samples/Paxos/Paxos.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + latest + Library + + + + + + + + + + + + + + + + diff --git a/Samples/Paxos/PaxosBugDemoTests.cs b/Samples/Paxos/PaxosBugDemoTests.cs new file mode 100644 index 0000000..354f198 --- /dev/null +++ b/Samples/Paxos/PaxosBugDemoTests.cs @@ -0,0 +1,60 @@ +namespace Accordant.Samples.Paxos +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// Bug-injection demo for the Paxos model: replaces the + /// =2 threshold inside + /// and + /// with quorum=1, breaking the + /// classical Paxos majority requirement. Each test asserts that the + /// corresponding LTL/RLTL safety property reports a counterexample. + /// + public class PaxosBugDemoTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph( + Paxos.AllStepsBuggyQuorum(), Paxos.InitialState(), lazy: true); + + /// + /// LTL Agreement fails: with quorum=1, two proposers can + /// independently get a single promise/accept and decide + /// different values. + /// + [Test] + public void Bug_Agreement_Fails() + { + var phi = LtlFormula.Always(LtlFormula.Prop(Paxos.Agreement, "Agreement")); + var r = LtlCheck.Check(_root, phi); + Assert.IsFalse(r.Valid, "Buggy quorum should break Agreement."); + Assert.That(r.GetTraceString(), Is.Not.Null.And.Not.Empty); + } + + /// + /// RLTL forbidden-prefix Agreement also fails — there is a + /// reachable run with Decided_0=1 · ... · Decided_1=2. + /// + [Test] + public void Bug_RltlForbiddenAgreement_Matches() + { + var sigmaStar = Regex.Star(Regex.Sigma); + var bad = Regex.Concat(sigmaStar, + Regex.Concat( + Regex.Prop(Paxos.DecidedValue(0, 1), "Dec_0=1"), + Regex.Concat(sigmaStar, + Regex.Prop(Paxos.DecidedValue(1, 2), "Dec_1=2")))); + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + var r = RltlCheck.Check(_root, phi); + Assert.IsFalse(r.Valid, "Disagreement prefix should match in the buggy model."); + Assert.That(r.GetTraceString(), Is.Not.Null.And.Not.Empty); + } + } +} + diff --git a/Samples/Paxos/PaxosLtlTests.cs b/Samples/Paxos/PaxosLtlTests.cs new file mode 100644 index 0000000..386f22f --- /dev/null +++ b/Samples/Paxos/PaxosLtlTests.cs @@ -0,0 +1,99 @@ +namespace Accordant.Samples.Paxos +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// LTL properties for the bounded single-decree Paxos model. + /// + public class PaxosLtlTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph(Paxos.AllSteps(), Paxos.InitialState(), lazy: true); + + /// + /// Agreement: at every reachable state, all decided proposers + /// have agreed on the same value. + /// + [Test] + public void Safety_Agreement() + { + var phi = LtlFormula.Always(LtlFormula.Prop(Paxos.Agreement, "Agreement")); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + /// Validity: any decided value was proposed by someone. + [Test] + public void Safety_Validity() + { + var phi = LtlFormula.Always(LtlFormula.Prop(Paxos.Validity, "Validity")); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + /// + /// Decided is stable: once a proposer decides a value, it + /// remains decided with the same value forever. + /// + [Test] + public void Safety_DecidedStable() + { + for (int p = 0; p < Paxos.P; p++) + { + var v = Paxos.Preferred(p); + // Note: a proposer can only ever decide its own preferred + // value (it overwrites only when it adopts a higher-ballot + // accepted value — but with 2 proposers and ballots 1<2, + // proposer 1 may adopt proposer 0's value, hence we check + // for each *possible* decided value separately. + for (int candV = 1; candV <= Paxos.P; candV++) + { + var dv = LtlFormula.Prop(Paxos.DecidedValue(p, candV), + $"Decided_{p}={candV}"); + var phi = LtlFormula.Always(LtlFormula.Implies( + dv, LtlFormula.Always(dv))); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, $"p={p}, v={candV}: {r.GetTraceString()}"); + } + } + } + + /// + /// Liveness: under strong fairness on every protocol step, some + /// proposer eventually decides. The schedule includes + /// PrepareDeliver/AcceptDeliver/Phase1Done/Phase2Done for both + /// proposers, so progress is guaranteed. + /// + [Test] + public void Liveness_SomeoneDecides_UnderFullFairness() + { + var fair = Fairness.StrongFair(sf => + sf is Paxos.PrepareDeliverStep + || sf is Paxos.AcceptDeliverStep + || sf is Paxos.Phase1DoneStep + || sf is Paxos.Phase2DoneStep); + var phi = LtlFormula.Eventually(LtlFormula.Prop(Paxos.AnyDecided, "AnyDecided")); + var r = LtlCheck.Check(_root, phi, fairness: fair); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + /// + /// Without fairness, the Idle self-loop avoids any decision — + /// liveness fails. + /// + [Test] + public void Liveness_SomeoneDecides_NoFairness_Fails() + { + var phi = LtlFormula.Eventually(LtlFormula.Prop(Paxos.AnyDecided, "AnyDecided")); + var r = LtlCheck.Check(_root, phi, fairness: Fairness.None); + Assert.IsFalse(r.Valid, "Idle stutter at the initial state is a non-deciding cycle."); + } + } +} + diff --git a/Samples/Paxos/PaxosRltlShowcaseTests.cs b/Samples/Paxos/PaxosRltlShowcaseTests.cs new file mode 100644 index 0000000..aa94774 --- /dev/null +++ b/Samples/Paxos/PaxosRltlShowcaseTests.cs @@ -0,0 +1,121 @@ +namespace Accordant.Samples.Paxos +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// RLTL showcase for single-decree Paxos: regex-shaped expressions of + /// the Paxos safety invariants (Agreement, monotonicity of Decided). + /// + public class PaxosRltlShowcaseTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph(Paxos.AllSteps(), Paxos.InitialState(), lazy: true); + + private static Regex SigmaStar => Regex.Star(Regex.Sigma); + private static Regex Decided(int p, int v) => + Regex.Prop(Paxos.DecidedValue(p, v), $"Dec_{p}={v}"); + + /// + /// Agreement as a forbidden prefix family: for every pair of + /// distinct values (v1, v2) and proposers (p1, p2), + /// the prefix Σ* · DecidedValue(p1, v1) · Σ* · DecidedValue(p2, v2) + /// never matches. Direct regex encoding of the LTL Agreement + /// invariant. + /// + [Test] + public void Agreement_AsForbiddenPrefixFamily() + { + for (int p1 = 0; p1 < Paxos.P; p1++) + for (int p2 = 0; p2 < Paxos.P; p2++) + { + if (p1 == p2) continue; + for (int v1 = 1; v1 <= Paxos.P; v1++) + for (int v2 = 1; v2 <= Paxos.P; v2++) + { + if (v1 == v2) continue; + var bad = Regex.Concat(SigmaStar, + Regex.Concat(Decided(p1, v1), + Regex.Concat(SigmaStar, Decided(p2, v2)))); + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + var r = RltlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, + $"p1={p1},v1={v1},p2={p2},v2={v2}: {r.GetTraceString()}"); + } + } + } + + /// + /// Monotonicity of decision: once a proposer decides with value + /// v, no prefix ending in "decided with a different value" + /// is reachable. Captures the LTL "Decided stable" property + /// purely as a forbidden regex. + /// + [Test] + public void DecidedStable_AsForbiddenPrefix() + { + for (int p = 0; p < Paxos.P; p++) + for (int v1 = 1; v1 <= Paxos.P; v1++) + for (int v2 = 1; v2 <= Paxos.P; v2++) + { + if (v1 == v2) continue; + var bad = Regex.Concat(SigmaStar, + Regex.Concat(Decided(p, v1), + Regex.Concat(SigmaStar, Decided(p, v2)))); + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + var r = RltlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, $"p={p},v1={v1},v2={v2}: {r.GetTraceString()}"); + } + } + + /// + /// Match-shape Agreement: at every prefix ending in "proposer 0 + /// has decided with value v", every future state where proposer 1 + /// is also decided must agree. Phrased as + /// (Σ* · Decided_0=v) ⊳⊳ □(Decided_1=v ∨ ¬Decided_1). + /// + [Test] + public void Match_AgreementAcrossProposers() + { + for (int v = 1; v <= Paxos.P; v++) + { + var prefix = Regex.Concat(SigmaStar, Decided(0, v)); + var dec1Agree = RltlFormula.Prop(Paxos.DecidedValue(1, v), $"Dec_1={v}"); + var notDec1 = RltlFormula.Not( + RltlFormula.Prop(Paxos.Decided(1), "Dec_1")); + var body = RltlFormula.Always(RltlFormula.Or(dec1Agree, notDec1)); + var phi = RltlFormula.Match(prefix, body); + var r = RltlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, $"v={v}: {r.GetTraceString()}"); + } + } + + /// + /// Intersection demo: the prefix language + /// (Σ* · Decided_0=v) ∩ (Σ* · Decided_1=v) with the same + /// value v is reachable (both proposers can decide on the + /// same value when proposer 1 adopts proposer 0's value via the + /// "highest accepted" rule). Asserted by Trigger-to-False failing. + /// + [Test] + public void Intersection_BothDecideSameValue_IsReachable() + { + // Pick the proposer-0-preferred value as the most likely to + // appear in both proposers' Decided sets. + int v = Paxos.Preferred(0); + var both = Regex.Intersect( + Regex.Concat(SigmaStar, Decided(0, v)), + Regex.Concat(SigmaStar, Decided(1, v))); + var phi = RltlFormula.Trigger(both, RltlFormula.False); + var r = RltlCheck.Check(_root, phi); + Assert.IsFalse(r.Valid, + "Both proposers should be able to decide the same value (P0's preference)."); + } + } +} + diff --git a/Samples/Paxos/PaxosState.cs b/Samples/Paxos/PaxosState.cs new file mode 100644 index 0000000..d9b70a8 --- /dev/null +++ b/Samples/Paxos/PaxosState.cs @@ -0,0 +1,37 @@ +namespace Accordant.Samples.Paxos +{ + using Microsoft.Accordant; + + /// + /// Global state of the bounded single-decree Paxos model. + /// proposers and acceptors; + /// each proposer has a fixed ballot and a fixed preferred value. + /// The model bakes the preferred value into the proposer id to keep + /// the state space small (no separate Init step needed). + /// + [State] + public partial class PaxosState : State + { + // --- Proposer state ----------------------------------------------- + public Paxos.ProposerPhase[] Phase { get; set; } + /// Value the proposer will use in Phase2 (set when entering Promised). + public int[] ProposedValue { get; set; } + /// Bitmask over acceptors: which have promised this proposer. + public int[] PreparedMask { get; set; } + /// Bitmask over acceptors: which have accepted this proposer's value. + public int[] AcceptedMask { get; set; } + + // --- Acceptor state ----------------------------------------------- + /// Highest ballot promised so far (0 = none). + public int[] Promised { get; set; } + /// Ballot of the last accepted proposal (0 = none). + public int[] AcceptedBallot { get; set; } + /// Value of the last accepted proposal (0 = none). + public int[] AcceptedValue { get; set; } + + public Paxos.PaxosOp LastOp { get; set; } + public int LastProposer { get; set; } + public int LastAcceptor { get; set; } + } +} + diff --git a/Samples/Peterson/Peterson.cs b/Samples/Peterson/Peterson.cs new file mode 100644 index 0000000..b04e904 --- /dev/null +++ b/Samples/Peterson/Peterson.cs @@ -0,0 +1,250 @@ +namespace Peterson +{ + using System.Collections.Generic; + using Microsoft.Accordant; + + /// + /// Peterson's algorithm for mutual exclusion of two processes + /// (Peterson, 1981). The classical pseudocode for process i with + /// j = 1 - i is: + /// + /// loop forever: + /// non-critical section + /// flag[i] := true + /// turn := j + /// while flag[j] ∧ turn = j: skip + /// critical section + /// flag[i] := false + /// + /// Each statement is realised here as a separate + /// so that the model checker can interleave + /// the two processes freely. Compound operations are kept atomic at the + /// per-statement granularity — sufficient to expose all the + /// interesting interleavings (the busy-wait loop is collapsed into a + /// single guarded ). + /// + public static class Peterson + { + // --- Atomic predicates -------------------------------------------- + + /// Process 0 is in the critical section. + public static bool Crit0(State s) => ((PetersonState)s).PC0 == PetersonPC.CS; + + /// Process 1 is in the critical section. + public static bool Crit1(State s) => ((PetersonState)s).PC1 == PetersonPC.CS; + + /// Process 0 is busy-waiting at the spin loop (PC0 = Wait). + public static bool InWait0(State s) => ((PetersonState)s).PC0 == PetersonPC.Wait; + + /// Process 1 is busy-waiting at the spin loop (PC1 = Wait). + public static bool InWait1(State s) => ((PetersonState)s).PC1 == PetersonPC.Wait; + + /// Process 0 has indicated intent and is not in CS yet. + public static bool Want0(State s) + { + var pc = ((PetersonState)s).PC0; + return pc == PetersonPC.SetFlag || pc == PetersonPC.SetTurn || pc == PetersonPC.Wait; + } + + /// Process 1 has indicated intent and is not in CS yet. + public static bool Want1(State s) + { + var pc = ((PetersonState)s).PC1; + return pc == PetersonPC.SetFlag || pc == PetersonPC.SetTurn || pc == PetersonPC.Wait; + } + + // --- State-graph construction ------------------------------------ + + /// + /// Returns the initial state: both processes idle in NCS, + /// flags clear, turn = 0. + /// + public static PetersonState InitialState() + => new PetersonState + { + Flag0 = false, + Flag1 = false, + Turn = 0, + PC0 = PetersonPC.NCS, + PC1 = PetersonPC.NCS, + }; + + /// + /// Returns the list of step functions for the two-process variant. + /// Each process contributes one step per pseudocode statement. + /// + public static IList AllSteps() => BuildSteps(buggy: false); + + /// + /// Bug-injection variant: replaces with + /// , which drops both Peterson guard + /// clauses (¬flag[j] ∨ turn = i) and so admits the + /// critical section unconditionally once the process is in + /// . Mutual exclusion fails. + /// + public static IList AllStepsBuggy() => BuildSteps(buggy: true); + + private static IList BuildSteps(bool buggy) + { + var steps = new List(); + for (int i = 0; i < 2; i++) + { + steps.Add(new RequestStep(i)); + steps.Add(new SetFlagStep(i)); + steps.Add(new SetTurnStep(i)); + steps.Add(buggy ? (PetersonStep)new BuggyEnterCSStep(i) : new EnterCSStep(i)); + steps.Add(new ExitCSStep(i)); + steps.Add(new ResetFlagStep(i)); + } + return steps; + } + + // --- Step functions ---------------------------------------------- + + /// Common scaffolding for a deterministic Peterson step. + public abstract class PetersonStep : BaseStepFunction + { + /// Process index (0 or 1) this step belongs to. + public int I { get; } + /// Index of the other process — 1 - I. + protected int J => 1 - I; + + protected PetersonStep(int i) { I = i; } + + /// Predicate selecting the states this step is enabled in. + public abstract bool IsEnabled(PetersonState s); + /// Computes the unique successor state. + public abstract PetersonState Apply(PetersonState s); + + /// + /// Stable id so two equally-configured steps in different + /// graph nodes hash identically (required by the fairness checker + /// which keys on ). + /// + public override string StepFunctionId => GetType().Name + "_" + I; + + protected override IList ApplyInternal(IState state) + { + var s = (PetersonState)state; + if (!IsEnabled(s)) return null; + return new[] + { + new StepResult + { + State = Apply(s), + StepFunctions = new IStepFunction[] { this }, + } + }; + } + + /// Mutates the chosen process's PC; helper for subclasses. + protected PetersonState WithPC(PetersonState s, PetersonPC pc) + { + var next = (PetersonState)s.Clone(); + if (I == 0) next.PC0 = pc; else next.PC1 = pc; + return next; + } + } + + /// NCS → SetFlag — process decides to enter. + public sealed class RequestStep : PetersonStep + { + public RequestStep(int i) : base(i) { } + public override bool IsEnabled(PetersonState s) + => (I == 0 ? s.PC0 : s.PC1) == PetersonPC.NCS; + public override PetersonState Apply(PetersonState s) + => WithPC(s, PetersonPC.SetFlag); + } + + /// SetFlag → SetTurn — flag[i] := true. + public sealed class SetFlagStep : PetersonStep + { + public SetFlagStep(int i) : base(i) { } + public override bool IsEnabled(PetersonState s) + => (I == 0 ? s.PC0 : s.PC1) == PetersonPC.SetFlag; + public override PetersonState Apply(PetersonState s) + { + var next = WithPC(s, PetersonPC.SetTurn); + if (I == 0) next.Flag0 = true; else next.Flag1 = true; + return next; + } + } + + /// SetTurn → Wait — turn := 1 - i. + public sealed class SetTurnStep : PetersonStep + { + public SetTurnStep(int i) : base(i) { } + public override bool IsEnabled(PetersonState s) + => (I == 0 ? s.PC0 : s.PC1) == PetersonPC.SetTurn; + public override PetersonState Apply(PetersonState s) + { + var next = WithPC(s, PetersonPC.Wait); + next.Turn = J; + return next; + } + } + + /// + /// Wait → CS — guard ¬flag[j] ∨ turn = i. Collapses the + /// busy-wait spin into a single enabling guard (the algorithm's + /// semantics are insensitive to the number of guard evaluations + /// while waiting, so a self-loop is unnecessary). + /// + public sealed class EnterCSStep : PetersonStep + { + public EnterCSStep(int i) : base(i) { } + public override bool IsEnabled(PetersonState s) + { + var myPC = I == 0 ? s.PC0 : s.PC1; + if (myPC != PetersonPC.Wait) return false; + var otherFlag = I == 0 ? s.Flag1 : s.Flag0; + return !otherFlag || s.Turn == I; + } + public override PetersonState Apply(PetersonState s) + => WithPC(s, PetersonPC.CS); + } + + /// CS → Exit — leave the critical section. + public sealed class ExitCSStep : PetersonStep + { + public ExitCSStep(int i) : base(i) { } + public override bool IsEnabled(PetersonState s) + => (I == 0 ? s.PC0 : s.PC1) == PetersonPC.CS; + public override PetersonState Apply(PetersonState s) + => WithPC(s, PetersonPC.Exit); + } + + /// + /// Bug-injection variant of : drops the + /// Peterson guard ¬flag[j] ∨ turn = i entirely, admitting + /// → + /// unconditionally. Used by to expose + /// a mutual-exclusion counterexample. + /// + public sealed class BuggyEnterCSStep : PetersonStep + { + public BuggyEnterCSStep(int i) : base(i) { } + public override bool IsEnabled(PetersonState s) + => (I == 0 ? s.PC0 : s.PC1) == PetersonPC.Wait; + public override PetersonState Apply(PetersonState s) + => WithPC(s, PetersonPC.CS); + // Re-use the correct step's id so fairness-keyed analyses + // treat the buggy step as the same enabling family. + public override string StepFunctionId => "EnterCSStep_" + I; + } + + /// Exit → NCS — flag[i] := false. + public sealed class ResetFlagStep : PetersonStep + { + public ResetFlagStep(int i) : base(i) { } + public override bool IsEnabled(PetersonState s) + => (I == 0 ? s.PC0 : s.PC1) == PetersonPC.Exit; + public override PetersonState Apply(PetersonState s) + { + var next = WithPC(s, PetersonPC.NCS); + if (I == 0) next.Flag0 = false; else next.Flag1 = false; + return next; + } + } + } +} diff --git a/Samples/Peterson/Peterson.csproj b/Samples/Peterson/Peterson.csproj new file mode 100644 index 0000000..c498d2a --- /dev/null +++ b/Samples/Peterson/Peterson.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + latest + Library + + + + + + + + + + + + + + + + diff --git a/Samples/Peterson/PetersonModelCheckingTests.cs b/Samples/Peterson/PetersonModelCheckingTests.cs new file mode 100644 index 0000000..45d772e --- /dev/null +++ b/Samples/Peterson/PetersonModelCheckingTests.cs @@ -0,0 +1,158 @@ +namespace Peterson +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using NUnit.Framework; + + /// + /// Model checking of Peterson's two-process mutual-exclusion algorithm + /// using the Accordant RLTL model-checking infrastructure. + /// + public class PetersonModelCheckingTests + { + private StateGraphNode _root; + private Properties _p; + + // Temporal observations + private Observation _crit0; + private Observation _crit1; + private Observation _want0; + private Observation _want1; + + [SetUp] + public void Setup() + { + _root = StateGraph.ExploreStateGraph(Peterson.AllSteps(), Peterson.InitialState(), lazy: true); + + _p = new Properties(); + _crit0 = _p.Observe(s => s.PC0 == PetersonPC.CS, "Crit0"); + _crit1 = _p.Observe(s => s.PC1 == PetersonPC.CS, "Crit1"); + _want0 = _p.Observe(s => s.PC0 == PetersonPC.SetFlag + || s.PC0 == PetersonPC.SetTurn + || s.PC0 == PetersonPC.Wait, "Want0"); + _want1 = _p.Observe(s => s.PC1 == PetersonPC.SetFlag + || s.PC1 == PetersonPC.SetTurn + || s.PC1 == PetersonPC.Wait, "Want1"); + } + + // --- Safety ------------------------------------------------------- + + /// + /// Mutual exclusion: □¬(Crit0 ∧ Crit1). + /// + [Test] + public void Safety_MutualExclusion() + { + var mutex = _p.Always(!(_crit0 & _crit1)); + var result = _root.Check(mutex); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// With a buggy model, mutual exclusion fails. + /// + [Test] + public void Safety_MutualExclusion_FailsWithBug() + { + var buggyRoot = StateGraph.ExploreStateGraph( + Peterson.AllStepsBuggy(), Peterson.InitialState(), lazy: true); + var mutex = _p.Always(!(_crit0 & _crit1)); + var result = buggyRoot.Check(mutex); + Assert.IsFalse(result.Valid, + "Buggy model should violate mutual exclusion."); + } + + // --- Liveness under weak fairness --------------------------------- + + /// + /// Starvation freedom for process 0: □(Want0 → ◇Crit0). + /// + [Test] + public void Liveness_StarvationFreedom_Process0() + { + var phi = _p.LeadsTo(_want0, _crit0); + var result = _root.Check(phi, fairness: Fairness.WeakFairAll); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Starvation freedom for process 1: □(Want1 → ◇Crit1). + /// + [Test] + public void Liveness_StarvationFreedom_Process1() + { + var phi = _p.LeadsTo(_want1, _crit1); + var result = _root.Check(phi, fairness: Fairness.WeakFairAll); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Without fairness, starvation freedom fails. + /// + [Test] + public void Liveness_StarvationFreedom_FailsWithoutFairness() + { + var phi = _p.LeadsTo(_want0, _crit0); + var result = _root.Check(phi, fairness: Fairness.None); + Assert.IsFalse(result.Valid, + "Without fairness, an unfair cycle keeps process 0 starved."); + } + + /// + /// Infinitely often: □◇Crit0 ∧ □◇Crit1 under weak fairness. + /// + [Test] + public void Liveness_BothProcessesEnterCSInfinitelyOften() + { + var phi = _p.InfinitelyOften(_crit0) & _p.InfinitelyOften(_crit1); + var result = _root.Check(phi, fairness: Fairness.WeakFairAll); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + // --- RLTL regex properties ---------------------------------------- + + /// + /// Bounded overtaking via regex trigger: process 1 cannot enter CS + /// twice while process 0 is spinning without process 0 getting in. + /// + [Test] + public void Regex_BoundedOvertaking() + { + var inWait0 = _p.Observe(s => s.PC0 == PetersonPC.Wait, "InWait0"); + var inWait1 = _p.Observe(s => s.PC1 == PetersonPC.Wait, "InWait1"); + + // Forbidden pattern for process 0: + // Σ* · (InWait0 ∧ Crit1) · (InWait0 ∧ ¬Crit1)* · (InWait0 ∧ Crit1) + RegexPattern w0c1 = inWait0 & _crit1; + RegexPattern w0nc1 = inWait0 & !_crit1; + var bad0 = RegexPattern.Sigma.Star() + .Then(w0c1) + .Then(w0nc1.Star()) + .Then(w0c1); + + // Symmetric for process 1 + RegexPattern w1c0 = inWait1 & _crit0; + RegexPattern w1nc0 = inWait1 & !_crit0; + var bad1 = RegexPattern.Sigma.Star() + .Then(w1c0) + .Then(w1nc0.Star()) + .Then(w1c0); + + var phi = _p.Trigger(bad0, _p.False) & _p.Trigger(bad1, _p.False); + var result = _root.Check(phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Whenever process 0 is in CS, process 1 is not (via regex match). + /// + [Test] + public void Regex_WheneverCrit0_NotCrit1() + { + RegexPattern prefix = RegexPattern.Sigma.Star().Then(_crit0); + var phi = _p.Match(prefix, _p.Not(_crit1)); + var result = _root.Check(phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + } +} diff --git a/Samples/Peterson/PetersonState.cs b/Samples/Peterson/PetersonState.cs new file mode 100644 index 0000000..4bba9bf --- /dev/null +++ b/Samples/Peterson/PetersonState.cs @@ -0,0 +1,43 @@ +namespace Peterson +{ + using Microsoft.Accordant; + + /// + /// Program-counter values for one process in Peterson's two-process + /// mutual-exclusion algorithm. Each process repeats the sequence + /// NCS → SetFlag → SetTurn → Wait → CS → Exit → NCS … + /// + public enum PetersonPC + { + /// Non-critical section (idle). + NCS, + /// About to execute flag[i] := true. + SetFlag, + /// About to execute turn := 1-i. + SetTurn, + /// Busy-waiting: while flag[j] ∧ turn = j. + Wait, + /// In the critical section. + CS, + /// About to execute flag[i] := false. + Exit, + } + + /// + /// Global state for Peterson's algorithm with two processes (i = 0, 1). + /// + [State] + public partial class PetersonState + { + /// flag[0] — process 0 wants the critical section. + public bool Flag0 { get; set; } + /// flag[1] — process 1 wants the critical section. + public bool Flag1 { get; set; } + /// turn ∈ {0, 1} — shared turn variable. + public int Turn { get; set; } + /// Process 0's program counter. + public PetersonPC PC0 { get; set; } + /// Process 1's program counter. + public PetersonPC PC1 { get; set; } + } +} diff --git a/Samples/TerminationDetection/EWD998.cs b/Samples/TerminationDetection/EWD998.cs new file mode 100644 index 0000000..f658445 --- /dev/null +++ b/Samples/TerminationDetection/EWD998.cs @@ -0,0 +1,495 @@ +namespace TerminationDetection +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + public enum Color { Black, White } + + /// + /// This class models the termination detection protocol described in + /// EWD998 (https://www.cs.utexas.edu/users/EWD/ewd09xx/EWD998.PDF), + /// where the leader in a set of nodes detects whether + /// the distributed computation being conducted by the nodes has terminated. The problem is + /// made interesting as active nodes can send messages to passive nodes, this "waking" them + /// again. Message sends and receives are asynchronous, though the specification does assume + /// that sent messages are always received, even if with an arbitrary delay. + /// + /// The specification below is a transcription of the specification of this protocol in TLA+. + /// See the TLA+ spec at https://github.com/lemmy/ewd998/blob/main/EWD998.tla + /// in the repository at https://github.com/lemmy/ewd998. + /// + public class EWD998 + { + /// + /// This constant models the number of nodes in the system. Larger values + /// of N take a longer time to check. Smaller values are often sufficient + /// to find tricky bugs + /// (also known as the small scale hypothesis: + /// http://projects.csail.mit.edu/mulsaw/papers/SSH.ps) + /// + public const int N = 2; + + /// + /// This step passes the token from the leader (node at index 0) to the + /// last node (node at index N - 1). + /// + public class InitiateProbeStep : TLAStepFunction + { + public override string StepFunctionId => "InitiateProbe"; + + /// + /// This step is enabled if the token is at the leader + /// and it detects termination hasn't happened by inspecting + /// the token's state and it's own state. + /// + public override bool IsEnabled(SystemState systemState) + { + var token = systemState.Token; + var leaderNode = systemState.Nodes[0]; + + return + token.NodeIndex == 0 && + (token.Color == Color.Black || + leaderNode.Color == Color.Black || + leaderNode.Counter + token.Q > 0); + } + + /// + /// Transition the system to the next state where the token is at node + /// at index N-1. + /// + public override IList NextStates(SystemState systemState) + { + var nextState = (SystemState)systemState.Clone(); + + var nextToken = nextState.Token; + nextToken.NodeIndex = N - 1; + nextToken.Q = 0; + nextToken.Color = Color.White; + + nextState.Nodes[0].Color = Color.White; + + return new SystemState[] { nextState }; + } + } + + /// + /// This step passes the token to the node at index i-1 if this + /// node is not active anymore. + /// + public class PassTokenStep : TLAStepFunction + { + private int nodeIndex; + + public PassTokenStep(int nodeIndex) + { + this.nodeIndex = nodeIndex; + } + + public override string StepFunctionId => $"PassToken_{nodeIndex}"; + + /// + /// This step is enabled if the token is at this node and it is not active. + /// + /// + /// + public override bool IsEnabled(SystemState systemState) + { + var token = systemState.Token; + var node = systemState.Nodes[nodeIndex]; + + return + !node.Active && + token.NodeIndex == nodeIndex; + } + + /// + /// Pass the token to the node at index i-1, updating the state of the + /// token based on this node's state. + /// + public override IList NextStates(SystemState systemState) + { + var nextState = (SystemState)systemState.Clone(); + + var node = systemState.Nodes[nodeIndex]; + + var nextToken = nextState.Token; + nextToken.NodeIndex -= 1; + nextToken.Q += node.Counter; + nextToken.Color = node.Color == Color.Black ? Color.Black : node.Color; + + nextState.Nodes[nodeIndex].Color = Color.White; + + return new SystemState[] { nextState }; + } + } + + /// + /// This step sends a message to another node, whether it be active or passive. + /// If a passive node receives a message, it gets active again. + /// + public class SendMessageStep : TLAStepFunction + { + private int nodeIndex; + + public SendMessageStep(int nodeIndex) + { + this.nodeIndex = nodeIndex; + } + + public override string StepFunctionId => $"SendMessage_{nodeIndex}"; + + /// + /// This step is enabled if the node is active. + /// + public override bool IsEnabled(SystemState systemState) + { + return + systemState.Nodes[nodeIndex].Active; + } + + /// + /// A node can send a message to any other node, except itself. + /// We need to explore all possibilities where a message can be sent + /// to any node. This step therefore produces N-1 next states, where + /// a message is sent to a different node in each possible future. + /// + public override IList NextStates(SystemState systemState) + { + var nextStates = new List(); + + for (int i = 0; i < N; i++) + { + if (i == nodeIndex) + { + continue; + } + + var nextState = (SystemState)systemState.Clone(); + + var nextThisNode = nextState.Nodes[nodeIndex]; + nextThisNode.Counter += 1; + + var receivingNode = nextState.Nodes[i]; + receivingNode.Pending += 1; + + nextStates.Add(nextState); + } + + return nextStates; + } + } + + /// + /// This steps receives a message sent from another node. + /// Message send and receive are async processes which is why the + /// reception of the message is modeled as a separate step from + /// sending the message. + /// + public class ReceiveMessageStep : TLAStepFunction + { + private int nodeIndex; + + public ReceiveMessageStep(int nodeIndex) + { + this.nodeIndex = nodeIndex; + } + + public override string StepFunctionId => $"ReceiveMessage_{nodeIndex}"; + + /// + /// This step is enabled if it's pending counter is greater than + /// zero. The pending count indicates "messages in flight" destined + /// for this node. A node need not be active to receive a message, + /// but does become active when the message is received. + /// + public override bool IsEnabled(SystemState systemState) + { + return systemState.Nodes[nodeIndex].Pending > 0; + } + + /// + /// Transitions to a state in which the message has been received. + /// The reception of the message marks the node as active. + /// + /// + /// + public override IList NextStates(SystemState systemState) + { + var nextState = (SystemState)systemState.Clone(); + + var nextNode = nextState.Nodes[nodeIndex]; + nextNode.Active = true; + nextNode.Pending -= 1; + nextNode.Counter -= 1; + nextNode.Color = Color.Black; + + return new SystemState[] { nextState }; + } + } + + /// + /// This step deactivates a single node — the one identified by + /// . Splitting the deactivation per node + /// (rather than emitting all 2^M active subsets in one global + /// step) lets fairness predicates target a specific node. In + /// particular, strong fairness on every + /// DeactivateStep(i) forces an active node to eventually + /// go passive, which is required to express token-traversal + /// liveness (□◇ TokenAtLeader). + /// + public class DeactivateStep : TLAStepFunction + { + public int NodeIndex { get; } + + public DeactivateStep(int nodeIndex) + { + NodeIndex = nodeIndex; + } + + public override string StepFunctionId => $"Deactivate_{NodeIndex}"; + + /// + /// Enabled iff the targeted node is currently active. + /// + public override bool IsEnabled(SystemState systemState) + { + return systemState.Nodes[NodeIndex].Active; + } + + /// + /// Single next-state: clone and mark this one node passive. + /// All 2^M "subset of nodes deactivate" combinations are + /// reachable via sequential applications of per-node steps, + /// so the reachable state space is preserved. + /// + public override IList NextStates(SystemState systemState) + { + var nextState = (SystemState)systemState.Clone(); + nextState.Nodes[NodeIndex].Active = false; + return new SystemState[] { nextState }; + } + } + + /// + /// This property encodes all the nodes in the system as having + /// terminated (i.e. none of the nodes in the system are active + /// and there are no messages in flight) + /// + public static bool HasSystemTerminated(IState state) + { + var systemState = (SystemState)state; + return systemState.Nodes.All(n => !n.Active && n.Pending == 0); + } + + /// + /// This property encodes the leader "detecting" whether the system + /// has terminated. The leader can leverage the token's state (when the + /// token is at the leader) and its own state to make this determination. + /// + public static bool TerminationDetected(IState state) + { + var systemState = (SystemState)state; + + var token = systemState.Token; + var leaderNode = systemState.Nodes[0]; + + return token.NodeIndex == 0 && + token.Color == Color.White && + (token.Q + leaderNode.Counter == 0) && + leaderNode.Color == Color.White && + !leaderNode.Active; + } + + /// + /// Returns the canonical list of step functions exercised by the + /// EWD998 model. Mirrors the inline setup used by + /// . + /// + public static IList AllSteps() => BuildSteps(buggy: false); + + /// + /// Bug-injection variant: replaces every + /// with a that forgets to + /// mark the receiving node black. The token can then traverse a + /// node that just received a message without picking up its + /// taint, and the leader can spuriously decide that termination + /// has occurred mid-conversation. + /// + public static IList AllStepsBuggy() => BuildSteps(buggy: true); + + private static IList BuildSteps(bool buggy) + { + var steps = new List(); + steps.Add(new InitiateProbeStep()); + for (int i = 0; i < N; i++) + { + if (i != 0) + steps.Add(new PassTokenStep(i)); + steps.Add(new SendMessageStep(i)); + steps.Add(buggy ? (TLAStepFunction)new BuggyReceiveMessageStep(i) : new ReceiveMessageStep(i)); + steps.Add(new DeactivateStep(i)); + } + return steps; + } + + /// + /// Returns the canonical initial system state: leader holds a + /// black token at Q=0; every node is active, white, with + /// counter 0 and no pending messages. + /// + public static SystemState InitialState() + { + var token = new TokenState { NodeIndex = 0, Q = 0, Color = Color.Black }; + var nodes = new List(); + for (int i = 0; i < N; i++) + nodes.Add(new NodeState { Active = true, Pending = 0, Color = Color.White, Counter = 0 }); + return new SystemState { Nodes = nodes, Token = token }; + } + + /// + /// Bug-injection variant of : bumps + /// the receiver's / counter / pending + /// state as usual but forgets to set its + /// to . + /// A token round that passes this node after the receive but + /// before any further send therefore picks up no taint, and the + /// leader can spuriously declare termination. + /// + public class BuggyReceiveMessageStep : TLAStepFunction + { + private readonly int nodeIndex; + public BuggyReceiveMessageStep(int nodeIndex) { this.nodeIndex = nodeIndex; } + public override string StepFunctionId => $"ReceiveMessage_{nodeIndex}"; + public override bool IsEnabled(SystemState systemState) + => systemState.Nodes[nodeIndex].Pending > 0; + public override IList NextStates(SystemState systemState) + { + var nextState = (SystemState)systemState.Clone(); + var nextNode = nextState.Nodes[nodeIndex]; + nextNode.Active = true; + nextNode.Pending -= 1; + nextNode.Counter -= 1; + // BUG: forget to set Color = Black. + return new SystemState[] { nextState }; + } + } + } + + public class Tests + { + [Test] + public static void TerminationDetectionModelChecking() + { + // + // 1. Instantiate steps. + // + + // We'll start the simulation by declaring steps that + // can be taken. + var steps = new List(); + + // Step through which the leader starts a token passing + // round. + steps.Add(new EWD998.InitiateProbeStep()); + + // A pass token step for each node, but the leader, + // and send and receive steps for each node. + for (int i = 0; i < EWD998.N; i++) + { + if (i != 0) + { + steps.Add(new EWD998.PassTokenStep(i)); + } + + steps.Add(new EWD998.SendMessageStep(i)); + steps.Add(new EWD998.ReceiveMessageStep(i)); + } + + // Step which de-actives nodes — one instance per node. + for (int i = 0; i < EWD998.N; i++) + { + steps.Add(new EWD998.DeactivateStep(i)); + } + + // + // 2. Define initial state of the system. + // + + // The token starts out at leader. + var token = new TokenState() + { + NodeIndex = 0, + Q = 0, + Color = Color.Black + }; + + // Each node starts out in active state. + var nodes = new List(); + for (int i = 0; i < EWD998.N; i++) + { + nodes.Add(new NodeState() + { + Active = true, + Pending = 0, + Color = Color.White, + Counter = 0 + }); + } + + // The full system state comprising of node states + // and token state. + var initialState = new SystemState() + { + Nodes = nodes, + Token = token + }; + + // + // 3. Explore all possible evolutions of the system. + // The system can evolve infinitely so we bound it + // by bounding the number of message sends and receives. + // + var rootNode = StateGraph.ExploreStateGraph( + steps, + initialState, + stateConstraint: (s) => + { + var systemState = (SystemState)s; + + return + systemState.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && + systemState.Token.Q < 3; + + }); + + // + // 4. Check safety and liveness properties after the simulation. + // + + // Safety condition: It's always true that when the leader detects termination, + // the system is in fact in a terminated state. + var safetyFormula = LtlFormula.Always( + LtlFormula.Implies( + LtlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"), + LtlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"))); + var safetyResult = LtlCheck.Check(rootNode, safetyFormula); + + Assert.IsTrue(safetyResult.Valid, safetyResult.GetTraceString()); + + // Liveness condition: When the system reaches a terminated state, + // the leader _eventually_ detects termination. + var livenessFormula = LtlFormula.LeadsTo( + LtlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"), + LtlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected")); + var livenessResult = LtlCheck.Check(rootNode, livenessFormula); + + Assert.IsTrue(livenessResult.Valid, livenessResult.GetTraceString()); + } + } +} diff --git a/Samples/TerminationDetection/EWD998AdditionalLtlTests.cs b/Samples/TerminationDetection/EWD998AdditionalLtlTests.cs new file mode 100644 index 0000000..423a4fd --- /dev/null +++ b/Samples/TerminationDetection/EWD998AdditionalLtlTests.cs @@ -0,0 +1,151 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// Additional EWD998 properties: stability (absorbing) of termination + /// and of termination-detection, structural well-formedness of the + /// token, and a counter-non-negativity invariant. + /// + public class EWD998AdditionalLtlTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() + { + var steps = new List(); + steps.Add(new EWD998.InitiateProbeStep()); + for (int i = 0; i < EWD998.N; i++) + { + if (i != 0) steps.Add(new EWD998.PassTokenStep(i)); + steps.Add(new EWD998.SendMessageStep(i)); + steps.Add(new EWD998.ReceiveMessageStep(i)); + steps.Add(new EWD998.DeactivateStep(i)); + } + + var token = new TokenState { NodeIndex = 0, Q = 0, Color = Color.Black }; + var nodes = new List(); + for (int i = 0; i < EWD998.N; i++) + nodes.Add(new NodeState { Active = true, Pending = 0, Color = Color.White, Counter = 0 }); + var initial = new SystemState { Nodes = nodes, Token = token }; + + _root = StateGraph.ExploreStateGraph(steps, initial, + stateConstraint: s => + { + var ss = (SystemState)s; + return ss.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && ss.Token.Q < 3; + }, lazy: true); + } + + // --- Atoms -------------------------------------------------------- + + private static LtlFormula Detected => + LtlFormula.Prop(EWD998.TerminationDetected, "Detected"); + private static LtlFormula Terminated => + LtlFormula.Prop(EWD998.HasSystemTerminated, "Terminated"); + + // --- Stability (absorbing-state) properties ---------------------- + + /// + /// Termination is absorbing: once every node is inactive and no + /// messages are pending, no step can re-activate the system, + /// so HasSystemTerminated stays true forever. + /// □(Terminated → □Terminated). + /// + [Test] + public void Safety_TerminationIsAbsorbing() + { + var phi = LtlFormula.Always(LtlFormula.Implies(Terminated, LtlFormula.Always(Terminated))); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + /// + /// Detection is absorbing: once the leader has detected + /// termination, the system stays in that detected state. + /// □(Detected → □Detected). Stronger than the existing + /// detection-implies-termination check — pins that no step can + /// "un-detect" termination. + /// + [Test] + public void Safety_DetectionIsAbsorbing() + { + var phi = LtlFormula.Always(LtlFormula.Implies(Detected, LtlFormula.Always(Detected))); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + // --- Structural invariants --------------------------------------- + + /// + /// The token always lives at a valid node index. + /// □(0 ≤ token.NodeIndex < N). Trivially true today; + /// surfaces immediately if a future refactor of + /// mis-handles the modular + /// arithmetic at the leader boundary. + /// + [Test] + public void Safety_TokenIndexInBounds() + { + var phi = LtlFormula.Always(LtlFormula.Prop(s => + { + var ss = (SystemState)s; + return ss.Token.NodeIndex >= 0 && ss.Token.NodeIndex < EWD998.N; + }, "TokenIndexInBounds")); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + /// + /// Per-node pending-message count is non-negative. + /// □(∀i. node[i].Pending ≥ 0). The Q counter on the token + /// is allowed to go negative by design (it accumulates negative + /// contributions from receives that happen *before* the + /// corresponding send is observed by the token), so we only + /// pin the per-node Pending field here. + /// + [Test] + public void Safety_PendingNonNegative() + { + var phi = LtlFormula.Always(LtlFormula.Prop(s => + { + var ss = (SystemState)s; + return ss.Nodes.All(n => n.Pending >= 0); + }, "PendingNonNegative")); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + + // --- Conservation between detection and structural termination --- + + /// + /// Stronger than the existing detection→termination check: + /// detection requires not just "some terminated state has been + /// reached" but specifically the strict structural conjunction + /// (token at leader, token white, leader white & inactive, + /// Q+counter sum 0). This redundantly pins each conjunct. + /// + [Test] + public void Safety_DetectionImpliesStructuralFingerprint() + { + var phi = LtlFormula.Always(LtlFormula.Implies(Detected, + LtlFormula.Prop(s => + { + var ss = (SystemState)s; + return ss.Token.NodeIndex == 0 + && ss.Token.Color == Color.White + && ss.Nodes[0].Color == Color.White + && !ss.Nodes[0].Active + && ss.Token.Q + ss.Nodes[0].Counter == 0; + }, "DetectionFingerprint"))); + var r = LtlCheck.Check(_root, phi); + Assert.IsTrue(r.Valid, r.GetTraceString()); + } + } +} diff --git a/Samples/TerminationDetection/EWD998BugDemoTests.cs b/Samples/TerminationDetection/EWD998BugDemoTests.cs new file mode 100644 index 0000000..41316dc --- /dev/null +++ b/Samples/TerminationDetection/EWD998BugDemoTests.cs @@ -0,0 +1,72 @@ +namespace TerminationDetection +{ + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// Bug-injection demonstration for EWD998 termination detection: + /// replaces every with + /// (forgets to set the + /// receiver's colour black). A token round that traverses the node + /// after the receive but before the next send picks up no taint, + /// so the leader can declare termination prematurely. The + /// classical safety property + /// TerminationDetected → HasSystemTerminated then fails. + /// + public class EWD998BugDemoTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() => + _root = StateGraph.ExploreStateGraph( + EWD998.AllStepsBuggy(), + EWD998.InitialState(), + stateConstraint: s => + { + var ss = (SystemState)s; + return ss.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && ss.Token.Q < 3; + }, lazy: true); + + /// + /// LTL safety: the leader should only ever detect termination + /// when the system has actually terminated. The buggy + /// pass-token step admits a state where the leader believes + /// termination while a passive node still has a pending + /// message (i.e. an in-flight wakeup). + /// + [Test] + public void Bug_LtlDetectImpliesTerminated_Fails() + { + var detected = LtlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"); + var terminated = LtlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + var phi = LtlFormula.Always(LtlFormula.Implies(detected, terminated)); + var r = LtlCheck.Check(_root, phi); + Assert.IsFalse(r.Valid, "Buggy pass-token should let the leader detect termination prematurely."); + Assert.That(r.GetTraceString(), Is.Not.Null.And.Not.Empty); + } + + /// + /// RLTL forbidden-prefix form: + /// Σ* · (TerminationDetected ∧ ¬HasSystemTerminated) must + /// match in the buggy state graph. + /// + [Test] + public void Bug_RltlForbiddenPrematureDetection_Matches() + { + var sigmaStar = Regex.Star(Regex.Sigma); + var bad = Regex.Concat(sigmaStar, + Regex.Intersect( + Regex.Prop(EWD998.TerminationDetected, "TerminationDetected"), + Regex.Prop(s => !EWD998.HasSystemTerminated(s), "¬HasSystemTerminated"))); + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + var r = RltlCheck.Check(_root, phi); + Assert.IsFalse(r.Valid, "Bad prefix is reachable in the buggy model."); + Assert.That(r.GetTraceString(), Is.Not.Null.And.Not.Empty); + } + } +} diff --git a/Samples/TerminationDetection/EWD998CrossCheckTests.cs b/Samples/TerminationDetection/EWD998CrossCheckTests.cs new file mode 100644 index 0000000..fa91f03 --- /dev/null +++ b/Samples/TerminationDetection/EWD998CrossCheckTests.cs @@ -0,0 +1,98 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Testing; + using NUnit.Framework; + + /// + /// Cross-check harness: every LTL-expressible property tested in + /// is lifted to RLTL via + /// and re-checked. + /// + public class EWD998CrossCheckTests + { + private StateGraphNode _rootNode; + + [SetUp] + public void Setup() + { + var steps = new List(); + steps.Add(new EWD998.InitiateProbeStep()); + for (int i = 0; i < EWD998.N; i++) + { + if (i != 0) steps.Add(new EWD998.PassTokenStep(i)); + steps.Add(new EWD998.SendMessageStep(i)); + steps.Add(new EWD998.ReceiveMessageStep(i)); + steps.Add(new EWD998.DeactivateStep(i)); + } + + var token = new TokenState { NodeIndex = 0, Q = 0, Color = Color.Black }; + var nodes = new List(); + for (int i = 0; i < EWD998.N; i++) + nodes.Add(new NodeState { Active = true, Pending = 0, Color = Color.White, Counter = 0 }); + var initial = new SystemState { Nodes = nodes, Token = token }; + + _rootNode = StateGraph.ExploreStateGraph( + steps, + initial, + stateConstraint: s => + { + var ss = (SystemState)s; + return ss.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && ss.Token.Q < 3; + }, lazy: true); + } + + private static LtlFormula Detected => LtlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"); + private static LtlFormula Terminated => LtlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + + [Test] + public void Safety_DetectedImpliesTerminated_CrossCheck() => + LtlRltlCrossCheck.Run( + _rootNode, + LtlFormula.Always(LtlFormula.Implies(Detected, Terminated)), + fairness: Fairness.None, + label: nameof(Safety_DetectedImpliesTerminated_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Liveness_TerminatedLeadsToDetected_CrossCheck() => + LtlRltlCrossCheck.Run( + _rootNode, + LtlFormula.LeadsTo(Terminated, Detected), + fairness: Fairness.WeakFairAll, + label: nameof(Liveness_TerminatedLeadsToDetected_CrossCheck) + ).ThrowIfDisagree(); + + [Test] + public void Combined_SafetyAndLiveness_CrossCheck() => + LtlRltlCrossCheck.Run( + _rootNode, + LtlFormula.Always(LtlFormula.Implies(Detected, Terminated)) + & LtlFormula.LeadsTo(Terminated, Detected), + fairness: Fairness.WeakFairAll, + label: nameof(Combined_SafetyAndLiveness_CrossCheck) + ).ThrowIfDisagree(); + + /// + /// Token-traversal under : should fail in + /// both checkers because no per-node deactivation can be forced + /// (see ). + /// + [Test] + public void InfinitelyOften_TokenReturnsToLeader_FailsWithoutFairness_CrossCheck() + { + var tokenAtLeader = LtlFormula.Prop( + s => ((SystemState)s).Token.NodeIndex == 0, "TokenAtLeader"); + LtlRltlCrossCheck.Run( + _rootNode, + LtlFormula.InfinitelyOften(tokenAtLeader), + fairness: Fairness.None, + label: nameof(InfinitelyOften_TokenReturnsToLeader_FailsWithoutFairness_CrossCheck) + ).ThrowIfDisagree(); + } + } +} diff --git a/Samples/TerminationDetection/EWD998LtlTests.cs b/Samples/TerminationDetection/EWD998LtlTests.cs new file mode 100644 index 0000000..a638f26 --- /dev/null +++ b/Samples/TerminationDetection/EWD998LtlTests.cs @@ -0,0 +1,324 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// Tests demonstrating the use of LTL (Linear Temporal Logic) formulas + /// for model checking the EWD998 termination detection protocol. + /// + /// These tests show equivalent properties to the ones in + /// but expressed using the full LTL formula language. + /// + public class EWD998LtlTests + { + private StateGraphNode _rootNode; + + [SetUp] + public void Setup() + { + // Build the state graph (same setup as the main test) + var steps = new List(); + + steps.Add(new EWD998.InitiateProbeStep()); + + for (int i = 0; i < EWD998.N; i++) + { + if (i != 0) + { + steps.Add(new EWD998.PassTokenStep(i)); + } + + steps.Add(new EWD998.SendMessageStep(i)); + steps.Add(new EWD998.ReceiveMessageStep(i)); + steps.Add(new EWD998.DeactivateStep(i)); + } + + var token = new TokenState() + { + NodeIndex = 0, + Q = 0, + Color = Color.Black + }; + + var nodes = new List(); + for (int i = 0; i < EWD998.N; i++) + { + nodes.Add(new NodeState() + { + Active = true, + Pending = 0, + Color = Color.White, + Counter = 0 + }); + } + + var initialState = new SystemState() + { + Nodes = nodes, + Token = token + }; + + _rootNode = StateGraph.ExploreStateGraph( + steps, + initialState, + stateConstraint: (s) => + { + var systemState = (SystemState)s; + return + systemState.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && + systemState.Token.Q < 3; + }, lazy: true); + } + + /// + /// Safety property using LTL: □(TerminationDetected → HasSystemTerminated) + /// + /// "Always, if termination is detected, then the system has actually terminated" + /// This ensures no false positives in termination detection. + /// + [Test] + public void Safety_WhenTerminationDetected_SystemMustBeTerminated() + { + // Define atomic propositions + var detected = LtlFormula.Prop( + EWD998.TerminationDetected, + "TerminationDetected"); + + var terminated = LtlFormula.Prop( + EWD998.HasSystemTerminated, + "HasSystemTerminated"); + + // Safety: □(detected → terminated) + // Equivalent to: Always(Not(detected) Or terminated) + var safetyFormula = LtlFormula.Always( + LtlFormula.Implies(detected, terminated)); + + var result = LtlCheck.Check(_rootNode, safetyFormula); + + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Liveness property using LTL: □(HasSystemTerminated → ◇TerminationDetected) + /// + /// "Always, if the system has terminated, then termination will eventually be detected" + /// This is the leads-to property expressed in LTL. + /// + [Test] + public void Liveness_SystemTermination_LeadsTo_TerminationDetected() + { + var terminated = LtlFormula.Prop( + EWD998.HasSystemTerminated, + "HasSystemTerminated"); + + var detected = LtlFormula.Prop( + EWD998.TerminationDetected, + "TerminationDetected"); + + // Liveness: terminated ~> detected + // Which is: □(terminated → ◇detected) + var livenessFormula = LtlFormula.LeadsTo(terminated, detected); + + var result = LtlCheck.Check(_rootNode, livenessFormula); + + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Combined property: Safety AND Liveness + /// + /// Demonstrates combining multiple LTL formulas into one check. + /// + [Test] + public void Combined_SafetyAndLiveness() + { + var detected = LtlFormula.Prop( + EWD998.TerminationDetected, + "TerminationDetected"); + + var terminated = LtlFormula.Prop( + EWD998.HasSystemTerminated, + "HasSystemTerminated"); + + // Safety: detected → terminated (always) + var safety = LtlFormula.Always( + LtlFormula.Implies(detected, terminated)); + + // Liveness: terminated ~> detected + var liveness = LtlFormula.LeadsTo(terminated, detected); + + // Combined: safety ∧ liveness + var combined = safety & liveness; + + var result = LtlCheck.Check(_rootNode, combined); + + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Infinitely Often property: □◇(token at leader). + /// + /// With the per-node refactor + /// in place, strong fairness on every DeactivateStep + /// instance forces an active node to eventually go passive + /// (because DeactivateStep(i) is enabled at every state + /// where node i is active). Combined with strong fairness + /// on every the token must + /// keep migrating toward the leader, so □◇ TokenAtLeader + /// holds. + /// + /// This is the canonical example where per-instance fairness + /// matters: a coarser Fairness.WeakFairAll over a global + /// no-op-bearing DeactivateStep could not have produced + /// this verdict. + /// + [Test] + public void InfinitelyOften_TokenReturnsToLeader_UnderPerNodeFairness() + { + var tokenAtLeader = LtlFormula.Prop( + state => ((SystemState)state).Token.NodeIndex == 0, + "TokenAtLeader"); + + var phi = LtlFormula.InfinitelyOften(tokenAtLeader); + + var fairness = Fairness.StrongFair(sf => + sf is EWD998.DeactivateStep || + sf is EWD998.PassTokenStep || + sf is EWD998.InitiateProbeStep); + + var result = LtlCheck.Check(_rootNode, phi, fairness: fairness); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Infinitely Often property: □◇(token at leader). + /// + /// Under the model still admits a + /// cycle in which an active node parks the token away from the + /// leader forever (e.g., node 1 stays active because + /// for node 1 is never + /// taken). Asserts the LTL checker correctly returns that + /// counterexample — companion to the passing + /// . + /// + [Test] + public void InfinitelyOften_TokenReturnsToLeader_FailsWithoutPerNodeDeactivationFairness() + { + var tokenAtLeader = LtlFormula.Prop( + state => ((SystemState)state).Token.NodeIndex == 0, + "TokenAtLeader"); + + var infinitelyOftenFormula = LtlFormula.InfinitelyOften(tokenAtLeader); + + var result = LtlCheck.Check(_rootNode, infinitelyOftenFormula, fairness: Fairness.None); + + Assert.IsFalse(result.Valid, + "Without per-node deactivation fairness the model admits a " + + "cycle in which an active node parks the token away from the leader forever."); + } + + /// + /// Positive InfinitelyOften smoke test: □◇True trivially holds. + /// Ensures the combinator and checker pipeline work end-to-end on a + /// satisfied formula even when the previous behavioural test has + /// been converted to a counterexample assertion. + /// + [Test] + public void InfinitelyOften_Trivial_Holds() + { + var phi = LtlFormula.InfinitelyOften(LtlFormula.True); + var result = LtlCheck.Check(_rootNode, phi); + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Eventually property: ◇(terminated ∨ detected) + /// + /// "Eventually, either the system terminates or termination is detected" + /// + /// This property does NOT hold because the system allows infinite non-terminating runs + /// where active nodes keep sending messages forever. The LTL checker correctly finds + /// a counterexample cycle. + /// + [Test] + public void Eventually_TerminatedOrDetected_Fails_WithInfiniteRuns() + { + var terminated = LtlFormula.Prop( + EWD998.HasSystemTerminated, + "HasSystemTerminated"); + + var detected = LtlFormula.Prop( + EWD998.TerminationDetected, + "TerminationDetected"); + + // ◇(terminated ∨ detected) + var eventuallyFormula = LtlFormula.Eventually(terminated | detected); + + var result = LtlCheck.Check(_rootNode, eventuallyFormula); + + // This FAILS because the system can loop forever without terminating. + // Active nodes can keep sending messages indefinitely. + Assert.IsFalse(result.Valid, "Expected failure due to infinite non-terminating runs"); + } + + /// + /// Until property: ¬detected U terminated + /// + /// "Termination is not detected until the system has actually terminated" + /// (Slightly stronger than the implication safety property) + /// + [Test] + public void Until_NotDetectedUntilTerminated() + { + var terminated = LtlFormula.Prop( + EWD998.HasSystemTerminated, + "HasSystemTerminated"); + + var detected = LtlFormula.Prop( + EWD998.TerminationDetected, + "TerminationDetected"); + + // This says: either we never detect termination, + // or the system terminates before (or when) we detect it + // ¬detected U terminated, but we need to allow never detecting too + // So: □(detected → terminated) is more appropriate + + // Alternative interpretation: detected can only happen after/during terminated + // Let's use: ◇detected → (¬detected U terminated) + var neverDetectedOrTerminatesFirst = LtlFormula.Implies( + LtlFormula.Eventually(detected), + LtlFormula.Until(!detected, terminated)); + + // Actually, the safety property □(detected → terminated) is cleaner + // but this demonstrates the Until operator + var safetyVariant = LtlFormula.Always( + LtlFormula.Implies(detected, terminated)); + + var result = LtlCheck.Check(_rootNode, safetyVariant); + + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + + /// + /// Demonstrates using operator overloads for a fluent API. + /// + [Test] + public void FluentApi_OperatorOverloads() + { + var p = LtlFormula.Prop(EWD998.HasSystemTerminated, "terminated"); + var q = LtlFormula.Prop(EWD998.TerminationDetected, "detected"); + + // Using operator overloads: & for And, | for Or, ! for Not + var formula = LtlFormula.Always(!q | p); // □(¬q ∨ p) = □(q → p) + + var result = LtlCheck.Check(_rootNode, formula); + + Assert.IsTrue(result.Valid, result.GetTraceString()); + } + } +} diff --git a/Samples/TerminationDetection/EWD998OracleSweepTests.cs b/Samples/TerminationDetection/EWD998OracleSweepTests.cs new file mode 100644 index 0000000..fa640e8 --- /dev/null +++ b/Samples/TerminationDetection/EWD998OracleSweepTests.cs @@ -0,0 +1,138 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Testing; + using NUnit.Framework; + + /// + /// Drives every LTL-expressible EWD998 property through the + /// four-backend differential oracle under several fairness + /// configurations. Any disagreement is a bug. + /// + public class EWD998OracleSweepTests + { + private StateGraphNode _root; + + [SetUp] + public void Setup() + { + var steps = new List(); + steps.Add(new EWD998.InitiateProbeStep()); + for (int i = 0; i < EWD998.N; i++) + { + if (i != 0) steps.Add(new EWD998.PassTokenStep(i)); + steps.Add(new EWD998.SendMessageStep(i)); + steps.Add(new EWD998.ReceiveMessageStep(i)); + steps.Add(new EWD998.DeactivateStep(i)); + } + var token = new TokenState { NodeIndex = 0, Q = 0, Color = Color.Black }; + var nodes = new List(); + for (int i = 0; i < EWD998.N; i++) + nodes.Add(new NodeState { Active = true, Pending = 0, Color = Color.White, Counter = 0 }); + + _root = StateGraph.ExploreStateGraph( + steps, + new SystemState { Nodes = nodes, Token = token }, + stateConstraint: (s) => + { + var st = (SystemState)s; + return st.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && st.Token.Q < 3; + }, lazy: true); + } + + private static LtlFormula Detected => LtlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"); + private static LtlFormula Terminated => LtlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + private static LtlFormula TokenAtLeader => + LtlFormula.Prop(state => ((SystemState)state).Token.NodeIndex == 0, "TokenAtLeader"); + + private static readonly Fairness PerNodeFairness = Fairness.StrongFair(sf => + sf is EWD998.DeactivateStep || sf is EWD998.PassTokenStep || sf is EWD998.InitiateProbeStep); + + // --- Safety ---------------------------------------------------- + + [Test] + public void Oracle_Safety_DetectedImpliesTerminated_NoFairness() + { + var phi = LtlFormula.Always(LtlFormula.Implies(Detected, Terminated)); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.None, nameof(Oracle_Safety_DetectedImpliesTerminated_NoFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_Safety_DetectedImpliesTerminated_WeakFairAll() + { + var phi = LtlFormula.Always(LtlFormula.Implies(Detected, Terminated)); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.WeakFairAll, nameof(Oracle_Safety_DetectedImpliesTerminated_WeakFairAll)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + // --- Liveness: terminated → detected -------------------------- + + [Test] + public void Oracle_Liveness_TerminatedLeadsToDetected_NoFairness() + { + var phi = LtlFormula.LeadsTo(Terminated, Detected); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.None, nameof(Oracle_Liveness_TerminatedLeadsToDetected_NoFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + // --- Eventually-terminated fails ------------------------------ + + [Test] + public void Oracle_Eventually_TerminatedOrDetected_FailsNoFairness() + { + var phi = LtlFormula.Eventually(Terminated | Detected); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.None, nameof(Oracle_Eventually_TerminatedOrDetected_FailsNoFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.False); + } + + // --- Infinitely often token at leader (per-node fairness) ---- + + [Test] + public void Oracle_InfinitelyOften_TokenAtLeader_PerNodeFairness() + { + var phi = LtlFormula.InfinitelyOften(TokenAtLeader); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, PerNodeFairness, nameof(Oracle_InfinitelyOften_TokenAtLeader_PerNodeFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + [Test] + public void Oracle_InfinitelyOften_TokenAtLeader_NoFairness_Fails() + { + var phi = LtlFormula.InfinitelyOften(TokenAtLeader); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.None, nameof(Oracle_InfinitelyOften_TokenAtLeader_NoFairness_Fails)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.False); + } + + // --- Until smoke test ---------------------------------------- + + [Test] + public void Oracle_Until_NotDetectedUntilTerminated_NoFairness() + { + var phi = LtlFormula.Always(LtlFormula.Implies(Detected, Terminated)); + var r = LtlMultiBackendCrossCheck.Run(_root, phi, Fairness.None, nameof(Oracle_Until_NotDetectedUntilTerminated_NoFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + + // --- Trivially true smoke ------------------------------------ + + [Test] + public void Oracle_True_NoFairness() + { + var r = LtlMultiBackendCrossCheck.Run(_root, LtlFormula.True, Fairness.None, nameof(Oracle_True_NoFairness)); + r.ThrowIfDisagree(); + Assert.That(r.Verdicts[0].Result.Valid, Is.True); + } + } +} diff --git a/Samples/TerminationDetection/EWD998RltlShowcaseTests.cs b/Samples/TerminationDetection/EWD998RltlShowcaseTests.cs new file mode 100644 index 0000000..1eedb62 --- /dev/null +++ b/Samples/TerminationDetection/EWD998RltlShowcaseTests.cs @@ -0,0 +1,98 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// RLTL regex-flavoured showcase for EWD998: complements + /// with properties that lean on the + /// regex DSL (intersection, complement, fusion, bounded counts). + /// + public class EWD998RltlShowcaseTests + { + private StateGraphNode _rootNode; + + [SetUp] + public void Setup() + { + var steps = new List(); + steps.Add(new EWD998.InitiateProbeStep()); + for (int i = 0; i < EWD998.N; i++) + { + if (i != 0) steps.Add(new EWD998.PassTokenStep(i)); + steps.Add(new EWD998.SendMessageStep(i)); + steps.Add(new EWD998.ReceiveMessageStep(i)); + steps.Add(new EWD998.DeactivateStep(i)); + } + + var token = new TokenState { NodeIndex = 0, Q = 0, Color = Color.Black }; + var nodes = new List(); + for (int i = 0; i < EWD998.N; i++) + nodes.Add(new NodeState { Active = true, Pending = 0, Color = Color.White, Counter = 0 }); + var initial = new SystemState { Nodes = nodes, Token = token }; + + _rootNode = StateGraph.ExploreStateGraph( + steps, + initial, + stateConstraint: s => + { + var ss = (SystemState)s; + return ss.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && ss.Token.Q < 3; + }, lazy: true); + } + + private static Regex SigmaStar => Regex.Star(Regex.Sigma); + private static Regex RDetected => Regex.Prop(EWD998.TerminationDetected, "TerminationDetected"); + private static Regex RTerminated => Regex.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + + /// + /// Safety via forbidden-prefix: a run prefix that ends in a state + /// where termination is detected without the system being + /// terminated must never match. Phrased as + /// R = Σ* · (TerminationDetected ∧ ¬HasSystemTerminated) and + /// asserted via Trigger(R, False). Equivalent in extension + /// to the LTL safety □(detected → terminated), but the + /// regex form makes the bad witness explicit as a shape. + /// + [Test] + public void Safety_NoFalsePositiveDetection_AsForbiddenPrefix() + { + var detectedButNotTerminated = Regex.Prop( + s => EWD998.TerminationDetected(s) && !EWD998.HasSystemTerminated(s), + "TerminationDetected ∧ ¬HasSystemTerminated"); + var bad = Regex.Concat(SigmaStar, detectedButNotTerminated); + var phi = RltlFormula.Trigger(bad, RltlFormula.False); + + var result = RltlCheck.Check(_rootNode, phi); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// Regex intersection: the prefix + /// (Σ* · TerminationDetected) ∩ (Σ* · HasSystemTerminated) + /// matches exactly the prefixes ending in a state where both + /// atoms hold simultaneously. Asserting Match(R, True) + /// just exercises the intersection machinery; the real content + /// is the safety property R ⊳⊳ HasSystemTerminated — at + /// every such position, the system is in fact terminated + /// (trivially true on the intersection by construction). + /// + [Test] + public void Intersection_DetectedAndTerminatedPrefix_SuffixIsTerminated() + { + var detectedPrefix = Regex.Concat(SigmaStar, RDetected); + var terminatedPrefix = Regex.Concat(SigmaStar, RTerminated); + var both = Regex.Intersect(detectedPrefix, terminatedPrefix); + + var terminated = RltlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + var phi = RltlFormula.Match(both, terminated); + + var result = RltlCheck.Check(_rootNode, phi); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + } +} diff --git a/Samples/TerminationDetection/EWD998RltlTests.cs b/Samples/TerminationDetection/EWD998RltlTests.cs new file mode 100644 index 0000000..08f0195 --- /dev/null +++ b/Samples/TerminationDetection/EWD998RltlTests.cs @@ -0,0 +1,245 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// End-to-end RLTL model-checking of the EWD998 termination-detection + /// protocol. Parallels but uses the + /// DSL — which is a strict superset of + /// LtlFormula because it also exposes regex-prefix operators + /// (; : ⊳ ⊳⊳). + /// + /// + /// Liveness properties (leads-to, infinitely-often) only hold under + /// fairness: without it, the protocol admits unfair infinite runs in + /// which (for example) one node sends messages forever and the token + /// never moves. Each test therefore passes the relevant + /// constraint to + /// . + /// When fairness is supplied, the checker switches from the + /// linear-space nested-DFS path to the SCC-based product checker + /// (SccProductCheck), which can evaluate fairness on whole + /// cycles. + /// + /// + public class EWD998RltlTests + { + private StateGraphNode _rootNode; + + [SetUp] + public void Setup() + { + var steps = new List(); + steps.Add(new EWD998.InitiateProbeStep()); + for (int i = 0; i < EWD998.N; i++) + { + if (i != 0) steps.Add(new EWD998.PassTokenStep(i)); + steps.Add(new EWD998.SendMessageStep(i)); + steps.Add(new EWD998.ReceiveMessageStep(i)); + steps.Add(new EWD998.DeactivateStep(i)); + } + + var token = new TokenState { NodeIndex = 0, Q = 0, Color = Color.Black }; + var nodes = new List(); + for (int i = 0; i < EWD998.N; i++) + { + nodes.Add(new NodeState + { + Active = true, Pending = 0, Color = Color.White, Counter = 0 + }); + } + var initialState = new SystemState { Nodes = nodes, Token = token }; + + _rootNode = StateGraph.ExploreStateGraph( + steps, + initialState, + stateConstraint: s => + { + var sys = (SystemState)s; + return sys.Nodes.All(n => n.Counter < 3 && n.Pending < 3) && + sys.Token.Q < 3; + }, lazy: true); + } + + #region Pure-LTL smoke tests (parity with EWD998LtlTests) + + /// + /// Safety: □(TerminationDetected → HasSystemTerminated). + /// "No false positives in termination detection." + /// + [Test] + public void Safety_DetectionImpliesTermination() + { + var detected = RltlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"); + var terminated = RltlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + + var safety = RltlFormula.Always(RltlFormula.Implies(detected, terminated)); + + var result = RltlCheck.Check(_rootNode, safety); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// Liveness via leads-to: HasSystemTerminated ~> TerminationDetected + /// under weak fairness on all step functions (parity with + /// ). + /// + [Test] + public void Liveness_TerminationLeadsToDetection() + { + var detected = RltlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"); + var terminated = RltlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + + var liveness = RltlFormula.LeadsTo(terminated, detected); + + var result = RltlCheck.Check(_rootNode, liveness, maxDepth: 0, + fairness: Fairness.WeakFairAll); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// Combined safety ∧ liveness under weak fairness. + /// + [Test] + public void Combined_SafetyAndLiveness_Via_AndOperator() + { + var detected = RltlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"); + var terminated = RltlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + + var safety = RltlFormula.Always(RltlFormula.Implies(detected, terminated)); + var liveness = RltlFormula.LeadsTo(terminated, detected); + + var combined = safety & liveness; + var result = RltlCheck.Check(_rootNode, combined, maxDepth: 0, + fairness: Fairness.WeakFairAll); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// After splitting into one + /// instance per node, the spurious no-op self-loop is gone: at + /// any terminated state the only enabled transitions force the + /// token back to the leader and an + /// eventually fires. As a result the leads-to property holds for + /// this encoding even without fairness — documented + /// here as the dual of the genuine liveness check above. + /// + [Test] + public void Liveness_TerminationLeadsToDetection_HoldsEvenWithoutFairness() + { + var detected = RltlFormula.Prop(EWD998.TerminationDetected, "TerminationDetected"); + var terminated = RltlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + + var liveness = RltlFormula.LeadsTo(terminated, detected); + + var result = RltlCheck.Check(_rootNode, liveness, fairness: Fairness.None); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + #endregion + + #region Regex-shaped property (genuinely RLTL) + + /// + /// A genuinely regex-shaped obligation: + /// (Σ* · TerminationDetected) ⊳ HasSystemTerminated + /// + /// "At every position k that is reached after some prefix ending in + /// TerminationDetected, the system must actually have + /// terminated (i.e. HasSystemTerminated holds at k)." + /// + /// This is equivalent to the safety property + /// □(TerminationDetected → HasSystemTerminated), but expressed + /// in regex-prefix form. It exercises the full pipeline (ERE + /// derivative, NBW construction over Rltl, nested DFS) on a + /// real model program. + /// + [Test] + public void Regex_PrefixEndingInDetection_ImpliesTermination() + { + var detectedRgx = Regex.Prop(EWD998.TerminationDetected, "TerminationDetected"); + var prefix = Regex.Sigma.Then(detectedRgx); + + var terminated = RltlFormula.Prop(EWD998.HasSystemTerminated, "HasSystemTerminated"); + + // For every prefix matching Σ*·detected, the suffix must satisfy + // (¬detected ∨ terminated) — equivalently, terminated holds at + // the position immediately after a detected-letter. + // + // Σ* requires every letter to satisfy ⊤ — always trivially true. + // The concatenation forces the LAST letter of the match to be + // one where TerminationDetected holds. Trigger then requires + // that at the suffix immediately starting at that position, the + // remainder satisfies HasSystemTerminated. + // + // Note: in (R ; / ⊳ φ), the match consumes letters [0..k) and + // the suffix starts at k. So we require terminated to hold at + // position k — i.e., the state immediately following a detected + // state. To express "detected → terminated AT the same state" + // we use the OVERLAPPING variant ⊳⊳ instead, where the match + // consumes [0..k+1) and the suffix starts at k. + var rgxOverlap = Regex.Sigma.Then(detectedRgx); + var phi = RltlFormula.Match(rgxOverlap, terminated); + + var result = RltlCheck.Check(_rootNode, phi); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// Token-traversal property: □◇ tokenAtLeader — passing + /// counterpart of + /// . + /// Strong fairness on each per-node + /// , + /// and forces token migration + /// back to the leader. + /// + [Test] + public void InfinitelyOften_TokenReturnsToLeader_UnderPerNodeFairness() + { + var tokenAtLeader = RltlFormula.Prop( + s => ((SystemState)s).Token.NodeIndex == 0, + "TokenAtLeader"); + + var formula = RltlFormula.InfinitelyOften(tokenAtLeader); + + var fairness = Fairness.StrongFair(sf => + sf is EWD998.DeactivateStep || + sf is EWD998.PassTokenStep || + sf is EWD998.InitiateProbeStep); + + var result = RltlCheck.Check(_rootNode, formula, maxDepth: 0, fairness: fairness); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + /// + /// Token-traversal property: □◇ tokenAtLeader. RLTL + /// counterpart of + /// . + /// Asserted as a counterexample under + /// to exercise the RLTL InfinitelyOften + /// combinator on a real □◇ obligation. + /// + [Test] + public void InfinitelyOften_TokenReturnsToLeader_FailsWithoutPerNodeDeactivationFairness() + { + var tokenAtLeader = RltlFormula.Prop( + s => ((SystemState)s).Token.NodeIndex == 0, + "TokenAtLeader"); + + var formula = RltlFormula.InfinitelyOften(tokenAtLeader); + + var result = RltlCheck.Check(_rootNode, formula, maxDepth: 0, + fairness: Fairness.None); + Assert.That(result.Valid, Is.False, + "Without per-node deactivation fairness an active node can park the token away from the leader forever."); + } + + #endregion + } +} diff --git a/Samples/TerminationDetection/EWD998State.cs b/Samples/TerminationDetection/EWD998State.cs new file mode 100644 index 0000000..1a3d968 --- /dev/null +++ b/Samples/TerminationDetection/EWD998State.cs @@ -0,0 +1,35 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using Microsoft.Accordant; + + [State] + public partial class TokenState : State + { + public int NodeIndex { get; set; } + + public int Q { get; set; } + + public Color Color { get; set; } + } + + [State] + public partial class NodeState : State + { + public bool Active { get; set; } + + public Color Color { get; set; } + + public int Pending { get; set; } + + public int Counter { get; set; } + } + + [State] + public partial class SystemState : State + { + public List Nodes { get; set; } = new(); + + public TokenState Token { get; set; } + } +} diff --git a/Samples/TerminationDetection/TLAStepFunction.cs b/Samples/TerminationDetection/TLAStepFunction.cs new file mode 100644 index 0000000..4beca8c --- /dev/null +++ b/Samples/TerminationDetection/TLAStepFunction.cs @@ -0,0 +1,45 @@ +namespace TerminationDetection +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + + /// + /// Step functions in bragger specs are _consumed_ after they are applied to a given state. + /// This is unlike the behavior in TLA+ where the set of actions in a specification is a constant + /// and never changes. They may be enabled or disabled in a particular state (similar to + /// step functions in bragger specs) but the act of applying them to a state does not + /// _consume_ them. Unlike TLA+, step functions in bragger specs can also _produce_ zero or + /// more step functions for subsequent states. We use this property of step functions + /// and produce the very same step function as a result of the + /// BaseStepFunction.Apply operation, thus producing the + /// same step function that was just consumed. This allows us to achieve the semantics of + /// TLA+ actions where the set of actions remains constant throughout the model checking of + /// a specification. + /// + public abstract class TLAStepFunction : BaseStepFunction + { + public abstract bool IsEnabled(SystemState systemState); + + public abstract IList NextStates(SystemState systemState); + + protected override IList ApplyInternal(IState state) + { + var systemState = (SystemState)state; + + if (!IsEnabled(systemState)) + { + return null; + } + + var nextStates = NextStates(systemState); + return + nextStates.Select(s => + new StepResult() + { + State = (State)s, + StepFunctions = new IStepFunction[] { this } + }).ToList(); + } + } +} diff --git a/Samples/TerminationDetection/TerminationDetection.csproj b/Samples/TerminationDetection/TerminationDetection.csproj new file mode 100644 index 0000000..04bde8f --- /dev/null +++ b/Samples/TerminationDetection/TerminationDetection.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + latest + Library + + + + + + + + + + + + + + + + diff --git a/Tests/Accordant.ModelChecking.Tests/Accordant.ModelChecking.Tests.csproj b/Tests/Accordant.ModelChecking.Tests/Accordant.ModelChecking.Tests.csproj new file mode 100644 index 0000000..3101fdf --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Accordant.ModelChecking.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + latest + Library + + + + + + + + + + + + + + + + + + diff --git a/Tests/Accordant.ModelChecking.Tests/ConsListTests.cs b/Tests/Accordant.ModelChecking.Tests/ConsListTests.cs new file mode 100644 index 0000000..1e4e45d --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/ConsListTests.cs @@ -0,0 +1,64 @@ +namespace Accordant.ModelChecking.Tests +{ + using System.Linq; + using Microsoft.Accordant.ModelChecking; + using NUnit.Framework; + + [TestFixture] + public class ConsListTests + { + [Test] + public void Empty_IsEmpty() + { + Assert.That(ConsList.Empty.IsEmpty, Is.True); + Assert.That(ConsList.Empty.Count, Is.EqualTo(0)); + Assert.That(ConsList.Empty.ToArray(), Is.Empty); + } + + [Test] + public void Cons_HeadAndTail() + { + var l = ConsList.Empty.Push(1).Push(2).Push(3); + Assert.That(l.Head, Is.EqualTo(3)); + Assert.That(l.Count, Is.EqualTo(3)); + Assert.That(l.ToArray(), Is.EqualTo(new[] { 3, 2, 1 })); + } + + [Test] + public void StructuralSharing_TailSurvives() + { + var a = ConsList.Empty.Push(1).Push(2); + var b = a.Push(3); + var c = a.Push(99); + Assert.That(a.ToArray(), Is.EqualTo(new[] { 2, 1 })); + Assert.That(b.ToArray(), Is.EqualTo(new[] { 3, 2, 1 })); + Assert.That(c.ToArray(), Is.EqualTo(new[] { 99, 2, 1 })); + // Same tail node reused. + Assert.That(ReferenceEquals(b.Tail, a), Is.True); + Assert.That(ReferenceEquals(c.Tail, a), Is.True); + } + + [Test] + public void Reverse_RoundTrip() + { + var l = ConsList.Empty.Push("a").Push("b").Push("c"); + Assert.That(l.Reverse().ToArray(), Is.EqualTo(new[] { "a", "b", "c" })); + Assert.That(l.Reverse().Reverse().ToArray(), + Is.EqualTo(new[] { "c", "b", "a" })); + } + + [Test] + public void FromEnumerableReversed_Order() + { + var l = ConsList.FromEnumerableReversed(new[] { 1, 2, 3 }); + // After folding with prepend, head is last input. + Assert.That(l.ToArray(), Is.EqualTo(new[] { 3, 2, 1 })); + } + + [Test] + public void EmptySingleton_IsShared() + { + Assert.That(ReferenceEquals(ConsList.Empty, ConsList.Empty), Is.True); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/DegenerateInputsTests.cs b/Tests/Accordant.ModelChecking.Tests/DegenerateInputsTests.cs new file mode 100644 index 0000000..ee32fef --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/DegenerateInputsTests.cs @@ -0,0 +1,264 @@ +namespace Accordant.ModelChecking.Tests +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using Microsoft.Accordant.ModelChecking.Testing; + using NUnit.Framework; + + /// + /// Cross-backend coverage on degenerate inputs. + /// + /// + /// Every backend (explicit , symbolic LTL via + /// Tarjan SCC, symbolic LTL via nested DFS, and RLTL) must agree on + /// the verdict for these scenarios. The four backends share almost + /// no implementation so unanimous agreement here is strong evidence + /// against regressions in any one path. + /// + /// + /// + /// Scenarios covered: + /// + /// Deadlocked (no outgoing edges) system node — exercises + /// the implicit stutter-at-terminal convention. + /// Single-node self-loop — degenerate but non-terminal. + /// Two-node chain ending in deadlock. + /// Tautology (true) and contradiction (false) + /// LTL constants. + /// + /// + /// + [TestFixture] + public class DegenerateInputsTests + { + #region Test infrastructure + + private sealed class TestState : State + { + public string Label { get; } + public int Value { get; } + + public TestState(string label, int value) + { + Label = label; + Value = value; + } + + protected override void CloneInternal(Dictionary map) + => map[this] = new TestState(Label, Value); + + protected override void LockComponents(HashSet visited) { } + + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) + => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class NoopStep : IStepFunction + { + private readonly string _id; + public NoopStep(string id) { _id = id; } + public string StepFunctionId => _id; + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> p) + => null; + } + + private static StateGraphNode MakeNode(string label, int value) + { + var st = new TestState(label, value); + st.Freeze(); + return new StateGraphNode + { + State = st, + StepFunctions = new List(), + Edges = new List() + }; + } + + private static void AddEdge(StateGraphNode from, StateGraphNode to, string stepId = "step") + { + from.Edges.Add(new StateGraphEdge { Target = to, StepFunction = new NoopStep(stepId) }); + } + + private static void AssertAllAgree(MultiBackendCrossCheckResult r, bool expectedValid) + { + r.ThrowIfDisagree(); + foreach (var v in r.Verdicts.Where(x => !x.Skipped)) + Assert.That(v.Result.Valid, Is.EqualTo(expectedValid), + $"Backend {v.BackendName} disagrees on '{r.Label}'."); + } + + #endregion + + #region Deadlock (no outgoing edges) + + /// + /// Deadlock node + G true: trivially valid on every backend. + /// Smoke test that no backend chokes on a terminal node. + /// + [Test] + public void Deadlock_GTrue_ValidEverywhere() + { + var s0 = MakeNode("s0", 0); + var phi = LtlFormula.Always(LtlFormula.True); + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, phi, label: "deadlock G true"), + expectedValid: true); + } + + /// + /// Deadlock node where p holds + G p: under the + /// stutter convention, the implicit self-loop preserves p, + /// so G p holds. Pins the explicit-LTL stutter fix. + /// + [Test] + public void Deadlock_GP_PHolds_ValidEverywhere() + { + var s0 = MakeNode("s0", 1); + var phi = LtlFormula.Always(LtlFormula.Prop(s => ((TestState)s).Value == 1, "p")); + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, phi, label: "deadlock G p (p holds)"), + expectedValid: true); + } + + /// + /// Deadlock node where p never holds + F p: under + /// the stutter convention, the infinite stutter never reaches + /// p, so F p fails everywhere. Without the + /// explicit-backend stutter fix this would silently report Valid + /// (no outgoing edges → no product transitions → no SCC), so + /// this test pins the cross-backend agreement. + /// + [Test] + public void Deadlock_FP_PNeverHolds_InvalidEverywhere() + { + var s0 = MakeNode("s0", 0); + var phi = LtlFormula.Eventually(LtlFormula.Prop(s => ((TestState)s).Value == 99, "goal")); + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, phi, fairness: Fairness.None, + label: "deadlock F goal (goal absent)"), + expectedValid: false); + } + + /// + /// Two-node chain s0 → s1 where s1 is a deadlock with p: + /// every run eventually reaches s1 and then stutters there; + /// F p holds. + /// + [Test] + public void TwoNodeChain_EndingInDeadlock_FP_ValidEverywhere() + { + var s0 = MakeNode("s0", 0); + var s1 = MakeNode("s1", 1); + AddEdge(s0, s1, "advance"); + + var phi = LtlFormula.Eventually(LtlFormula.Prop(s => ((TestState)s).Value == 1, "p")); + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, phi, fairness: Fairness.None, + label: "chain to deadlock, F p"), + expectedValid: true); + } + + #endregion + + #region Single-node self-loop + + /// + /// Single-node self-loop where p never holds + F p: + /// the loop is the obvious counterexample. + /// + [Test] + public void SelfLoop_FP_PNeverHolds_InvalidEverywhere() + { + var s0 = MakeNode("s0", 0); + AddEdge(s0, s0, "loop"); + + var phi = LtlFormula.Eventually(LtlFormula.Prop(s => ((TestState)s).Value == 99, "p")); + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, phi, fairness: Fairness.None, + label: "self-loop F p"), + expectedValid: false); + } + + /// + /// Single-node self-loop where p holds + G p: + /// trivially valid. + /// + [Test] + public void SelfLoop_GP_PAlwaysHolds_ValidEverywhere() + { + var s0 = MakeNode("s0", 1); + AddEdge(s0, s0, "loop"); + + var phi = LtlFormula.Always(LtlFormula.Prop(s => ((TestState)s).Value == 1, "p")); + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, phi, fairness: Fairness.None, + label: "self-loop G p"), + expectedValid: true); + } + + #endregion + + #region Constant LTL formulas + + /// + /// Constant true against a non-trivial system: every backend + /// returns Valid. Exercises the no-accepting-states NBW path (the + /// NBW for ¬true = false has empty language, so no NBW + /// transition produces any counterexample). + /// + [Test] + public void ConstantTrue_AnySystem_ValidEverywhere() + { + var s0 = MakeNode("s0", 0); + var s1 = MakeNode("s1", 1); + AddEdge(s0, s1); + AddEdge(s1, s0); + + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, LtlFormula.True, fairness: Fairness.None, + label: "constant true on 2-cycle"), + expectedValid: true); + } + + /// + /// Constant false against a non-trivial system: every + /// backend must report Invalid (the formula admits no satisfying + /// run). Pins the early-out path in + /// against the symbolic NBW-empty-language path. + /// + [Test] + public void ConstantFalse_AnySystem_InvalidEverywhere() + { + var s0 = MakeNode("s0", 0); + var s1 = MakeNode("s1", 1); + AddEdge(s0, s1); + AddEdge(s1, s0); + + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, LtlFormula.False, fairness: Fairness.None, + label: "constant false on 2-cycle"), + expectedValid: false); + } + + /// + /// Constant true against a deadlock node: every backend + /// returns Valid. Cross-cuts the constant-formula and terminal- + /// stutter paths. + /// + [Test] + public void ConstantTrue_OnDeadlock_ValidEverywhere() + { + var s0 = MakeNode("s0", 0); + AssertAllAgree( + LtlMultiBackendCrossCheck.Run(s0, LtlFormula.True, fairness: Fairness.None, + label: "constant true on deadlock"), + expectedValid: true); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/FairnessDirectUnitTests.cs b/Tests/Accordant.ModelChecking.Tests/FairnessDirectUnitTests.cs new file mode 100644 index 0000000..bf7d314 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/FairnessDirectUnitTests.cs @@ -0,0 +1,309 @@ +namespace Accordant.ModelChecking.Tests +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using NUnit.Framework; + + /// + /// Direct unit tests for that + /// exhaustively cover the WeakFair × StrongFair × continuously-enabled + /// × taken cross-product on hand-built SCCs. Previously this helper + /// was only exercised indirectly through the sample suites; these + /// tests pin its semantics with minimal synthetic state graphs. + /// + /// + /// All scenarios use the convention: + /// + /// + /// + /// α, β, γ, … — distinct named step functions. + /// + /// + /// NamedStep(id) — a no-op + /// whose only purpose is to carry an id on edges. + /// + /// + /// "Enabled at v" is derived from v.Edges — the + /// contract followed by . + /// + /// + /// + [TestFixture] + public class FairnessDirectUnitTests + { + private sealed class TestState : State + { + public string Label { get; } + public TestState(string label) { Label = label; } + protected override void CloneInternal(Dictionary map) + => map[this] = new TestState(Label); + protected override void LockComponents(HashSet visited) { } + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) => Label; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class NamedStep : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + public NamedStep(string id) { StepFunctionId = id; StepFunctionIdHash = id.GetHashCode(); } + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> p) => null; + } + + private static StateGraphNode Node(string label, params IStepFunction[] enabled) + { + var st = new TestState(label); st.Freeze(); + return new StateGraphNode + { + State = st, + StepFunctions = new List(enabled), + Edges = new List(), + }; + } + + private static void AddEdge(StateGraphNode src, StateGraphNode dst, IStepFunction step) + => src.Edges.Add(new StateGraphEdge { Target = dst, StepFunction = step }); + + private static StronglyConnectedComponent Scc(params StateGraphNode[] nodes) + { + var scc = new StronglyConnectedComponent(); + foreach (var n in nodes) scc.Nodes.Add(n); + return scc; + } + + // ---------------- single-node SCCs ---------------- + + /// + /// Single self-loop on α. α is continuously enabled and taken. + /// + [Test] + public void SingleNode_AlphaSelfLoop_Fair_Under_All_Fairness() + { + var alpha = new NamedStep("alpha"); + var v = Node("v", alpha); + AddEdge(v, v, alpha); + var scc = Scc(v); + + Assert.That(Fairness.None.IsFairCycle(scc), Is.True); + Assert.That(Fairness.WeakFairAll.IsFairCycle(scc), Is.True); + Assert.That(Fairness.WeakFair(_ => true).IsFairCycle(scc), Is.True); + Assert.That(Fairness.StrongFair(_ => true).IsFairCycle(scc), Is.True); + Assert.That( + (Fairness.WeakFair(_ => true) + Fairness.StrongFair(_ => true)) + .IsFairCycle(scc), Is.True); + } + + /// + /// Single node with two enabled steps α (self-loop) and β + /// (system edge to a node outside the SCC, so β is NOT taken + /// inside the SCC). β is continuously enabled here. + /// + /// + /// No fairness → fair. + /// WeakFair on β → unfair (continuously enabled, not taken). + /// StrongFair on β → unfair (enabled inf. often, not taken). + /// WeakFair on α only → fair (α taken). + /// + /// + [Test] + public void SingleNode_BetaEnabledButNotTaken_Unfair_Under_FairnessOnBeta() + { + var alpha = new NamedStep("alpha"); + var beta = new NamedStep("beta"); + var v = Node("v", alpha, beta); + var w = Node("w"); + AddEdge(v, v, alpha); + AddEdge(v, w, beta); // β leaves SCC + var scc = Scc(v); // single-node SCC = {v} + + Assert.That(Fairness.None.IsFairCycle(scc), Is.True); + Assert.That(Fairness.WeakFair(sf => sf.StepFunctionId == "beta").IsFairCycle(scc), + Is.False, "β continuously enabled at v, not taken in SCC"); + Assert.That(Fairness.StrongFair(sf => sf.StepFunctionId == "beta").IsFairCycle(scc), + Is.False, "β enabled, not taken — strong fairness rejects"); + Assert.That(Fairness.WeakFair(sf => sf.StepFunctionId == "alpha").IsFairCycle(scc), + Is.True, "α taken — weak fairness satisfied"); + Assert.That(Fairness.WeakFairAll.IsFairCycle(scc), Is.False, + "WeakFairAll covers β, which is continuously enabled and not taken"); + } + + // ---------------- two-node SCCs ---------------- + + /// + /// Two-node cycle v ⇄ w via α (v→w) and β (w→v); each step + /// enabled only at one node — neither is continuously enabled. + /// Both α and β are taken. Should be fair under all fairness. + /// + [Test] + public void TwoNode_AlphaBetaCycle_Fair_Under_All_Fairness() + { + var alpha = new NamedStep("alpha"); + var beta = new NamedStep("beta"); + var v = Node("v", alpha); + var w = Node("w", beta); + AddEdge(v, w, alpha); + AddEdge(w, v, beta); + var scc = Scc(v, w); + + Assert.That(Fairness.WeakFairAll.IsFairCycle(scc), Is.True); + Assert.That(Fairness.StrongFair(_ => true).IsFairCycle(scc), Is.True); + } + + /// + /// Two-node cycle v ⇄ w with α both ways; γ also enabled at v + /// but γ leads to a node outside the SCC. γ enabled only at v, + /// so NOT continuously enabled in {v, w}. + /// + /// + /// WeakFair on γ → fair (not continuously enabled). + /// StrongFair on γ → unfair (enabled inf. often at v, not taken). + /// + /// + [Test] + public void TwoNode_GammaEnabledAtOneNodeOnly_DistinguishesWeakStrong() + { + var alpha = new NamedStep("alpha"); + var gamma = new NamedStep("gamma"); + var v = Node("v", alpha, gamma); + var w = Node("w", alpha); + var outside = Node("outside"); + AddEdge(v, w, alpha); + AddEdge(w, v, alpha); + AddEdge(v, outside, gamma); // γ leaves SCC + var scc = Scc(v, w); + + Assert.That(Fairness.WeakFair(sf => sf.StepFunctionId == "gamma").IsFairCycle(scc), + Is.True, "γ enabled only at v, not continuously enabled in {v,w}"); + Assert.That(Fairness.StrongFair(sf => sf.StepFunctionId == "gamma").IsFairCycle(scc), + Is.False, "γ enabled at v (inf. often) but never taken"); + } + + /// + /// Two-node SCC with α as the cycle and δ continuously enabled + /// at every node (self-loop at v and at w), but δ never taken + /// in the cycle (cycle uses α only). Both WF and SF reject. + /// + [Test] + public void TwoNode_DeltaContinuouslyEnabledButNotTaken_Unfair_WF_And_SF() + { + var alpha = new NamedStep("alpha"); + var delta = new NamedStep("delta"); + var outside = Node("outside"); + var v = Node("v", alpha, delta); + var w = Node("w", alpha, delta); + AddEdge(v, w, alpha); + AddEdge(w, v, alpha); + AddEdge(v, outside, delta); // δ leaves SCC + AddEdge(w, outside, delta); + var scc = Scc(v, w); + + Assert.That(Fairness.WeakFair(sf => sf.StepFunctionId == "delta").IsFairCycle(scc), + Is.False, "δ continuously enabled, never taken"); + Assert.That(Fairness.StrongFair(sf => sf.StepFunctionId == "delta").IsFairCycle(scc), + Is.False, "δ enabled inf. often, never taken"); + } + + // ---------------- predicate scoping ---------------- + + /// + /// Fairness only on a step that is neither enabled nor present + /// in the SCC is trivially satisfied. + /// + [Test] + public void Fairness_OnAbsentStep_IsAlwaysFair() + { + var alpha = new NamedStep("alpha"); + var v = Node("v", alpha); + AddEdge(v, v, alpha); + var scc = Scc(v); + + Assert.That(Fairness.WeakFair(sf => sf.StepFunctionId == "absent").IsFairCycle(scc), + Is.True); + Assert.That(Fairness.StrongFair(sf => sf.StepFunctionId == "absent").IsFairCycle(scc), + Is.True); + } + + /// + /// WeakFair + StrongFair combined via : + /// rejects a cycle that violates either constraint independently. + /// + [Test] + public void Combined_WF_Plus_SF_RejectsViolationsOfEither() + { + var alpha = new NamedStep("alpha"); + var beta = new NamedStep("beta"); + var v = Node("v", alpha, beta); + var w = Node("w"); + AddEdge(v, v, alpha); + AddEdge(v, w, beta); // β leaves SCC + var scc = Scc(v); + + var fair = Fairness.WeakFair(sf => sf.StepFunctionId == "alpha") + + Fairness.StrongFair(sf => sf.StepFunctionId == "beta"); + + // α: WF, continuously enabled and taken → ok. + // β: SF, enabled inf. often, not taken → rejected. + Assert.That(fair.IsFairCycle(scc), Is.False); + } + + // ---------------- degenerate inputs ---------------- + + /// + /// An empty SCC (no nodes) is vacuously fair: no step is + /// enabled anywhere, so no fairness constraint can be violated. + /// + [Test] + public void EmptySCC_IsVacuouslyFair() + { + var scc = new StronglyConnectedComponent(); + Assert.That(Fairness.WeakFairAll.IsFairCycle(scc), Is.True); + Assert.That(Fairness.StrongFair(_ => true).IsFairCycle(scc), Is.True); + } + + /// + /// An isolated node with no outgoing edges has empty enabled + /// and empty taken sets — vacuously fair. + /// + [Test] + public void IsolatedNode_NoEdges_IsVacuouslyFair() + { + var v = Node("v"); + var scc = Scc(v); + Assert.That(Fairness.WeakFairAll.IsFairCycle(scc), Is.True); + Assert.That(Fairness.StrongFair(_ => true).IsFairCycle(scc), Is.True); + } + + // ---------------- WeakFair vs StrongFair semantics distinction -------- + + /// + /// Step σ enabled at one of two nodes (so enabled inf. often + /// but NOT continuously enabled) and never taken in the cycle. + /// + /// + /// WeakFair on σ → fair. + /// StrongFair on σ → unfair. + /// + /// This is the textbook distinction between WF and SF. + /// + [Test] + public void Textbook_WeakFair_vs_StrongFair_Distinction() + { + var alpha = new NamedStep("alpha"); + var sigma = new NamedStep("sigma"); + var outside = Node("outside"); + var v = Node("v", alpha, sigma); // σ enabled at v + var w = Node("w", alpha); // σ NOT enabled at w + AddEdge(v, w, alpha); + AddEdge(w, v, alpha); + AddEdge(v, outside, sigma); // σ leaves SCC + var scc = Scc(v, w); + + Assert.That(Fairness.WeakFair(sf => sf.StepFunctionId == "sigma").IsFairCycle(scc), + Is.True, "WF: σ not continuously enabled in {v,w} → vacuously satisfied"); + Assert.That(Fairness.StrongFair(sf => sf.StepFunctionId == "sigma").IsFairCycle(scc), + Is.False, "SF: σ enabled inf. often at v but never taken → unfair"); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/IntUnionFindTests.cs b/Tests/Accordant.ModelChecking.Tests/IntUnionFindTests.cs new file mode 100644 index 0000000..1fd3e72 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/IntUnionFindTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Accordant.ModelChecking; + +namespace Accordant.ModelChecking.Tests +{ + using NUnit.Framework; + + [TestFixture] + public class IntUnionFindTests + { + [Test] + public void Find_OnFreshKey_ReturnsSelf_AndMaterializes() + { + var uf = new IntUnionFind(4); + Assert.That(uf.Contains(7), Is.False); + Assert.That(uf.Find(7), Is.EqualTo(7)); + Assert.That(uf.Contains(7), Is.True); + } + + [Test] + public void Union_MergesClasses_ReturnsTrueOnceFalseAfterwards() + { + var uf = new IntUnionFind(); + Assert.That(uf.Union(1, 2), Is.True); + Assert.That(uf.Union(2, 1), Is.False); + Assert.That(uf.InSameClass(1, 2), Is.True); + } + + [Test] + public void Union_IsTransitive() + { + var uf = new IntUnionFind(); + uf.Union(1, 2); + uf.Union(3, 4); + Assert.That(uf.InSameClass(1, 4), Is.False); + uf.Union(2, 3); + Assert.That(uf.InSameClass(1, 4), Is.True); + Assert.That(uf.InSameClass(2, 4), Is.True); + } + + [Test] + public void DistinctSingletons_AreNotInSameClass() + { + var uf = new IntUnionFind(); + Assert.That(uf.InSameClass(10, 11), Is.False); + Assert.That(uf.Find(10), Is.EqualTo(10)); + Assert.That(uf.Find(11), Is.EqualTo(11)); + } + + [Test] + public void GrowthBeyondInitialCapacity_Works() + { + // Initial capacity 2; force several doublings. + var uf = new IntUnionFind(2); + uf.Union(0, 1000); + uf.Union(1000, 2000); + Assert.That(uf.InSameClass(0, 2000), Is.True); + Assert.That(uf.InSameClass(0, 1234), Is.False); + } + + [Test] + public void PathCompression_ProducesShallowTrees() + { + // Build a deliberately long left-leaning chain by unioning 0..N + // in order; after a single Find(N), parents should mostly point + // at the root. + var uf = new IntUnionFind(8); + const int N = 32; + for (int i = 0; i + 1 < N; i++) uf.Union(i + 1, i); + int root = uf.Find(N - 1); + // Every key 0..N-1 should report the same root. + for (int i = 0; i < N; i++) + { + Assert.That(uf.Find(i), Is.EqualTo(root)); + } + } + + [Test] + public void Find_NegativeKey_Throws() + { + var uf = new IntUnionFind(); + Assert.Throws(() => uf.Find(-1)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/LazyStateGraphTests.cs b/Tests/Accordant.ModelChecking.Tests/LazyStateGraphTests.cs new file mode 100644 index 0000000..591bb72 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/LazyStateGraphTests.cs @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Accordant.ModelChecking.Tests +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using NUnit.Framework; + + /// + /// Tests for lazy (on-the-fly) state-graph construction and its use by the + /// model-checking emptiness search. Verifies that: + /// 1. lazy construction defers all step-function evaluation until the graph + /// is actually walked, yet ultimately yields the same graph as eager; + /// 2. lazy and eager roots produce identical model-checking verdicts across + /// safety, liveness and fairness properties; + /// 3. when a counterexample is shallow, the lazy search stops early and + /// never explores the (large) unreached remainder of the graph. + /// + [TestFixture] + public class LazyStateGraphTests + { + #region Counter model (finite, bounded [0, max]) + + private sealed class CounterState : State + { + public int Count { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + => clonedMap[this] = new CounterState { Count = this.Count }; + + protected override string StringRepresentationInternal( + Dictionary objectPaths, string path, bool forceRecompute) + => $"Count={this.Count}"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + private sealed class IncrementStep : BaseStepFunction + { + private readonly int max; + public int ApplyCount; + + public IncrementStep(int max) { this.max = max; } + + public override string StepFunctionId => "Increment"; + + protected override IList ApplyInternal(IState state) + { + this.ApplyCount++; + var cs = (CounterState)state; + if (cs.Count >= this.max) return null; + var next = (CounterState)cs.Clone(); + next.Count++; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private sealed class DecrementStep : BaseStepFunction + { + public int ApplyCount; + + public override string StepFunctionId => "Decrement"; + + protected override IList ApplyInternal(IState state) + { + this.ApplyCount++; + var cs = (CounterState)state; + if (cs.Count <= 0) return null; + var next = (CounterState)cs.Clone(); + next.Count--; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private static int CountReachableNodes(StateGraphNode root) + { + var seen = new HashSet(); + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var n = stack.Pop(); + if (!seen.Add(n.GetNodeFingerprint())) continue; + foreach (var e in n.Edges) stack.Push(e.Target); + } + return seen.Count; + } + + #endregion + + [Test] + public void Lazy_DefersExpansion_ButYieldsSameGraphWhenWalked() + { + const int max = 4; + var eagerInc = new IncrementStep(max); + var eagerDec = new DecrementStep(); + var eagerRoot = StateGraph.ExploreStateGraph( + new IStepFunction[] { eagerInc, eagerDec }, new CounterState { Count = 0 }); + + var lazyInc = new IncrementStep(max); + var lazyDec = new DecrementStep(); + var lazyRoot = StateGraph.ExploreStateGraph( + new IStepFunction[] { lazyInc, lazyDec }, new CounterState { Count = 0 }, lazy: true); + + // Lazy construction must not have applied any step function yet. + Assert.That(lazyInc.ApplyCount, Is.Zero, "lazy root should not expand on construction"); + Assert.That(lazyDec.ApplyCount, Is.Zero, "lazy root should not expand on construction"); + + // Walking the lazy graph materializes exactly the same set of nodes + // as the fully eager graph. + var eagerCount = CountReachableNodes(eagerRoot); + var lazyCount = CountReachableNodes(lazyRoot); + Assert.That(lazyCount, Is.EqualTo(eagerCount)); + Assert.That(eagerCount, Is.EqualTo(max + 1), "counter [0..max] has max+1 states"); + Assert.That(lazyInc.ApplyCount, Is.GreaterThan(0), "walking should have driven expansion"); + } + + [Test] + public void Lazy_And_Eager_AgreeOnAllVerdicts() + { + const int max = 3; + + StateGraphNode Build(bool lazy) + => StateGraph.ExploreStateGraph( + new IStepFunction[] { new IncrementStep(max), new DecrementStep() }, + new CounterState { Count = 0 }, + lazy: lazy); + + var eagerRoot = Build(lazy: false); + var lazyRoot = Build(lazy: true); + + var p = new Properties(); + var inRange = p.Observe(s => s.Count >= 0 && s.Count <= max, "InRange"); + var atTwo = p.Observe(s => s.Count == 2, "AtTwo"); + var atZero = p.Observe(s => s.Count == 0, "AtZero"); + + var cases = new (TemporalFormula formula, Fairness fairness, string name)[] + { + (p.Always(inRange), null, "safety-holds"), + (p.Always(!atTwo), null, "safety-fails"), + (p.Eventually(atTwo), null, "reachability"), + (p.InfinitelyOften(atZero), Fairness.WeakFairAll, "liveness-fairness"), + }; + + foreach (var (formula, fairness, name) in cases) + { + var eager = eagerRoot.Check(formula, fairness: fairness); + var lazy = lazyRoot.Check(formula, fairness: fairness); + Assert.That(lazy.Valid, Is.EqualTo(eager.Valid), + $"lazy and eager must agree on '{name}'"); + } + } + + [Test] + public void Lazy_And_Eager_AgreeOnRandomVerdicts() + { + for (var seed = 0; seed < 150; seed++) + { + var rnd = new Random(seed); + var max = rnd.Next(1, 6); + + StateGraphNode Build(bool lazy) + => StateGraph.ExploreStateGraph( + new IStepFunction[] { new IncrementStep(max), new DecrementStep() }, + new CounterState { Count = 0 }, + lazy: lazy); + + var eagerRoot = Build(lazy: false); + var lazyRoot = Build(lazy: true); + + var target = rnd.Next(0, max + 2); + var p = new Properties(); + var atTarget = p.Observe(s => s.Count == target, "AtTarget"); + var atZero = p.Observe(s => s.Count == 0, "AtZero"); + + var cases = new (TemporalFormula formula, Fairness fairness, string name)[] + { + (p.Always(atTarget), null, "always"), + (p.Always(!atTarget), null, "always-not"), + (p.Eventually(atTarget), null, "eventually"), + (p.InfinitelyOften(atZero), Fairness.WeakFairAll, "inf-often-fair"), + }; + + foreach (var (formula, fairness, name) in cases) + { + var eager = eagerRoot.Check(formula, fairness: fairness).Valid; + var lazy = lazyRoot.Check(formula, fairness: fairness).Valid; + Assert.That(lazy, Is.EqualTo(eager), + $"seed {seed}: verdict for '{name}' (max={max}, target={target}) differs " + + $"(eager={eager}, lazy={lazy})"); + } + } + } + + #region Region model (large, with a shallow bad self-loop) + + private sealed class RegionState : State + { + public int Region { get; set; } + public int Count { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + => clonedMap[this] = new RegionState { Region = this.Region, Count = this.Count }; + + protected override string StringRepresentationInternal( + Dictionary objectPaths, string path, bool forceRecompute) + => $"R={this.Region},C={this.Count}"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + // "A_ToBad" sorts before "B_Grow", so it is the first edge explored from + // every region-0 node: it moves to region 1, an absorbing self-loop where + // the safety property is violated. This guarantees the lazy search finds a + // shallow accepting lasso before ever descending the large grow chain. + private sealed class ToBadStep : BaseStepFunction + { + public int ApplyCount; + + public override string StepFunctionId => "A_ToBad"; + + protected override IList ApplyInternal(IState state) + { + this.ApplyCount++; + var s = (RegionState)state; + var next = (RegionState)s.Clone(); + next.Region = 1; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private sealed class GrowStep : BaseStepFunction + { + private readonly int max; + public int ApplyCount; + + public GrowStep(int max) { this.max = max; } + + public override string StepFunctionId => "B_Grow"; + + protected override IList ApplyInternal(IState state) + { + this.ApplyCount++; + var s = (RegionState)state; + if (s.Region != 0 || s.Count >= this.max) return null; + var next = (RegionState)s.Clone(); + next.Count++; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + #endregion + + [Test] + public void Lazy_StopsEarly_OnShallowCounterexample() + { + const int max = 500; // eager must build ~2*(max+1) nodes + + // Eager: fully explores the graph, so GrowStep is applied at every + // region-0 node. + var eagerGrow = new GrowStep(max); + var eagerRoot = StateGraph.ExploreStateGraph( + new IStepFunction[] { new ToBadStep(), eagerGrow }, + new RegionState { Region = 0, Count = 0 }); + + // Lazy: the search should close the region-1 self-loop lasso after a + // couple of nodes and never descend the grow chain. + var lazyGrow = new GrowStep(max); + var lazyRoot = StateGraph.ExploreStateGraph( + new IStepFunction[] { new ToBadStep(), lazyGrow }, + new RegionState { Region = 0, Count = 0 }, + lazy: true); + + var p = new Properties(); + var inRegion0 = p.Observe(s => s.Region == 0, "InRegion0"); + var safety = p.Always(inRegion0); + + var eagerResult = eagerRoot.Check(safety); + var lazyResult = lazyRoot.Check(safety); + + // Same verdict: the safety property is violated. + Assert.That(eagerResult.Valid, Is.False); + Assert.That(lazyResult.Valid, Is.False); + + // Early exit: lazy applied GrowStep far fewer times than eager, which + // had to expand the entire chain. + Assert.That(lazyGrow.ApplyCount, Is.LessThan(eagerGrow.ApplyCount), + "lazy search should not explore the whole graph"); + Assert.That(lazyGrow.ApplyCount, Is.LessThan(20), + "lazy search should only touch a shallow prefix"); + Assert.That(eagerGrow.ApplyCount, Is.GreaterThan(max), + "eager exploration touches every region-0 node"); + } + + [Test] + public void Lazy_RespectsConstructionMaxDepth() + { + // Unbounded grow chain, truncated by construction maxDepth. Region-1 + // is never reachable via ToBad within a too-shallow bound because the + // only violation requires stepping to region 1 (depth 2). With + // maxDepth = 1, only the root exists, so the safety property holds + // on the truncated graph. + var steps = new IStepFunction[] { new ToBadStep(), new GrowStep(int.MaxValue) }; + + var shallow = StateGraph.ExploreStateGraph( + steps, new RegionState { Region = 0, Count = 0 }, maxDepth: 1, lazy: true); + var deeper = StateGraph.ExploreStateGraph( + new IStepFunction[] { new ToBadStep(), new GrowStep(int.MaxValue) }, + new RegionState { Region = 0, Count = 0 }, maxDepth: 5, lazy: true); + + var p = new Properties(); + var safety = p.Always(p.Observe(s => s.Region == 0, "InRegion0")); + + // Depth 1: root only, no edge to region 1 -> property holds. + Assert.That(shallow.Check(safety).Valid, Is.True, + "truncated graph has no region-1 state"); + + // Depth 5: region 1 reachable at depth 2 -> violation is present. + Assert.That(deeper.Check(safety).Valid, Is.False); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Ltl/LtlCheckFairnessProjectionRegressionTests.cs b/Tests/Accordant.ModelChecking.Tests/Ltl/LtlCheckFairnessProjectionRegressionTests.cs new file mode 100644 index 0000000..af0b69b --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Ltl/LtlCheckFairnessProjectionRegressionTests.cs @@ -0,0 +1,184 @@ +namespace Accordant.ModelChecking.Tests.Ltl +{ + using System.Collections.Generic; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + /// + /// Pins the LtlCheck fairness-projection bug + /// (todo fix-ltlcheck-fairness-projection). + /// + /// + /// Pre-fix, 's product-cycle fairness check + /// projected the product SCC to a system-level SCC and then asked + /// whether the system SCC was + /// fair. The "taken in SCC" set was therefore computed from + /// system edges, but the projected run actually only fires + /// the step functions that appear on product edges within + /// the product SCC. A system edge s →[α] s' may exist with + /// both endpoints in the projected system SCC, yet the corresponding + /// product edges may all leave the product SCC (because the formula + /// derivative is incompatible with the cycle). The bug was that the + /// system-level check would classify the cycle as fair (α is + /// "taken" in the system projection) while the product run never + /// actually fires α, leading to spurious counterexamples on LTL + /// liveness properties under fairness. + /// + /// + /// The synthetic scenario constructed here exhibits exactly this + /// pattern: one system state with two enabled self-loops α and τ, + /// and a single product node whose only in-SCC outgoing edge is τ; + /// the α-edge leaves the SCC. Under + /// α is continuously enabled but never taken by the projected cycle, + /// so the cycle must be classified as unfair. + /// + /// + [TestFixture] + public class LtlCheckFairnessProjectionRegressionTests + { + private sealed class TestState : State + { + public string Label { get; } + public TestState(string label) { Label = label; } + protected override void CloneInternal(Dictionary map) + => map[this] = new TestState(Label); + protected override void LockComponents(HashSet visited) { } + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) => Label; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class NamedStep : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + public NamedStep(string id) { StepFunctionId = id; StepFunctionIdHash = id.GetHashCode(); } + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> p) => null; + } + + [Test] + public void Unfair_When_AlphaSystemSelfLoop_But_ProductAlphaLeavesSCC() + { + var sState = new TestState("s"); sState.Freeze(); + var outState = new TestState("out"); outState.Freeze(); + + var alpha = new NamedStep("alpha"); + var tau = new NamedStep("tau"); + + var s = new StateGraphNode + { + State = sState, + StepFunctions = new List { alpha, tau }, + Edges = new List(), + }; + s.Edges.Add(new StateGraphEdge { Target = s, StepFunction = alpha }); + s.Edges.Add(new StateGraphEdge { Target = s, StepFunction = tau }); + + var sOut = new StateGraphNode + { + State = outState, + StepFunctions = new List(), + Edges = new List(), + }; + + var p = new ProductNode(s, LtlFormula.True); + var pOut = new ProductNode(sOut, LtlFormula.True); + p.Edges.Add(new ProductEdge(p, tau)); + p.Edges.Add(new ProductEdge(pOut, alpha)); + + var scc = new ProductSCC(); + scc.Nodes.Add(p); + // HasCycle is internal-set; not needed by IsFairCycle + + // Sanity-check the pre-fix shape: the system-only projection + // would classify this SCC as fair (α self-loops s in the + // system, so "taken in system SCC" ⊇ {α}). + var systemOnlySCC = new StronglyConnectedComponent(); + systemOnlySCC.Nodes.Add(s); + typeof(StronglyConnectedComponent) + .GetProperty(nameof(StronglyConnectedComponent.HasCycle)) + .SetValue(systemOnlySCC, true); + Assert.That( + Fairness.WeakFairAll.IsFairCycle(systemOnlySCC), Is.True, + "Sanity: system-only projection is fair (pre-fix would accept)."); + + // Post-fix: product-edge-projected fairness must reject. + Assert.That( + LtlCheck.IsFairCycle(scc, Fairness.WeakFairAll), Is.False, + "α is continuously enabled at s but the product cycle never " + + "fires α — cycle is unfair under WeakFairAll."); + } + + [Test] + public void Fair_When_BothStepsTakenInProduct() + { + var sState = new TestState("s"); sState.Freeze(); + + var alpha = new NamedStep("alpha"); + var tau = new NamedStep("tau"); + + var s = new StateGraphNode + { + State = sState, + StepFunctions = new List { alpha, tau }, + Edges = new List(), + }; + s.Edges.Add(new StateGraphEdge { Target = s, StepFunction = alpha }); + s.Edges.Add(new StateGraphEdge { Target = s, StepFunction = tau }); + + var p = new ProductNode(s, LtlFormula.True); + p.Edges.Add(new ProductEdge(p, tau)); + p.Edges.Add(new ProductEdge(p, alpha)); + + var scc = new ProductSCC(); + scc.Nodes.Add(p); + // HasCycle is internal-set; not needed by IsFairCycle + + Assert.That( + LtlCheck.IsFairCycle(scc, Fairness.WeakFairAll), Is.True, + "Both α and τ taken in product SCC; cycle is fair under WeakFairAll."); + } + + [Test] + public void StrongFair_Detects_AlphaNotTakenInProduct() + { + var sState = new TestState("s"); sState.Freeze(); + + var alpha = new NamedStep("alpha"); + var tau = new NamedStep("tau"); + + var s = new StateGraphNode + { + State = sState, + StepFunctions = new List { alpha, tau }, + Edges = new List(), + }; + s.Edges.Add(new StateGraphEdge { Target = s, StepFunction = alpha }); + s.Edges.Add(new StateGraphEdge { Target = s, StepFunction = tau }); + + var outState = new TestState("out"); outState.Freeze(); + var sOut = new StateGraphNode + { + State = outState, + StepFunctions = new List(), + Edges = new List(), + }; + + var p = new ProductNode(s, LtlFormula.True); + var pOut = new ProductNode(sOut, LtlFormula.True); + p.Edges.Add(new ProductEdge(p, tau)); + p.Edges.Add(new ProductEdge(pOut, alpha)); + + var scc = new ProductSCC(); + scc.Nodes.Add(p); + // HasCycle is internal-set; not needed by IsFairCycle + + var sf = Fairness.StrongFair(x => x.StepFunctionId == "alpha"); + Assert.That( + LtlCheck.IsFairCycle(scc, sf), Is.False, + "Strong fairness on α must reject the cycle since α is enabled at s " + + "but the product cycle never fires α."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Ltl/LtlFormulaCanonicalizationTests.cs b/Tests/Accordant.ModelChecking.Tests/Ltl/LtlFormulaCanonicalizationTests.cs new file mode 100644 index 0000000..99fc4d4 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Ltl/LtlFormulaCanonicalizationTests.cs @@ -0,0 +1,146 @@ +namespace Accordant.ModelChecking.Tests.Ltl +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Ltl; + using NUnit.Framework; + + [TestFixture] + public class LtlFormulaCanonicalizationTests + { + private static LtlFormula P(string name) => + // Each call creates a distinct closure capturing `name`, so two + // Props with different names have distinct Predicate references + // (LtlProp equality is ReferenceEquals on the predicate delegate). + LtlFormula.Prop(s => name != null, name); + + [Test] + public void And_Idempotent_CollapsesDuplicates() + { + var p = P("p"); + var q = P("q"); + var a = LtlFormula.And(p, p); + var b = LtlFormula.And(LtlFormula.And(p, q), p); + Assert.That(a, Is.EqualTo(p)); + Assert.That(b, Is.EqualTo(LtlFormula.And(p, q))); + } + + [Test] + public void And_Commutative_ReordersToCanonical() + { + var p = P("p"); + var q = P("q"); + var a = LtlFormula.And(p, q); + var b = LtlFormula.And(q, p); + Assert.That(a, Is.EqualTo(b)); + Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); + Assert.That(a.ToString(), Is.EqualTo(b.ToString())); + } + + [Test] + public void And_Associative_FlattensNestedAnds() + { + var p = P("p"); var q = P("q"); var r = P("r"); + var a = LtlFormula.And(p, LtlFormula.And(q, r)); + var b = LtlFormula.And(LtlFormula.And(p, q), r); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void Or_Idempotent_Commutative_Associative() + { + var p = P("p"); var q = P("q"); var r = P("r"); + var a = LtlFormula.Or(p, LtlFormula.Or(q, r)); + var b = LtlFormula.Or(LtlFormula.Or(r, q), p); + var c = LtlFormula.Or(LtlFormula.Or(p, q), LtlFormula.Or(r, p)); + Assert.That(a, Is.EqualTo(b)); + Assert.That(a, Is.EqualTo(c)); + } + + [Test] + public void DoubleNegation_Eliminated() + { + var p = P("p"); + var notNotP = LtlFormula.Not(LtlFormula.Not(p)); + Assert.That(notNotP, Is.EqualTo(p)); + } + + [Test] + public void DeMorgan_PushedThroughAnd() + { + var p = P("p"); + var q = P("q"); + var lhs = LtlFormula.Not(LtlFormula.And(p, q)); + var rhs = LtlFormula.Or(LtlFormula.Not(p), LtlFormula.Not(q)); + Assert.That(lhs, Is.EqualTo(rhs)); + } + + [Test] + public void DeMorgan_PushedThroughOr() + { + var p = P("p"); + var q = P("q"); + var lhs = LtlFormula.Not(LtlFormula.Or(p, q)); + var rhs = LtlFormula.And(LtlFormula.Not(p), LtlFormula.Not(q)); + Assert.That(lhs, Is.EqualTo(rhs)); + } + + [Test] + public void Negation_PushedThroughNext() + { + var p = P("p"); + var lhs = LtlFormula.Not(LtlFormula.Next(p)); + var rhs = LtlFormula.Next(LtlFormula.Not(p)); + Assert.That(lhs, Is.EqualTo(rhs)); + } + + [Test] + public void Negation_UntilReleaseDuality() + { + var p = P("p"); + var q = P("q"); + // ¬(p U q) = (¬p) R (¬q) + var notUntil = LtlFormula.Not(LtlFormula.Until(p, q)); + var releaseNeg = LtlFormula.Release(LtlFormula.Not(p), LtlFormula.Not(q)); + Assert.That(notUntil, Is.EqualTo(releaseNeg)); + + // ¬(p R q) = (¬p) U (¬q) + var notRelease = LtlFormula.Not(LtlFormula.Release(p, q)); + var untilNeg = LtlFormula.Until(LtlFormula.Not(p), LtlFormula.Not(q)); + Assert.That(notRelease, Is.EqualTo(untilNeg)); + } + + [Test] + public void Negation_TrueFalseDual() + { + Assert.That(LtlFormula.Not(LtlFormula.True), Is.EqualTo(LtlFormula.False)); + Assert.That(LtlFormula.Not(LtlFormula.False), Is.EqualTo(LtlFormula.True)); + } + + [Test] + public void NNF_ComplexFormula_NoInternalNegationOnCompoundNodes() + { + var p = P("p"); var q = P("q"); var r = P("r"); + // ¬((p U q) ∧ X r) + // → ¬(p U q) ∨ ¬X r + // → ((¬p) R (¬q)) ∨ X(¬r) + var phi = LtlFormula.Not(LtlFormula.And( + LtlFormula.Until(p, q), + LtlFormula.Next(r))); + var expected = LtlFormula.Or( + LtlFormula.Release(LtlFormula.Not(p), LtlFormula.Not(q)), + LtlFormula.Next(LtlFormula.Not(r))); + Assert.That(phi, Is.EqualTo(expected)); + } + + [Test] + public void ToString_IsStableAcrossEquivalentConstructions() + { + var p = P("p"); var q = P("q"); var r = P("r"); + var a = LtlFormula.And(p, LtlFormula.And(q, r)); + var b = LtlFormula.And(r, LtlFormula.And(q, p)); + var c = LtlFormula.And(q, LtlFormula.And(r, p)); + Assert.That(a.ToString(), Is.EqualTo(b.ToString())); + Assert.That(a.ToString(), Is.EqualTo(c.ToString())); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Rltl/RltlDslTests.cs b/Tests/Accordant.ModelChecking.Tests/Rltl/RltlDslTests.cs new file mode 100644 index 0000000..8d55df8 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Rltl/RltlDslTests.cs @@ -0,0 +1,170 @@ +namespace Accordant.ModelChecking.Tests.Rltl +{ + using System.Collections.Generic; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using NUnit.Framework; + + /// + /// Smoke tests for the user-facing RLTL DSL (, + /// , ). The semantics are + /// delegated to SymbolicRltlCheck (already covered by + /// SymbolicRltlCheckTests); these tests focus on the surface API + /// and operator overloads. + /// + [TestFixture] + public class RltlDslTests + { + #region Test infrastructure + + private sealed class TestState : State + { + public int Value { get; set; } + public TestState(int v) { Value = v; } + protected override void CloneInternal(Dictionary m) + => m[this] = new TestState(Value); + protected override void LockComponents(HashSet v) { } + protected override string StringRepresentationInternal(Dictionary p, string path, bool forceRecompute) => $"s({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class Step : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + public Step(string id) { StepFunctionId = id; StepFunctionIdHash = id.GetHashCode(); } + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + private static StateGraphNode N(int v) + { + var st = new TestState(v); st.Freeze(); + return new StateGraphNode + { + State = st, + StepFunctions = new List(), + Edges = new List() + }; + } + + private static void E(StateGraphNode a, StateGraphNode b, string id = "s") + => a.Edges.Add(new StateGraphEdge { Target = b, StepFunction = new Step(id) }); + + #endregion + + #region Boolean / temporal operator overloads + + [Test] + public void Dsl_Always_Eventually_LeadsTo_OperatorOverloads() + { + // s0(active) → s1(detected) → s1 self-loop. + var s0 = N(1); + var s1 = N(2); + E(s0, s1); + E(s1, s1); + + var active = RltlFormula.Prop(s => ((TestState)s).Value == 1, "active"); + var detected = RltlFormula.Prop(s => ((TestState)s).Value == 2, "detected"); + + // Combined: □(active → ◇detected) ∧ □(detected → detected), + // exercising leads-to and the & operator. + var safety = RltlFormula.Always(RltlFormula.Implies(detected, detected)); + var liveness = RltlFormula.LeadsTo(active, detected); + var combined = safety & liveness; + + var result = RltlCheck.Check(s0, combined); + Assert.That(result.Valid, Is.True, result.GetTraceString()); + } + + [Test] + public void Dsl_Negation_ProducesEquivalentInverse() + { + var s0 = N(0); + var s1 = N(1); + E(s0, s1); E(s1, s0); + + var a = RltlFormula.Prop(s => ((TestState)s).Value == 99, "a"); + var fa = RltlFormula.Eventually(a); + + Assert.That(RltlCheck.Check(s0, fa).Valid, Is.False); + Assert.That(RltlCheck.Check(s0, !fa).Valid, Is.True); + } + + #endregion + + #region Regex DSL + + [Test] + public void Dsl_Regex_Sigma_TriggerEquivalentToAlways() + { + // Σ* ⊳ p ≡ G p. + var p = RltlFormula.Prop(st => ((TestState)st).Value == 1, "p"); + var formula = RltlFormula.Trigger(Regex.Sigma, p); + + var s0 = N(1); var s1 = N(1); + E(s0, s1); E(s1, s1); + Assert.That(RltlCheck.Check(s0, formula).Valid, Is.True); + + var v0 = N(1); var v1 = N(0); + E(v0, v1); E(v1, v1); + Assert.That(RltlCheck.Check(v0, formula).Valid, Is.False); + } + + [Test] + public void Dsl_Regex_OperatorOverloads_BuildAcceptedRegex() + { + // Just check the DSL composes: (p | !p) — every letter — starred, + // used as a Trigger guard, equivalent to G φ. + var p = Regex.Prop(s => ((TestState)s).Value == 1, "p"); + var anyLetter = p | !p; + var rgxAll = Regex.Star(anyLetter); + + var q = RltlFormula.Prop(s => ((TestState)s).Value > 0, "v>0"); + var formula = RltlFormula.Trigger(rgxAll, q); + + var s0 = N(1); var s1 = N(2); + E(s0, s1); E(s1, s1); + Assert.That(RltlCheck.Check(s0, formula).Valid, Is.True); + + var v0 = N(1); var v1 = N(0); + E(v0, v1); E(v1, v1); + Assert.That(RltlCheck.Check(v0, formula).Valid, Is.False); + } + + [Test] + public void Dsl_SeqPrefix_Then_RegexShape() + { + // (p .Then q) ; r — exists a (p then q) prefix followed by r. + var p = Regex.Prop(s => ((TestState)s).Value == 1, "p"); + var q = Regex.Prop(s => ((TestState)s).Value == 2, "q"); + var rPred = RltlFormula.Prop(s => ((TestState)s).Value == 3, "r"); + + var formula = RltlFormula.SeqPrefix(p.Then(q), rPred); + + // Satisfied: 1 → 2 → 3 → 3 + var s0 = N(1); var s1 = N(2); var s2 = N(3); + E(s0, s1); E(s1, s2); E(s2, s2); + Assert.That(RltlCheck.Check(s0, formula).Valid, Is.True); + + // Violated: 1 → 2 → 0 → 0 (q-suffix never satisfies r). + var v0 = N(1); var v1 = N(2); var v2 = N(0); + E(v0, v1); E(v1, v2); E(v2, v2); + Assert.That(RltlCheck.Check(v0, formula).Valid, Is.False); + } + + #endregion + + #region Constants + + [Test] + public void Dsl_True_AlwaysHolds_False_AlwaysFails() + { + var s0 = N(0); E(s0, s0); + Assert.That(RltlCheck.Check(s0, RltlFormula.True).Valid, Is.True); + Assert.That(RltlCheck.Check(s0, RltlFormula.False).Valid, Is.False); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/ABWTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/ABWTests.cs new file mode 100644 index 0000000..24f15df --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/ABWTests.cs @@ -0,0 +1,733 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class StateSetTests + { + private static readonly IComparer Cmp = Comparer.Default; + + [Test] + public void Empty_HasCountZero() + { + var s = StateSet.Empty(Cmp); + Assert.AreEqual(0, s.Count); + Assert.IsTrue(s.IsEmpty); + } + + [Test] + public void Singleton_HasCountOne() + { + var s = StateSet.Singleton(42, Cmp); + Assert.AreEqual(1, s.Count); + Assert.IsTrue(s.Contains(42)); + Assert.IsFalse(s.Contains(0)); + } + + [Test] + public void Constructor_SortsAndDeduplicates() + { + var s = new StateSet(new[] { 3, 1, 2, 1, 3 }, Cmp); + Assert.AreEqual(3, s.Count); + Assert.AreEqual(1, s[0]); + Assert.AreEqual(2, s[1]); + Assert.AreEqual(3, s[2]); + } + + [Test] + public void Union_MergesSortedSets() + { + var a = new StateSet(new[] { 1, 3, 5 }, Cmp); + var b = new StateSet(new[] { 2, 3, 4 }, Cmp); + var u = a.Union(b); + Assert.AreEqual(5, u.Count); + CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5 }, u.ToArray()); + } + + [Test] + public void Intersect_FindsCommonElements() + { + var a = new StateSet(new[] { 1, 2, 3, 4 }, Cmp); + var b = new StateSet(new[] { 2, 4, 6 }, Cmp); + var i = a.Intersect(b); + CollectionAssert.AreEqual(new[] { 2, 4 }, i.ToArray()); + } + + [Test] + public void Except_RemovesElements() + { + var a = new StateSet(new[] { 1, 2, 3, 4 }, Cmp); + var b = new StateSet(new[] { 2, 4 }, Cmp); + var e = a.Except(b); + CollectionAssert.AreEqual(new[] { 1, 3 }, e.ToArray()); + } + + [Test] + public void IsSubsetOf_Works() + { + var a = new StateSet(new[] { 2, 3 }, Cmp); + var b = new StateSet(new[] { 1, 2, 3, 4 }, Cmp); + Assert.IsTrue(a.IsSubsetOf(b)); + Assert.IsFalse(b.IsSubsetOf(a)); + Assert.IsTrue(a.IsSubsetOf(a)); // reflexive + } + + [Test] + public void IsProperSubsetOf_ExcludesEqual() + { + var a = new StateSet(new[] { 1, 2 }, Cmp); + Assert.IsFalse(a.IsProperSubsetOf(a)); + Assert.IsTrue(a.IsProperSubsetOf(new StateSet(new[] { 1, 2, 3 }, Cmp))); + } + + [Test] + public void Equality_Structural() + { + var a = new StateSet(new[] { 1, 2, 3 }, Cmp); + var b = new StateSet(new[] { 3, 1, 2 }, Cmp); + Assert.AreEqual(a, b); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Test] + public void CompareTo_LexicographicShorterFirst() + { + var a = new StateSet(new[] { 1, 2 }, Cmp); + var b = new StateSet(new[] { 1, 2, 3 }, Cmp); + Assert.IsTrue(a.CompareTo(b) < 0); + } + + [Test] + public void CompareTo_LexicographicSameLength() + { + var a = new StateSet(new[] { 1, 3 }, Cmp); + var b = new StateSet(new[] { 1, 4 }, Cmp); + Assert.IsTrue(a.CompareTo(b) < 0); + } + } + + [TestFixture] + public class DnfTests + { + private static readonly IComparer Cmp = Comparer.Default; + private DnfAlgebra _alg; + + [SetUp] + public void Setup() + { + _alg = new DnfAlgebra(Cmp); + } + + [Test] + public void Top_HasOneEmptyClause() + { + var top = _alg.Top; + Assert.IsTrue(top.IsTrue); + Assert.IsFalse(top.IsFalse); + Assert.AreEqual(1, top.ClauseCount); + Assert.AreEqual(0, top.Clauses[0].Count); + } + + [Test] + public void Bottom_HasNoClauses() + { + var bot = _alg.Bottom; + Assert.IsTrue(bot.IsFalse); + Assert.IsFalse(bot.IsTrue); + Assert.AreEqual(0, bot.ClauseCount); + } + + [Test] + public void Atom_SingleStateClause() + { + var a = _alg.Atom(5); + Assert.AreEqual(1, a.ClauseCount); + Assert.AreEqual(1, a.Clauses[0].Count); + Assert.IsTrue(a.Clauses[0].Contains(5)); + } + + [Test] + public void Or_UnionOfClauses() + { + var a = _alg.Atom(1); // { {1} } + var b = _alg.Atom(2); // { {2} } + var r = _alg.Or(a, b); // { {1}, {2} } + Assert.AreEqual(2, r.ClauseCount); + } + + [Test] + public void Or_WithBottom_IsIdentity() + { + var a = _alg.Atom(1); + Assert.AreEqual(a, _alg.Or(a, _alg.Bottom)); + Assert.AreEqual(a, _alg.Or(_alg.Bottom, a)); + } + + [Test] + public void Or_WithTop_IsTop() + { + var a = _alg.Atom(1); + Assert.IsTrue(_alg.Or(a, _alg.Top).IsTrue); + Assert.IsTrue(_alg.Or(_alg.Top, a).IsTrue); + } + + [Test] + public void And_CrossProduct() + { + var a = _alg.Atom(1); // { {1} } + var b = _alg.Atom(2); // { {2} } + var r = _alg.And(a, b); // { {1,2} } + Assert.AreEqual(1, r.ClauseCount); + Assert.AreEqual(2, r.Clauses[0].Count); + Assert.IsTrue(r.Clauses[0].Contains(1)); + Assert.IsTrue(r.Clauses[0].Contains(2)); + } + + [Test] + public void And_WithTop_IsIdentity() + { + var a = _alg.Atom(1); + Assert.AreEqual(a, _alg.And(a, _alg.Top)); + Assert.AreEqual(a, _alg.And(_alg.Top, a)); + } + + [Test] + public void And_WithBottom_IsBottom() + { + var a = _alg.Atom(1); + Assert.IsTrue(_alg.And(a, _alg.Bottom).IsFalse); + } + + [Test] + public void And_DistributesOverOr() + { + // (1 ∨ 2) ∧ 3 = (1∧3) ∨ (2∧3) + var oneOrTwo = _alg.Or(_alg.Atom(1), _alg.Atom(2)); // { {1}, {2} } + var three = _alg.Atom(3); // { {3} } + var r = _alg.And(oneOrTwo, three); // { {1,3}, {2,3} } + Assert.AreEqual(2, r.ClauseCount); + } + + [Test] + public void Subsumption_RemovesSupersets() + { + // {1} subsumes {1,2}: if {1} is enough, {1,2} is redundant + var a = _alg.Atom(1); // { {1} } + var b = _alg.Clause(new[] { 1, 2 }); // { {1,2} } + var r = _alg.Or(a, b); // { {1} } + Assert.AreEqual(1, r.ClauseCount); + Assert.AreEqual(1, r.Clauses[0].Count); + } + + [Test] + public void Subsumption_InAndResult() + { + // (1 ∨ 2) ∧ (1 ∨ 3) = {1} ∨ {1,3} ∨ {1,2} ∨ {2,3} + // After subsumption: {1} subsumes {1,3} and {1,2}, so result = {1} ∨ {2,3} + var a = _alg.Or(_alg.Atom(1), _alg.Atom(2)); + var b = _alg.Or(_alg.Atom(1), _alg.Atom(3)); + var r = _alg.And(a, b); + Assert.AreEqual(2, r.ClauseCount); + // Clauses should be {1} and {2,3} + Assert.IsTrue(r.Clauses.Any(c => c.Count == 1 && c.Contains(1))); + Assert.IsTrue(r.Clauses.Any(c => c.Count == 2 && c.Contains(2) && c.Contains(3))); + } + + [Test] + public void Equality_StructuralAcrossConstruction() + { + var a = _alg.And(_alg.Atom(1), _alg.Atom(2)); // { {1,2} } + var b = _alg.Clause(new[] { 2, 1 }); // { {1,2} } (reordered) + Assert.AreEqual(a, b); + } + + [Test] + public void Not_Throws() + { + Assert.Throws(() => _alg.Not(_alg.Atom(1))); + } + + [Test] + public void GetAllStates_ReturnsDistinctStates() + { + var formula = _alg.Or( + _alg.Clause(new[] { 1, 2 }), + _alg.Clause(new[] { 2, 3 })); + var states = new HashSet(formula.GetAllStates()); + CollectionAssert.AreEquivalent(new[] { 1, 2, 3 }, states); + } + } + + [TestFixture] + public class SymbolicABWTests + { + private IntEba _eba; + private ConditionRegistry _registry; + private DnfAlgebra _dnfAlgebra; + + [SetUp] + public void Setup() + { + _eba = new IntEba(3); // universe {0,1,2} + _registry = new ConditionRegistry(); + _dnfAlgebra = new DnfAlgebra(StringComparer.Ordinal); + } + + [Test] + public void BasicABW_CreationAndTransition() + { + // Simple ABW with 2 states: "p" (initial), "q" + // δ(p) = (α ? {q} : ⊥) — read α, go to q + // δ(q) = ⊤ — accepting sink + var alpha = _registry.Register(new IntPredicate("α", 0, 1)); + + TransitionTerm> Delta(string state) + { + switch (state) + { + case "p": + return TransitionTerm>.Ite( + alpha, + TransitionTerm>.Leaf(_dnfAlgebra.Atom("q")), + TransitionTerm>.Leaf(_dnfAlgebra.Bottom)); + case "q": + return TransitionTerm>.Leaf(_dnfAlgebra.Top); + default: + throw new ArgumentException($"Unknown state: {state}"); + } + } + + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, + "p", s => s == "q", Delta); + + // Check initial state + Assert.AreEqual(_dnfAlgebra.Atom("p"), abw.InitialState); + Assert.IsTrue(abw.States.Contains("p")); + + // Get transition for p — should discover q + var deltaP = abw.GetTransition("p"); + Assert.IsTrue(abw.States.Contains("q")); + + // Evaluate for element 0 (in α): should give Dnf({q}) + var leafAt0 = deltaP.Evaluate(0, _registry, _eba); + Assert.IsFalse(leafAt0.IsFalse); + Assert.AreEqual(1, leafAt0.ClauseCount); + Assert.IsTrue(leafAt0.Clauses[0].Contains("q")); + + // Evaluate for element 2 (not in α): should give ⊥ + var leafAt2 = deltaP.Evaluate(2, _registry, _eba); + Assert.IsTrue(leafAt2.IsFalse); + } + + [Test] + public void ABW_AlternatingTransition() + { + // ABW with alternation: δ(s) = {p} ∧ {q} = { {p,q} } + // Both p AND q must hold in successor + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, + "s", + s => false, + s => + { + if (s == "s") + return TransitionTerm>.Leaf( + _dnfAlgebra.And(_dnfAlgebra.Atom("p"), _dnfAlgebra.Atom("q"))); + return TransitionTerm>.Leaf(_dnfAlgebra.Top); + }); + + var delta = abw.GetTransition("s"); + var leaf = ((TransitionTermLeaf>)delta).Value; + Assert.AreEqual(1, leaf.ClauseCount); + Assert.AreEqual(2, leaf.Clauses[0].Count); // {p, q} + } + + [Test] + public void ABW_GetTermAlgebra_CombinesTransitions() + { + // Two states with transitions, combine with And + var alpha = _registry.Register(new IntPredicate("α", 0)); + + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, + "init", + s => false, + s => + { + switch (s) + { + case "init": + return TransitionTerm>.Leaf( + _dnfAlgebra.And(_dnfAlgebra.Atom("a"), _dnfAlgebra.Atom("b"))); + case "a": + return TransitionTerm>.Ite(alpha, + TransitionTerm>.Leaf(_dnfAlgebra.Atom("a")), + TransitionTerm>.Leaf(_dnfAlgebra.Bottom)); + case "b": + return TransitionTerm>.Leaf(_dnfAlgebra.Top); + default: + return TransitionTerm>.Leaf(_dnfAlgebra.Bottom); + } + }); + + var alg = abw.GetTermAlgebra(); + var deltaA = abw.GetTransition("a"); + var deltaB = abw.GetTransition("b"); + + // Combined: δ(a) ∧ δ(b) + var combined = alg.And(deltaA, deltaB); + + // For element 0 (α=true): {a} ∧ ⊤ = {a} + var leafAt0 = combined.Evaluate(0, _registry, _eba); + Assert.AreEqual(1, leafAt0.ClauseCount); + Assert.IsTrue(leafAt0.Clauses[0].Contains("a")); + + // For element 1 (α=false): ⊥ ∧ ⊤ = ⊥ + var leafAt1 = combined.Evaluate(1, _registry, _eba); + Assert.IsTrue(leafAt1.IsFalse); + } + } + + [TestFixture] + public class AlternationEliminationTests + { + private IntEba _eba; + private ConditionRegistry _registry; + private DnfAlgebra _dnfAlgebra; + + [SetUp] + public void Setup() + { + _eba = new IntEba(2); // universe {0, 1} + _registry = new ConditionRegistry(); + _dnfAlgebra = new DnfAlgebra(StringComparer.Ordinal); + } + + [Test] + public void Eliminate_SimpleNondeterministic() + { + // ABW that is already nondeterministic (no alternation): + // States: "p" (initial, accepting), "q" + // δ(p) = (α ? {p} ∨ {q} : {p}) — on α, go to p or q; else stay in p + // δ(q) = {q} — self-loop + var alpha = _registry.Register(new IntPredicate("α", 0)); + + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, + "p", + s => s == "p", + s => + { + switch (s) + { + case "p": + var pOrQ = _dnfAlgebra.Or(_dnfAlgebra.Atom("p"), _dnfAlgebra.Atom("q")); + var justP = _dnfAlgebra.Atom("p"); + return TransitionTerm>.Ite(alpha, + TransitionTerm>.Leaf(pOrQ), + TransitionTerm>.Leaf(justP)); + case "q": + return TransitionTerm>.Leaf(_dnfAlgebra.Atom("q")); + default: + return TransitionTerm>.Leaf(_dnfAlgebra.Bottom); + } + }); + + var nbw = AlternationElimination.Eliminate(abw); + + // NBW initial state should be ({p}, ∅) + Assert.AreEqual(1, nbw.InitialStates.Count); + var init = nbw.InitialStates[0]; + Assert.AreEqual(1, init.Macrostate.Count); + Assert.IsTrue(init.Macrostate.Contains("p")); + Assert.IsTrue(init.Obligation.IsEmpty); + + // Initial state is accepting (O = ∅) + Assert.IsTrue(nbw.IsAccepting(init)); + + // Explore the NBW + var states = AlternationElimination.Explore>(nbw, maxStates: 20); + Assert.IsTrue(states.Count > 0); + } + + [Test] + public void Eliminate_WithAlternation() + { + // ABW with true alternation: + // States: "s" (initial), "a", "b" + // δ(s) = {a} ∧ {b} = { {a,b} } — both a AND b must hold + // δ(a) = ⊤ (accepting sink) + // δ(b) = ⊤ (accepting sink) + // F = {a, b} + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, + "s", + s => s == "a" || s == "b", + s => + { + if (s == "s") + return TransitionTerm>.Leaf( + _dnfAlgebra.And(_dnfAlgebra.Atom("a"), _dnfAlgebra.Atom("b"))); + // a and b are accepting sinks + return TransitionTerm>.Leaf(_dnfAlgebra.Top); + }); + + var nbw = AlternationElimination.Eliminate(abw); + + // Explore + var states = AlternationElimination.Explore>(nbw, maxStates: 20); + Assert.IsTrue(states.Count > 0); + + // The initial transition should produce macrostate {a,b} + var initTransitions = nbw.GetTransition(nbw.InitialStates[0]); + Assert.IsTrue(initTransitions.Count > 0); + } + + [Test] + public void Eliminate_AcceptingCondition() + { + // ABW: single accepting state with self-loop + // δ(p) = {p}, F = {p} + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, + "p", + s => s == "p", + s => TransitionTerm>.Leaf(_dnfAlgebra.Atom("p"))); + + var nbw = AlternationElimination.Eliminate(abw); + + // Initial: ({p}, ∅) — accepting since O=∅ + Assert.IsTrue(nbw.IsAccepting(nbw.InitialStates[0])); + + // Get transitions and explore + var states = AlternationElimination.Explore>(nbw, maxStates: 20); + + // Should have breakpoint states + bool hasAccepting = false; + foreach (BreakpointState s in states) + { + if (nbw.IsAccepting(s)) hasAccepting = true; + } + Assert.IsTrue(hasAccepting, "Should have accepting states"); + // For a single accepting self-loop, all reachable states have O=∅ + // because after reset O = S = {p}, then O' = O \ F = {p}\{p} = ∅ + } + } + + [TestFixture] + public class SymbolicABWGeneralizedInitialTests + { + private IntEba _eba; + private ConditionRegistry _registry; + private DnfAlgebra _dnfAlgebra; + + [SetUp] + public void Setup() + { + _eba = new IntEba(3); + _registry = new ConditionRegistry(); + _dnfAlgebra = new DnfAlgebra(StringComparer.Ordinal); + } + + // Single accepting sink on every state. + private TransitionTerm> SinkDelta(string s) + => TransitionTerm>.Leaf(_dnfAlgebra.Top); + + [Test] + public void DnfInitial_SeedsAllMentionedStates() + { + // φ₀ = {p} ∨ ({q} ∧ {r}) → all of p, q, r should be seeded. + var initial = _dnfAlgebra.Or( + _dnfAlgebra.Atom("p"), + _dnfAlgebra.And(_dnfAlgebra.Atom("q"), _dnfAlgebra.Atom("r"))); + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, initial, _ => true, SinkDelta); + + Assert.AreEqual(initial, abw.InitialState); + Assert.IsTrue(abw.States.Contains("p")); + Assert.IsTrue(abw.States.Contains("q")); + Assert.IsTrue(abw.States.Contains("r")); + } + + [Test] + public void AtomConstructor_WrapsAsSingletonDnf() + { + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "p", _ => true, SinkDelta); + Assert.AreEqual(_dnfAlgebra.Atom("p"), abw.InitialState); + } + + [Test] + public void GetTransitionOnDnf_TrueAndFalse_AreLeafTopBottom() + { + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "p", _ => true, SinkDelta); + + var top = abw.GetTransition(_dnfAlgebra.Top); + var bot = abw.GetTransition(_dnfAlgebra.Bottom); + + Assert.IsInstanceOf>>(top); + Assert.IsInstanceOf>>(bot); + Assert.IsTrue(((TransitionTermLeaf>)top).Value.IsTrue); + Assert.IsTrue(((TransitionTermLeaf>)bot).Value.IsFalse); + } + + [Test] + public void GetTransitionOnDnf_AtomMatchesAtomicDelta() + { + var alpha = _registry.Register(new IntPredicate("α", 0, 1)); + TransitionTerm> Delta(string s) => + s == "p" + ? TransitionTerm>.Ite(alpha, + TransitionTerm>.Leaf(_dnfAlgebra.Atom("q")), + TransitionTerm>.Leaf(_dnfAlgebra.Bottom)) + : TransitionTerm>.Leaf(_dnfAlgebra.Top); + + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "p", s => s == "q", Delta); + + var atomic = abw.GetTransition("p"); + var lifted = abw.GetTransition(_dnfAlgebra.Atom("p")); + + // Same Dnf leaves on both paths for every element. + for (int e = 0; e < 3; e++) + Assert.AreEqual(atomic.Evaluate(e, _registry, _eba), + lifted.Evaluate(e, _registry, _eba)); + } + + [Test] + public void GetTransitionOnDnf_DisjunctionLiftsToOr() + { + // δ(p) leaf {q}, δ(r) leaf {s}, so δ̂(p ∨ r) leaf = {q} ∨ {s}. + TransitionTerm> Delta(string s) + { + if (s == "p") return TransitionTerm>.Leaf(_dnfAlgebra.Atom("q")); + if (s == "r") return TransitionTerm>.Leaf(_dnfAlgebra.Atom("s")); + return TransitionTerm>.Leaf(_dnfAlgebra.Top); + } + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "p", _ => false, Delta); + + var lifted = abw.GetTransition( + _dnfAlgebra.Or(_dnfAlgebra.Atom("p"), _dnfAlgebra.Atom("r"))); + + var leaf = lifted.Evaluate(0, _registry, _eba); + Assert.AreEqual( + _dnfAlgebra.Or(_dnfAlgebra.Atom("q"), _dnfAlgebra.Atom("s")), + leaf); + } + + [Test] + public void GetTransitionOnDnf_ConjunctionLiftsToAnd() + { + TransitionTerm> Delta(string s) + { + if (s == "p") return TransitionTerm>.Leaf(_dnfAlgebra.Atom("q")); + if (s == "r") return TransitionTerm>.Leaf(_dnfAlgebra.Atom("s")); + return TransitionTerm>.Leaf(_dnfAlgebra.Top); + } + var abw = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "p", _ => false, Delta); + + var lifted = abw.GetTransition( + _dnfAlgebra.And(_dnfAlgebra.Atom("p"), _dnfAlgebra.Atom("r"))); + + var leaf = lifted.Evaluate(0, _registry, _eba); + // {q} ∧ {s} = { {q,s} } + Assert.AreEqual(1, leaf.ClauseCount); + Assert.IsTrue(leaf.Clauses[0].Contains("q")); + Assert.IsTrue(leaf.Clauses[0].Contains("s")); + } + + [Test] + public void Union_InitialIsDisjunctionOfTheTwoInitials() + { + var a = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "a0", s => s == "a0", SinkDelta); + var b = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "b0", s => s == "b0", SinkDelta); + + var u = SymbolicABW.Union( + a, b, s => s.StartsWith("a")); + + Assert.AreEqual( + _dnfAlgebra.Or(_dnfAlgebra.Atom("a0"), _dnfAlgebra.Atom("b0")), + u.InitialState); + Assert.IsTrue(u.IsAccepting("a0")); + Assert.IsTrue(u.IsAccepting("b0")); + Assert.IsFalse(u.IsAccepting("other")); + } + + [Test] + public void Intersect_InitialIsConjunctionOfTheTwoInitials() + { + var a = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "a0", s => s == "a0", SinkDelta); + var b = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "b0", s => s == "b0", SinkDelta); + + var x = SymbolicABW.Intersect( + a, b, s => s.StartsWith("a")); + + // {a0} ∧ {b0} = { {a0, b0} } + Assert.AreEqual(1, x.InitialState.ClauseCount); + Assert.IsTrue(x.InitialState.Clauses[0].Contains("a0")); + Assert.IsTrue(x.InitialState.Clauses[0].Contains("b0")); + } + + [Test] + public void Union_DispatchesDeltaByIsInA() + { + // Distinct deltas: a routes "a0" → atom("aSucc"); b routes "b0" → atom("bSucc"). + TransitionTerm> DeltaA(string s) => + s == "a0" + ? TransitionTerm>.Leaf(_dnfAlgebra.Atom("aSucc")) + : TransitionTerm>.Leaf(_dnfAlgebra.Top); + TransitionTerm> DeltaB(string s) => + s == "b0" + ? TransitionTerm>.Leaf(_dnfAlgebra.Atom("bSucc")) + : TransitionTerm>.Leaf(_dnfAlgebra.Top); + + var a = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "a0", _ => false, DeltaA); + var b = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "b0", _ => false, DeltaB); + + var u = SymbolicABW.Union( + a, b, s => s.StartsWith("a")); + + var ta = u.GetTransition("a0").Evaluate(0, _registry, _eba); + var tb = u.GetTransition("b0").Evaluate(0, _registry, _eba); + Assert.AreEqual(_dnfAlgebra.Atom("aSucc"), ta); + Assert.AreEqual(_dnfAlgebra.Atom("bSucc"), tb); + } + + [Test] + public void Union_IncompatibleEba_Throws() + { + var a = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "a0", _ => true, SinkDelta); + var otherEba = new IntEba(3); + var b = new SymbolicABW( + otherEba, _registry, _dnfAlgebra, "b0", _ => true, SinkDelta); + Assert.Throws(() => + SymbolicABW.Union(a, b, _ => true)); + } + + [Test] + public void Union_NullDiscriminator_Throws() + { + var a = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "a0", _ => true, SinkDelta); + var b = new SymbolicABW( + _eba, _registry, _dnfAlgebra, "b0", _ => true, SinkDelta); + Assert.Throws(() => + SymbolicABW.Union(a, b, null)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/BddStatePropEbaTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/BddStatePropEbaTests.cs new file mode 100644 index 0000000..21f50b7 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/BddStatePropEbaTests.cs @@ -0,0 +1,205 @@ +namespace Accordant.ModelChecking.Tests +{ + using Microsoft.Accordant.ModelChecking.Bdd; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Unit tests for the BDD-backed EBA. Covers the propositional + /// decision contract — including cases that exceed the toy + /// 's 2²⁰ brute-force cutoff. + /// + [TestFixture] + public class BddStatePropEbaTests + { + private BddStatePropEba _eba; + private StateProp _p, _q, _r; + private StatePredAtom _ap, _aq, _ar; + + [SetUp] + public void Setup() + { + // The singleton's per-instance ordinal cache only affects + // diagnostics, not decision correctness, so we share it. + _eba = BddStatePropEba.Instance; + _p = new StateProp("p", _ => true); + _q = new StateProp("q", _ => true); + _r = new StateProp("r", _ => true); + _ap = new StatePredAtom(_p); + _aq = new StatePredAtom(_q); + _ar = new StatePredAtom(_r); + } + + // ----------------- IsSatisfiable ----------------- + + [Test] + public void Top_Is_Sat() => Assert.That(_eba.IsSatisfiable(_eba.Top), Is.True); + + [Test] + public void Bottom_Is_Unsat() => Assert.That(_eba.IsSatisfiable(_eba.Bottom), Is.False); + + [Test] + public void Atom_Is_Sat() => Assert.That(_eba.IsSatisfiable(_ap), Is.True); + + [Test] + public void PAndNotP_Is_Unsat() + { + var phi = _eba.And(_ap, _eba.Not(_ap)); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + + [Test] + public void PqAndNotP_Is_Unsat() + { + var phi = _eba.And(_eba.And(_ap, _aq), _eba.Not(_ap)); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + + [Test] + public void POrQ_AndNotP_AndNotQ_Is_Unsat() + { + var phi = _eba.And( + _eba.And(_eba.Or(_ap, _aq), _eba.Not(_ap)), + _eba.Not(_aq)); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + + // ----------------- AreEquivalent ----------------- + + [Test] + public void Idempotence_PAndP_Equiv_P() + => Assert.That(_eba.AreEquivalent(_eba.And(_ap, _ap), _ap), Is.True); + + [Test] + public void DeMorgan_Holds() + { + // ¬(p ∧ q) ≡ ¬p ∨ ¬q + var lhs = _eba.Not(_eba.And(_ap, _aq)); + var rhs = _eba.Or(_eba.Not(_ap), _eba.Not(_aq)); + Assert.That(_eba.AreEquivalent(lhs, rhs), Is.True); + } + + [Test] + public void Distributivity_Holds() + { + // p ∧ (q ∨ r) ≡ (p ∧ q) ∨ (p ∧ r) + var lhs = _eba.And(_ap, _eba.Or(_aq, _ar)); + var rhs = _eba.Or(_eba.And(_ap, _aq), _eba.And(_ap, _ar)); + Assert.That(_eba.AreEquivalent(lhs, rhs), Is.True); + } + + [Test] + public void NonEquivalent_Returns_False() + => Assert.That(_eba.AreEquivalent(_ap, _aq), Is.False); + + // ----------------- Implies ----------------- + + [Test] + public void Atom_Implies_TopButNotBottom() + { + Assert.That(_eba.Implies(_ap, _eba.Top), Is.True); + Assert.That(_eba.Implies(_ap, _eba.Bottom), Is.False); + } + + [Test] + public void PAndQ_Implies_P() + { + Assert.That(_eba.Implies(_eba.And(_ap, _aq), _ap), Is.True); + Assert.That(_eba.Implies(_ap, _eba.And(_ap, _aq)), Is.False); + } + + // ----------------- Beyond the toy cutoff ----------------- + + [Test] + public void Unsat_Detected_Beyond_BruteForce_Cutoff() + { + // The toy enumerator caps at 20 atoms and returns + // conservative-true above that. The BDD backend has no cap: + // build a 25-atom contradiction "p0 ∧ ¬p0" buried under + // irrelevant fan-out and confirm we still say unsat. + var atoms = new StatePredAtom[25]; + for (int i = 0; i < atoms.Length; i++) + atoms[i] = new StatePredAtom( + new StateProp("p" + i, _ => true)); + + IStatePredicate bigOr = atoms[1]; + for (int i = 2; i < atoms.Length; i++) + bigOr = _eba.Or(bigOr, atoms[i]); + + // (atoms[0] ∧ big_or) ∧ ¬atoms[0] — unsat regardless of big_or. + var phi = _eba.And(_eba.And(atoms[0], bigOr), _eba.Not(atoms[0])); + Assert.That(_eba.IsSatisfiable(phi), Is.False, + "BDD backend must detect contradictions above the 20-atom toy cutoff."); + } + + [Test] + public void Equivalence_Detected_Beyond_BruteForce_Cutoff() + { + // 25-atom de-Morgan pair: ¬(∧ atoms_i) ≡ ∨ ¬atoms_i + var atoms = new StatePredAtom[25]; + for (int i = 0; i < atoms.Length; i++) + atoms[i] = new StatePredAtom( + new StateProp("q" + i, _ => true)); + + IStatePredicate andAll = atoms[0]; + for (int i = 1; i < atoms.Length; i++) + andAll = _eba.And(andAll, atoms[i]); + + IStatePredicate orNeg = _eba.Not(atoms[0]); + for (int i = 1; i < atoms.Length; i++) + orNeg = _eba.Or(orNeg, _eba.Not(atoms[i])); + + Assert.That(_eba.AreEquivalent(_eba.Not(andAll), orNeg), Is.True); + } + + // ----------------- Models (delegates to structural) ----------------- + + [Test] + public void Models_Delegates_To_Atom_Evaluate() + { + var trueProp = new StateProp("alwaysTrue", _ => true); + var falseProp = new StateProp("alwaysFalse", _ => false); + var phi = _eba.And(new StatePredAtom(trueProp), + _eba.Not(new StatePredAtom(falseProp))); + // State is the model-program state; we just need a stand-in. + Assert.That(_eba.Models(element: null, predicate: phi), Is.True); + } + } + + /// + /// Verifies that the [ModuleInitializer] in + /// fires automatically when this assembly + /// is loaded — the visible effect is that + /// resolves to the BDD + /// adapter instead of the toy. + /// + [TestFixture] + public class BddBackendRegistrationTests + { + [Test] + public void ModuleInitializer_Registers_Bdd_As_Default() + { + Assert.That(StatePropEbaProvider.Default, + Is.SameAs(BddStatePropEba.Instance)); + } + + [Test] + public void Reset_Then_Reregister_Roundtrips() + { + try + { + StatePropEbaProvider.ResetToFallback(); + Assert.That(StatePropEbaProvider.Default, + Is.SameAs(StatePropEba.Instance)); + + BddBackend.RegisterAsDefault(); + Assert.That(StatePropEbaProvider.Default, + Is.SameAs(BddStatePropEba.Instance)); + } + finally + { + BddBackend.RegisterAsDefault(); + } + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedDepthFrontierTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedDepthFrontierTests.cs new file mode 100644 index 0000000..fdd0c16 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedDepthFrontierTests.cs @@ -0,0 +1,202 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Audit tests for the maxDepth frontier-stutter semantics + /// across the three symbolic backends. The audit task asks whether + /// the implicit self-loop added at the depth frontier preserves + /// sound Büchi semantics (no false counterexamples). + /// + /// + /// Two distinct semantics existed pre-fix: + /// + /// + /// NestedDfsCheck: at frontier, system stutters but + /// the NBW makes a real transition on the current system state. + /// If the NBW has no valid outgoing transition (e.g. q is + /// waiting for ¬p and the frontier state has p=true), + /// the run cuts off and no accepting cycle is reported. + /// ExploreProduct / SccProductCheck: at frontier, + /// add a pure product self-loop without consulting the NBW. + /// This pretends the NBW can self-loop at (sys, q) on + /// state(sys) regardless of whether the NBW actually has + /// such a transition. + /// + /// + /// + /// The pure-product-self-loop is unsound: it can fabricate an + /// accepting cycle where no real Büchi-accepting run exists, + /// producing a false counterexample for an LTL property that + /// actually holds. + /// + /// + /// + /// The discriminating scenario: + /// + /// + /// System: linear chain s0 → s1 → s2 with p + /// true only at s2. + /// Property: F p. ¬F p = G ¬p; NBW is single + /// accepting state q0 with self-loop on ¬p. + /// maxDepth=2: s2 is the frontier node and + /// p is true there. + /// + /// + /// + /// At (s2, q0) the NBW transition q0 -¬p-> q0 cannot + /// fire because p is true. The correct semantics yields no + /// accepting cycle, so F p should be reported as holding. + /// All three backends must agree. + /// + /// + [TestFixture] + public class BoundedDepthFrontierTests + { + private sealed class TestState : State + { + public string Label { get; } + public int Value { get; } + + public TestState(string label, int value) + { + Label = label; + Value = value; + } + + protected override void CloneInternal(Dictionary map) + => map[this] = new TestState(Label, Value); + + protected override void LockComponents(HashSet visited) { } + + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) + => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class NoopStep : IStepFunction + { + private readonly string _id; + public NoopStep(string id) { _id = id; } + public string StepFunctionId => _id; + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> p) + => null; + } + + private static StateGraphNode MakeNode(string label, int value) + { + var st = new TestState(label, value); + st.Freeze(); + return new StateGraphNode + { + State = st, + StepFunctions = new List(), + Edges = new List() + }; + } + + private static void AddEdge(StateGraphNode from, StateGraphNode to, string stepId) + => from.Edges.Add(new StateGraphEdge { Target = to, StepFunction = new NoopStep(stepId) }); + + /// + /// Build the discriminating chain s0 → s1 → s2 with + /// p true only at s2. + /// + private static StateGraphNode BuildChain() + { + var s0 = MakeNode("s0", 0); + var s1 = MakeNode("s1", 0); + var s2 = MakeNode("s2", 1); + AddEdge(s0, s1, "a"); + AddEdge(s1, s2, "b"); + return s0; + } + + /// p ≡ Value == 1. + private static StateProp PProp => + new StateProp("p", s => ((TestState)s).Value == 1); + + /// + /// (ExploreProduct path, + /// no fairness) must report F p as holding when the + /// frontier state already satisfies p. + /// + [Test] + public void ExploreProduct_FrontierAtSatisfyingState_FPHolds() + { + var s0 = BuildChain(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(PProp))); + + var result = SymbolicLtlCheck.Check(s0, phi, maxDepth: 2); + + Assert.That(result.Valid, Is.True, + "F p holds because p is true at the frontier state s2; " + + "a pure product self-loop at the frontier would fabricate " + + "an accepting cycle that the real NBW cannot produce."); + } + + /// + /// (NestedDfsCheck path) + /// must agree. + /// + [Test] + public void NDFS_FrontierAtSatisfyingState_FPHolds() + { + var s0 = BuildChain(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(PProp))); + + var result = SymbolicLtlCheck.CheckNDFS(s0, phi, maxDepth: 2); + Assert.That(result.Valid, Is.True); + } + + /// + /// with non-trivial + /// fairness routes through ; it + /// too must report F p as holding. + /// + [Test] + public void SccProductCheck_FrontierAtSatisfyingState_FPHolds() + { + var s0 = BuildChain(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(PProp))); + + var result = SymbolicLtlCheck.Check(s0, phi, maxDepth: 2, + fairness: Fairness.WeakFairAll); + Assert.That(result.Valid, Is.True); + } + + /// + /// Negative-direction sanity check on the same chain. If + /// p is true at every frontier system state, + /// the unbounded property G p must still fail under all + /// three backends because the chain visits states where p + /// is false before reaching the frontier. + /// + [Test] + public void Frontier_GP_PFailsBeforeFrontier_InvalidEverywhere() + { + var s0 = BuildChain(); + var phi = Ltl.Globally( + Ltl.Atom(new StatePredAtom(PProp))); + + var r1 = SymbolicLtlCheck.Check(s0, phi, maxDepth: 2); + var r2 = SymbolicLtlCheck.CheckNDFS(s0, phi, maxDepth: 2); + var r3 = SymbolicLtlCheck.Check(s0, phi, maxDepth: 2, + fairness: Fairness.WeakFairAll); + + Assert.That(r1.Valid, Is.False, "ExploreProduct: G p must fail (p false at s0)."); + Assert.That(r2.Valid, Is.False, "NDFS: G p must fail (p false at s0)."); + Assert.That(r3.Valid, Is.False, "SccProductCheck: G p must fail (p false at s0)."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedModelCheckingE2ETests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedModelCheckingE2ETests.cs new file mode 100644 index 0000000..fa0dc1b --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/BoundedModelCheckingE2ETests.cs @@ -0,0 +1,646 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// End-to-end bounded symbolic model checking of Bragger model programs. + /// Each test defines a model program (state + operations), explores the state + /// graph with bounded depth, then checks LTL properties using SymbolicLtlCheck. + /// + [TestFixture] + public class BoundedModelCheckingE2ETests + { + #region LTL Helpers + + private static StateProp Prop(string name, Func eval) + => new StateProp(name, eval); + + private static Ltl Atom(StateProp p) + => Ltl.Atom(new StatePredAtom(p)); + + private static Ltl NegAtom(StateProp p) + => LtlAlgebra.Default.NegAtom(new StatePredAtom(p)); + + private static Ltl G(Ltl f) + => Ltl.Globally(f); + + private static Ltl F(Ltl f) + => Ltl.Eventually(f); + + private static Ltl And(Ltl a, Ltl b) + => LtlAlgebra.Default.And(a, b); + + private static Ltl Or(Ltl a, Ltl b) + => LtlAlgebra.Default.Or(a, b); + + private static Ltl Implies(Ltl a, Ltl b) + => LtlAlgebra.Default.Implies(a, b); + + #endregion + + #region Model 1: Bounded Counter + + /// + /// A simple counter that increments and decrements between 0 and MAX. + /// Properties: + /// - Safety: G(0 ≤ count ≤ MAX) + /// - Liveness: GF(count > 0) — counter is infinitely often positive + /// + private sealed class CounterState : State + { + public int Count { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + { + clonedMap[this] = new CounterState + { + Count = this.Count + }; + } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"Count={this.Count}"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + private sealed class IncrementOp : BaseStepFunction + { + private readonly int _max; + public IncrementOp(int max) { _max = max; } + public override string StepFunctionId => "Increment"; + + protected override IList ApplyInternal(IState state) + { + var cs = (CounterState)state; + if (cs.Count >= _max) return null; + var next = (CounterState)cs.Clone(); + next.Count++; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private sealed class DecrementOp : BaseStepFunction + { + public override string StepFunctionId => "Decrement"; + + protected override IList ApplyInternal(IState state) + { + var cs = (CounterState)state; + if (cs.Count <= 0) return null; + var next = (CounterState)cs.Clone(); + next.Count--; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + [Test] + public void Counter_Safety_InBounds() + { + const int max = 3; + var initial = new CounterState { Count = 0 }; + var steps = new IStepFunction[] { new IncrementOp(max), new DecrementOp() }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 10); + + var inBounds = Prop("inBounds", s => { + var c = ((CounterState)s).Count; + return c >= 0 && c <= max; + }); + + var result = SymbolicLtlCheck.Check(root, G(Atom(inBounds))); + Assert.That(result.Valid, Is.True, + "Counter should always remain within [0, max]"); + } + + [Test] + public void Counter_Safety_NeverNegative() + { + const int max = 5; + var initial = new CounterState { Count = 2 }; + var steps = new IStepFunction[] { new IncrementOp(max), new DecrementOp() }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 15); + + var nonNeg = Prop("nonNeg", s => ((CounterState)s).Count >= 0); + var result = SymbolicLtlCheck.Check(root, G(Atom(nonNeg))); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Counter_Reachability_CanReachMax() + { + // Starting at max-1 with only increment, F(atMax) holds trivially + const int max = 3; + var initial = new CounterState { Count = max - 1 }; + var steps = new IStepFunction[] { new IncrementOp(max) }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 10); + + var atMax = Prop("atMax", s => ((CounterState)s).Count == max); + // With only increment from max-1, we must reach max + var result = SymbolicLtlCheck.Check(root, F(Atom(atMax))); + Assert.That(result.Valid, Is.True, + "Starting at max-1 with only increment, counter must reach max"); + } + + #endregion + + #region Model 2: Traffic Light + + /// + /// Traffic light cycles: Red → Green → Yellow → Red. + /// Properties: + /// - Safety: G(¬(Red ∧ Green)) — never red and green simultaneously + /// - Liveness: GF(Green) — green appears infinitely often + /// + private enum LightColor { Red, Green, Yellow } + + private sealed class TrafficLightState : State + { + public LightColor Color { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + { + clonedMap[this] = new TrafficLightState + { + Color = this.Color + }; + } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"Color={this.Color}".ToString(); + + protected override void FreezeComponents(HashSet visited) + { + } + } + + private sealed class ChangeColorOp : BaseStepFunction + { + private readonly LightColor _from; + private readonly LightColor _to; + + public ChangeColorOp(LightColor from, LightColor to) + { + _from = from; _to = to; + } + + public override string StepFunctionId => $"Change_{_from}_to_{_to}"; + + protected override IList ApplyInternal(IState state) + { + var tls = (TrafficLightState)state; + if (tls.Color != _from) return null; + var next = (TrafficLightState)tls.Clone(); + next.Color = _to; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + [Test] + public void TrafficLight_Safety_NeverRedAndGreen() + { + var initial = new TrafficLightState { Color = LightColor.Red }; + var steps = new IStepFunction[] + { + new ChangeColorOp(LightColor.Red, LightColor.Green), + new ChangeColorOp(LightColor.Green, LightColor.Yellow), + new ChangeColorOp(LightColor.Yellow, LightColor.Red) + }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 20); + + // G(¬(red ∧ green)) — trivially true since color is one value + var red = Prop("red", s => ((TrafficLightState)s).Color == LightColor.Red); + var green = Prop("green", s => ((TrafficLightState)s).Color == LightColor.Green); + var notBoth = G(Or(NegAtom(red), NegAtom(green))); + + var result = SymbolicLtlCheck.Check(root, notBoth); + Assert.That(result.Valid, Is.True, + "Traffic light should never be both red and green"); + } + + [Test] + public void TrafficLight_Liveness_GreenInfinitelyOften() + { + var initial = new TrafficLightState { Color = LightColor.Red }; + var steps = new IStepFunction[] + { + new ChangeColorOp(LightColor.Red, LightColor.Green), + new ChangeColorOp(LightColor.Green, LightColor.Yellow), + new ChangeColorOp(LightColor.Yellow, LightColor.Red) + }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 20); + + var green = Prop("green", s => ((TrafficLightState)s).Color == LightColor.Green); + // GF(green) — green appears infinitely often + var result = SymbolicLtlCheck.Check(root, G(F(Atom(green)))); + Assert.That(result.Valid, Is.True, + "Traffic light should cycle through green infinitely often"); + } + + [Test] + public void TrafficLight_EventuallyGreen() + { + var initial = new TrafficLightState { Color = LightColor.Red }; + var steps = new IStepFunction[] + { + new ChangeColorOp(LightColor.Red, LightColor.Green), + new ChangeColorOp(LightColor.Green, LightColor.Yellow), + new ChangeColorOp(LightColor.Yellow, LightColor.Red) + }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 10); + + var green = Prop("green", s => ((TrafficLightState)s).Color == LightColor.Green); + var result = SymbolicLtlCheck.Check(root, F(Atom(green))); + Assert.That(result.Valid, Is.True, + "Starting from red, the light should eventually turn green"); + } + + #endregion + + #region Model 3: Producer-Consumer Buffer + + /// + /// Bounded buffer: producer adds items, consumer removes items. + /// Properties: + /// - Safety: G(0 ≤ size ≤ capacity) + /// - No overflow: G(size ≤ capacity) + /// - Liveness: GF(size < capacity) — buffer is infinitely often not full + /// + private sealed class BufferState : State + { + public int Size { get; set; } + public int Capacity { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + { + clonedMap[this] = new BufferState + { + Size = this.Size, + Capacity = this.Capacity + }; + } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"Size={this.Size},Capacity={this.Capacity}"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + private sealed class ProduceOp : BaseStepFunction + { + public override string StepFunctionId => "Produce"; + + protected override IList ApplyInternal(IState state) + { + var bs = (BufferState)state; + if (bs.Size >= bs.Capacity) return null; + var next = (BufferState)bs.Clone(); + next.Size++; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private sealed class ConsumeOp : BaseStepFunction + { + public override string StepFunctionId => "Consume"; + + protected override IList ApplyInternal(IState state) + { + var bs = (BufferState)state; + if (bs.Size <= 0) return null; + var next = (BufferState)bs.Clone(); + next.Size--; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + [Test] + public void Buffer_Safety_NoOverflow() + { + var initial = new BufferState { Size = 0, Capacity = 3 }; + var steps = new IStepFunction[] { new ProduceOp(), new ConsumeOp() }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 15); + + var noOverflow = Prop("noOverflow", + s => ((BufferState)s).Size <= ((BufferState)s).Capacity); + var result = SymbolicLtlCheck.Check(root, G(Atom(noOverflow))); + Assert.That(result.Valid, Is.True, + "Buffer should never exceed capacity"); + } + + [Test] + public void Buffer_Safety_NonNegativeSize() + { + var initial = new BufferState { Size = 1, Capacity = 4 }; + var steps = new IStepFunction[] { new ProduceOp(), new ConsumeOp() }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 15); + + var nonNeg = Prop("nonNeg", s => ((BufferState)s).Size >= 0); + var result = SymbolicLtlCheck.Check(root, G(Atom(nonNeg))); + Assert.That(result.Valid, Is.True, + "Buffer size should never be negative"); + } + + [Test] + public void Buffer_Liveness_NotAlwaysFull() + { + var initial = new BufferState { Size = 0, Capacity = 2 }; + var steps = new IStepFunction[] { new ProduceOp(), new ConsumeOp() }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 15); + + var notFull = Prop("notFull", + s => ((BufferState)s).Size < ((BufferState)s).Capacity); + // GF(notFull) — buffer is infinitely often not full + var result = SymbolicLtlCheck.Check(root, G(F(Atom(notFull)))); + Assert.That(result.Valid, Is.True, + "Buffer should be infinitely often not full (can always consume)"); + } + + #endregion + + #region Model 4: Simple Mutex Protocol + + /// + /// Two-process mutex with a turn variable. + /// State: (p1_in_cs, p2_in_cs, turn ∈ {1,2}) + /// Properties: + /// - Safety (mutual exclusion): G(¬(p1_in_cs ∧ p2_in_cs)) + /// - Liveness: G(requesting → F(in_cs)) + /// + private sealed class MutexState : State + { + public bool P1InCS { get; set; } + public bool P2InCS { get; set; } + public int Turn { get; set; } // 1 or 2 + + protected override void CloneInternal(Dictionary clonedMap) + { + clonedMap[this] = new MutexState + { + P1InCS = this.P1InCS, + P2InCS = this.P2InCS, + Turn = this.Turn + }; + } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"P1InCS={this.P1InCS},P2InCS={this.P2InCS},Turn={this.Turn}"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + private sealed class P1EnterOp : BaseStepFunction + { + public override string StepFunctionId => "P1_Enter"; + + protected override IList ApplyInternal(IState state) + { + var ms = (MutexState)state; + if (ms.P1InCS || ms.P2InCS || ms.Turn != 1) return null; + var next = (MutexState)ms.Clone(); + next.P1InCS = true; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private sealed class P1ExitOp : BaseStepFunction + { + public override string StepFunctionId => "P1_Exit"; + + protected override IList ApplyInternal(IState state) + { + var ms = (MutexState)state; + if (!ms.P1InCS) return null; + var next = (MutexState)ms.Clone(); + next.P1InCS = false; + next.Turn = 2; // pass turn to P2 + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private sealed class P2EnterOp : BaseStepFunction + { + public override string StepFunctionId => "P2_Enter"; + + protected override IList ApplyInternal(IState state) + { + var ms = (MutexState)state; + if (ms.P2InCS || ms.P1InCS || ms.Turn != 2) return null; + var next = (MutexState)ms.Clone(); + next.P2InCS = true; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + private sealed class P2ExitOp : BaseStepFunction + { + public override string StepFunctionId => "P2_Exit"; + + protected override IList ApplyInternal(IState state) + { + var ms = (MutexState)state; + if (!ms.P2InCS) return null; + var next = (MutexState)ms.Clone(); + next.P2InCS = false; + next.Turn = 1; // pass turn to P1 + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + [Test] + public void Mutex_Safety_MutualExclusion() + { + var initial = new MutexState { P1InCS = false, P2InCS = false, Turn = 1 }; + var steps = new IStepFunction[] + { + new P1EnterOp(), new P1ExitOp(), + new P2EnterOp(), new P2ExitOp() + }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 20); + + var mutualExcl = Prop("mutualExcl", + s => !(((MutexState)s).P1InCS && ((MutexState)s).P2InCS)); + var result = SymbolicLtlCheck.Check(root, G(Atom(mutualExcl))); + Assert.That(result.Valid, Is.True, + "Mutual exclusion: P1 and P2 should never both be in critical section"); + } + + [Test] + public void Mutex_Liveness_P1EventuallyEnters() + { + var initial = new MutexState { P1InCS = false, P2InCS = false, Turn = 1 }; + var steps = new IStepFunction[] + { + new P1EnterOp(), new P1ExitOp(), + new P2EnterOp(), new P2ExitOp() + }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 20); + + var p1InCs = Prop("p1InCS", s => ((MutexState)s).P1InCS); + // GF(p1InCS) — P1 enters CS infinitely often + var result = SymbolicLtlCheck.Check(root, G(F(Atom(p1InCs)))); + Assert.That(result.Valid, Is.True, + "P1 should enter critical section infinitely often"); + } + + [Test] + public void Mutex_Liveness_P2EventuallyEnters() + { + var initial = new MutexState { P1InCS = false, P2InCS = false, Turn = 1 }; + var steps = new IStepFunction[] + { + new P1EnterOp(), new P1ExitOp(), + new P2EnterOp(), new P2ExitOp() + }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 20); + + var p2InCs = Prop("p2InCS", s => ((MutexState)s).P2InCS); + var result = SymbolicLtlCheck.Check(root, G(F(Atom(p2InCs)))); + Assert.That(result.Valid, Is.True, + "P2 should enter critical section infinitely often"); + } + + #endregion + + #region Model 5: Broken Counter (Negative Test) + + /// + /// A buggy counter that can go below 0 — should fail safety check. + /// + private sealed class BuggyDecrementOp : BaseStepFunction + { + public override string StepFunctionId => "BuggyDecrement"; + // Bug: always enabled (no guard on count > 0) + + protected override IList ApplyInternal(IState state) + { + var cs = (CounterState)state; + var next = (CounterState)cs.Clone(); + next.Count--; + return new[] { new StepResult { State = next, StepFunctions = new IStepFunction[] { this } } }; + } + } + + [Test] + public void BuggyCounter_Safety_Violated() + { + var initial = new CounterState { Count = 1 }; + var steps = new IStepFunction[] { new IncrementOp(3), new BuggyDecrementOp() }; + + // Bounded check — buggy decrement will go negative + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 5); + + var nonNeg = Prop("nonNeg", s => ((CounterState)s).Count >= 0); + var result = SymbolicLtlCheck.Check(root, G(Atom(nonNeg))); + Assert.That(result.Valid, Is.False, + "Buggy counter should violate non-negativity"); + } + + #endregion + + #region Model 6: Bounded Depth Effects + + [Test] + public void BoundedDepth_ShallowBound_MaySatisfyProperty() + { + // With shallow depth bound, a property might appear to hold + // that would be violated with deeper exploration + var initial = new CounterState { Count = 5 }; + var steps = new IStepFunction[] { new BuggyDecrementOp() }; + + // At depth 4, counter goes 5→4→3→2→1 (all non-negative within bound) + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 4); + + var nonNeg = Prop("nonNeg", s => ((CounterState)s).Count >= 0); + // With bounded checking at this depth, the stutter loop + // keeps it at count=1 forever — property holds + var result = SymbolicLtlCheck.Check(root, G(Atom(nonNeg)), maxDepth: 4); + // Note: depending on stutter semantics, this may pass at shallow depth + // The key point is bounded checking doesn't crash + Assert.That(result, Is.Not.Null); + } + + [Test] + public void BoundedDepth_DeeperBound_FindsViolation() + { + var initial = new CounterState { Count = 2 }; + var steps = new IStepFunction[] { new BuggyDecrementOp() }; + + // At depth 5, counter goes 2→1→0→-1→... violates non-negativity + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 5); + + var nonNeg = Prop("nonNeg", s => ((CounterState)s).Count >= 0); + var result = SymbolicLtlCheck.Check(root, G(Atom(nonNeg))); + Assert.That(result.Valid, Is.False, + "Deeper exploration should find the negativity violation"); + } + + #endregion + + #region Model 7: Response Properties + + [Test] + public void TrafficLight_Response_RedLeadsToGreen() + { + var initial = new TrafficLightState { Color = LightColor.Red }; + var steps = new IStepFunction[] + { + new ChangeColorOp(LightColor.Red, LightColor.Green), + new ChangeColorOp(LightColor.Green, LightColor.Yellow), + new ChangeColorOp(LightColor.Yellow, LightColor.Red) + }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 20); + + var red = Prop("red", s => ((TrafficLightState)s).Color == LightColor.Red); + var green = Prop("green", s => ((TrafficLightState)s).Color == LightColor.Green); + + // G(red → F(green)) — every red is eventually followed by green + var result = SymbolicLtlCheck.Check(root, G(Implies(Atom(red), F(Atom(green))))); + Assert.That(result.Valid, Is.True, + "Red should always be eventually followed by green"); + } + + [Test] + public void Buffer_Response_FullImpliesEventuallyNotFull() + { + var initial = new BufferState { Size = 0, Capacity = 2 }; + var steps = new IStepFunction[] { new ProduceOp(), new ConsumeOp() }; + + var root = StateGraph.ExploreStateGraph(steps, initial, maxDepth: 15); + + var full = Prop("full", s => ((BufferState)s).Size >= ((BufferState)s).Capacity); + var notFull = Prop("notFull", s => ((BufferState)s).Size < ((BufferState)s).Capacity); + + // G(full → F(notFull)) — whenever full, eventually becomes not full + var result = SymbolicLtlCheck.Check(root, G(Implies(Atom(full), F(Atom(notFull))))); + Assert.That(result.Valid, Is.True, + "Full buffer should eventually have space (consume can run)"); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/BpWeakEquivalenceBpEndToEndTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/BpWeakEquivalenceBpEndToEndTests.cs new file mode 100644 index 0000000..473c95f --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/BpWeakEquivalenceBpEndToEndTests.cs @@ -0,0 +1,292 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using System.Diagnostics; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Bdd; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// End-to-end tests for the post-construction bisimulation minimiser + /// () exposed via the + /// mergeWeakEquivalentBp knob on + /// . + /// + /// The knob implements JACM Lemma 5.x (state-reduction in the + /// breakpoint NBW) as a graph-bound partition refinement. Compared to + /// the per-pair language-equivalence-based mergeWeakEquivalent + /// knob, it is structural rather than semantic and operates on the + /// already-constructed NBW — so it stays tractable on + /// GFa ∧ GFb ∧ GFc where the language-level merge times out. + /// + [TestFixture] + public class BpWeakEquivalenceBpEndToEndTests + { + private static readonly BddStatePropEba Eba = BddStatePropEba.Instance; + + private static readonly StateProp PropA = new StateProp("a", _ => true); + private static readonly StateProp PropB = new StateProp("b", _ => true); + private static readonly StateProp PropC = new StateProp("c", _ => true); + + private static Rltl A(StateProp p) + => Rltl.Atom(new StatePredAtom(p)); + private static Rltl NA(StateProp p) + => Rltl.Atom(Eba.Not(new StatePredAtom(p))); + + private static int CountReachable( + SymbolicNBW>> nbw, + int cap) + { + var seen = new HashSet>>( + BreakpointState>.GetEqualityComparer()); + var work = new Queue>>(); + foreach (var s in nbw.InitialStates) if (seen.Add(s)) work.Enqueue(s); + while (work.Count > 0 && seen.Count <= cap) + { + var s = work.Dequeue(); + foreach (var tt in nbw.GetTransition(s)) + foreach (var leaf in tt.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) work.Enqueue(succ); + } + return seen.Count; + } + + public enum MinVariant { Post, Fused, Dedup } + + private static SymbolicNBW>> + BuildMinimisedNbw(Rltl formula, bool negate, MinVariant variant = MinVariant.Dedup) + { + var (min, _) = BuildMinimisedNbwWithAe(formula, negate, variant); + return min; + } + + private static (SymbolicNBW>> min, + IncrementalAE> ae) + BuildMinimisedNbwWithAe(Rltl formula, bool negate, MinVariant variant) + { + var registry = new ConditionRegistry( + EqualityComparer.Default); + var ed = new EreDerivative(Eba, registry); + var ereCanon = new EreCanonicalizer( + new EreEquivalenceChecker(ed)); + var ralg = new RltlAlgebra(Eba, ereCanon); + var rltlCanon = new RltlCanonicalizer(Eba, ralg); + var deriv = new RltlDerivative( + Eba, registry, ereCanon, rltlCanon); + var seed = negate ? ralg.Not(formula) : formula; + var abw = deriv.ToABW(seed); + var ae = new IncrementalAE>(abw); + var nbw = ae.ToNBW(); + + var bpEq = BreakpointState>.GetEqualityComparer(); + var bpOrd = BreakpointState>.GetComparer( + Comparer>.Default); + var min = variant switch + { + MinVariant.Post => BpWeakEquivalenceMinimizer.Minimize(nbw, bpEq, bpOrd), + MinVariant.Fused => BpWeakEquivalenceMinimizer.MinimizeFused(nbw, bpEq, bpOrd), + MinVariant.Dedup => BpWeakEquivalenceMinimizer.DedupOnTheFly(nbw, bpEq, bpOrd), + _ => throw new System.ArgumentOutOfRangeException(nameof(variant)), + }; + return (min, ae); + } + + /// + /// JACM Example 5.1: G(Fa ∧ F¬a). The unminimised breakpoint + /// NBW has 8 reachable states; the bisimulation minimiser must + /// collapse it to exactly 3, matching the paper. + /// + [Test] + public void Jacm51_BisimMin_CollapsesToThree() + { + var ralg = RltlAlgebra.Default; + var phi = ralg.Globally(ralg.And( + ralg.Eventually(A(PropA)), + ralg.Eventually(NA(PropA)))); + var min = BuildMinimisedNbw(phi, negate: false); + int count = CountReachable(min, 100); + + // Print reachable BPs for diagnostics. + var seen = new HashSet>>( + BreakpointState>.GetEqualityComparer()); + var work = new Queue>>(); + foreach (var s in min.InitialStates) if (seen.Add(s)) work.Enqueue(s); + while (work.Count > 0) + { + var s = work.Dequeue(); + var macro = string.Join(",", System.Linq.Enumerable.Select(s.Macrostate, f => f.ToString())); + var oblig = string.Join(",", System.Linq.Enumerable.Select(s.Obligation, f => f.ToString())); + TestContext.WriteLine($" S={{{macro}}} O={{{oblig}}} acc={s.Obligation.IsEmpty}"); + foreach (var tt in min.GetTransition(s)) + { + TestContext.WriteLine($" tt: {tt}"); + foreach (var leaf in tt.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) work.Enqueue(succ); + } + } + + Assert.That(count, Is.EqualTo(3), + "G(Fa ∧ F¬a) must collapse to 3 BP states under bisimulation minimisation."); + } + + /// + /// GFa ∧ GFb ∧ GFc: the language-level + /// mergeWeakEquivalent times out at 30s here, but + /// bisimulation minimisation should complete in well under a second + /// and reduce the 27 reachable BP states. + /// + [Test, Timeout(120_000)] + public void GFaGFbGFc_BisimMin_TractableAndReduces() + { + var ralg = RltlAlgebra.Default; + var phi = ralg.And( + ralg.And( + ralg.Globally(ralg.Eventually(A(PropA))), + ralg.Globally(ralg.Eventually(A(PropB)))), + ralg.Globally(ralg.Eventually(A(PropC)))); + + var sw = Stopwatch.StartNew(); + var minP = BuildMinimisedNbw(phi, negate: true, MinVariant.Post); + int countPost = CountReachable(minP, 1000); + sw.Stop(); + TestContext.WriteLine($"Post : {countPost} BP states in {sw.ElapsedMilliseconds} ms"); + + sw.Restart(); + var minD = BuildMinimisedNbw(phi, negate: true, MinVariant.Dedup); + int countDedup = CountReachable(minD, 1000); + sw.Stop(); + TestContext.WriteLine($"Dedup: {countDedup} BP states in {sw.ElapsedMilliseconds} ms"); + + Assert.That(countPost, Is.LessThanOrEqualTo(27), + "Bisimulation minimisation must not enlarge the BP count."); + // Dedup is shallow (no cyclic structural alignment) so its count + // may exceed the bisim-optimal count, but must still be sound. + Assert.That(countDedup, Is.LessThanOrEqualTo(27), + "Dedup must not enlarge the BP count."); + } + + /// + /// On-the-fly dedup must expand strictly fewer raw BPs on + /// GFa ∧ GFb ∧ GFc than the post-construction variant — the + /// dedup short-circuits at construction time so aliased BPs are + /// never expanded further. + /// + [Test, Timeout(120_000)] + public void GFaGFbGFc_Dedup_ExpandsFewerThanPostConstruction() + { + var ralg = RltlAlgebra.Default; + Rltl Phi() => ralg.And( + ralg.And( + ralg.Globally(ralg.Eventually(A(PropA))), + ralg.Globally(ralg.Eventually(A(PropB)))), + ralg.Globally(ralg.Eventually(A(PropC)))); + + var (minPost, aePost) = BuildMinimisedNbwWithAe(Phi(), negate: true, MinVariant.Post); + var (minDedup, aeDedup) = BuildMinimisedNbwWithAe(Phi(), negate: true, MinVariant.Dedup); + + int postCount = CountReachable(minPost, 1000); + int dedupCount = CountReachable(minDedup, 1000); + TestContext.WriteLine( + $"Post : {aePost.ComputedStateCount} BP σ-computations → {postCount} reachable."); + TestContext.WriteLine( + $"Dedup: {aeDedup.ComputedStateCount} BP σ-computations → {dedupCount} reachable."); + + Assert.That(aeDedup.ComputedStateCount, Is.LessThanOrEqualTo(aePost.ComputedStateCount), + "Dedup must not exceed post-construction BP expansions."); + } + + /// + /// Soundness: alternating-a model satisfies G(Fa ∧ F¬a), + /// even with the bisim minimisation applied. + /// + [Test] + public void GFaFNa_AlternatingModel_ValidUnderBpMerge() + { + var s0 = JacmExample51EndToEndTests_Helpers.MakeNode("s0", a: true); + var s1 = JacmExample51EndToEndTests_Helpers.MakeNode("s1", a: false); + JacmExample51EndToEndTests_Helpers.AddEdge(s0, s1); + JacmExample51EndToEndTests_Helpers.AddEdge(s1, s0); + + var aProp = new StateProp("a", + st => ((JacmExample51EndToEndTests_Helpers.TestState)st).A); + var alg = RltlAlgebra.Default; + var formula = alg.Globally(alg.And( + alg.Eventually(Rltl.Atom(new StatePredAtom(aProp))), + alg.Eventually(alg.NegAtom(new StatePredAtom(aProp))))); + + var withBp = SymbolicRltlCheck.Check(s0, formula, mergeWeakEquivalentBp: true); + Assert.That(withBp.Valid, Is.True); + } + + /// + /// Soundness: G(Fa ∧ F¬a) fails on a model that eventually + /// stops emitting a, even with bisim minimisation applied, + /// and a counterexample trace is reported. + /// + [Test] + public void GFaFNa_EventuallyStuckModel_InvalidUnderBpMerge() + { + var s0 = JacmExample51EndToEndTests_Helpers.MakeNode("s0", a: true); + var s1 = JacmExample51EndToEndTests_Helpers.MakeNode("s1", a: false); + JacmExample51EndToEndTests_Helpers.AddEdge(s0, s1); + JacmExample51EndToEndTests_Helpers.AddEdge(s1, s1); + + var aProp = new StateProp("a", + st => ((JacmExample51EndToEndTests_Helpers.TestState)st).A); + var alg = RltlAlgebra.Default; + var formula = alg.Globally(alg.And( + alg.Eventually(Rltl.Atom(new StatePredAtom(aProp))), + alg.Eventually(alg.NegAtom(new StatePredAtom(aProp))))); + + var withBp = SymbolicRltlCheck.Check(s0, formula, mergeWeakEquivalentBp: true); + Assert.That(withBp.Valid, Is.False); + Assert.That(withBp.Trace, Is.Not.Null); + } + } + + /// + /// Shared test-state helpers replicating the minimal model-program + /// scaffold used by . + /// + internal static class JacmExample51EndToEndTests_Helpers + { + public sealed class TestState : State + { + public string Label { get; } + public bool A { get; } + public TestState(string label, bool a) { Label = label; A = a; } + protected override void CloneInternal(Dictionary map) + => map[this] = new TestState(Label, A); + protected override void LockComponents(HashSet visited) { } + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) => Label; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class TestStep : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + public TestStep(string id) { StepFunctionId = id; StepFunctionIdHash = id.GetHashCode(); } + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + public static StateGraphNode MakeNode(string label, bool a) + { + var s = new TestState(label, a); + s.Freeze(); + return new StateGraphNode + { + State = s, + StepFunctions = new List { new TestStep("step") }, + Edges = new List() + }; + } + + public static void AddEdge(StateGraphNode from, StateGraphNode to) + => from.Edges.Add(new StateGraphEdge { Target = to, StepFunction = new TestStep("step") }); + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistryPropositionTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistryPropositionTests.cs new file mode 100644 index 0000000..858e3bc --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistryPropositionTests.cs @@ -0,0 +1,120 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// EREQ Phase-1 tests: proposition support in + /// . Propositions live + /// in a separate index range (strictly negative) from predicates + /// (non-negative), so the two streams never collide. + /// + [TestFixture] + public class ConditionRegistryPropositionTests + { + private sealed class P + { + public string Name { get; } + public P(string name) { Name = name; } + public override bool Equals(object obj) => obj is P o && o.Name == Name; + public override int GetHashCode() => Name.GetHashCode(); + public override string ToString() => Name; + } + + [Test] + public void RegisterProposition_AllocatesNegativeIndicesInOrder() + { + var r = new ConditionRegistry

(); + Assert.That(r.RegisterProposition("p"), Is.EqualTo(-1)); + Assert.That(r.RegisterProposition("q"), Is.EqualTo(-2)); + Assert.That(r.RegisterProposition("s"), Is.EqualTo(-3)); + Assert.That(r.PropositionCount, Is.EqualTo(3)); + } + + [Test] + public void RegisterProposition_IsIdempotentOnRepeatedName() + { + var r = new ConditionRegistry

(); + var first = r.RegisterProposition("p"); + var again = r.RegisterProposition("p"); + Assert.That(again, Is.EqualTo(first)); + Assert.That(r.PropositionCount, Is.EqualTo(1)); + } + + [Test] + public void PredicateAndPropositionStreamsAreDisjoint() + { + var r = new ConditionRegistry

(); + var pred0 = r.Register(new P("a")); + var prop0 = r.RegisterProposition("p"); + var pred1 = r.Register(new P("b")); + var prop1 = r.RegisterProposition("q"); + + Assert.That(pred0, Is.EqualTo(0)); + Assert.That(pred1, Is.EqualTo(1)); + Assert.That(prop0, Is.EqualTo(-1)); + Assert.That(prop1, Is.EqualTo(-2)); + + Assert.That(ConditionRegistry

.IsProposition(pred0), Is.False); + Assert.That(ConditionRegistry

.IsProposition(pred1), Is.False); + Assert.That(ConditionRegistry

.IsProposition(prop0), Is.True); + Assert.That(ConditionRegistry

.IsProposition(prop1), Is.True); + } + + [Test] + public void GetPropositionName_RoundTripsRegisteredNames() + { + var r = new ConditionRegistry

(); + var pIdx = r.RegisterProposition("p"); + var qIdx = r.RegisterProposition("q"); + Assert.That(r.GetPropositionName(pIdx), Is.EqualTo("p")); + Assert.That(r.GetPropositionName(qIdx), Is.EqualTo("q")); + } + + [Test] + public void GetPropositionName_ThrowsOnNonNegativeIndex() + { + var r = new ConditionRegistry

(); + r.Register(new P("a")); + Assert.Throws(() => r.GetPropositionName(0)); + } + + [Test] + public void IndexOfProposition_ReturnsZeroSentinelWhenNotRegistered() + { + var r = new ConditionRegistry

(); + r.RegisterProposition("p"); + Assert.That(r.IndexOfProposition("p"), Is.EqualTo(-1)); + Assert.That(r.IndexOfProposition("nope"), Is.EqualTo(0)); + } + + [Test] + public void RegisterProposition_ThrowsOverMaxPropositions() + { + var r = new ConditionRegistry

(); + for (int i = 0; i < ConditionRegistry

.MaxPropositions; i++) + r.RegisterProposition("p" + i); + Assert.Throws(() => r.RegisterProposition("overflow")); + } + + [Test] + public void IsProposition_StaticHelperMatchesSign() + { + Assert.That(ConditionRegistry

.IsProposition(-1), Is.True); + Assert.That(ConditionRegistry

.IsProposition(-64), Is.True); + Assert.That(ConditionRegistry

.IsProposition(0), Is.False); + Assert.That(ConditionRegistry

.IsProposition(7), Is.False); + } + + [Test] + public void Propositions_EnumerationOrderMatchesRegistration() + { + var r = new ConditionRegistry

(); + r.RegisterProposition("p"); + r.RegisterProposition("q"); + r.RegisterProposition("s"); + Assert.That(r.Propositions, Is.EqualTo(new[] { "p", "q", "s" })); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistrySolverAwareTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistrySolverAwareTests.cs new file mode 100644 index 0000000..f1ab5df --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/ConditionRegistrySolverAwareTests.cs @@ -0,0 +1,125 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + ///

+ /// Tests for the solver-aware predicate-level union-find in + /// . When an + /// is provided to the registry, + /// structurally-distinct predicates that are reported equivalent by + /// are aliased to a single + /// condition index — the predicate-level analogue of the regex-level + /// canonicalisation in . + /// + [TestFixture] + public class ConditionRegistrySolverAwareTests + { + /// + /// Predicate with reference-only equality, named for debugging. + /// Structurally distinct instances always compare unequal even when + /// they carry the same set of integers; the only way to alias them + /// is via the EBA's . + /// + private sealed class OpaquePredicate + { + public string Name { get; } + public HashSet Elements { get; } + public OpaquePredicate(string name, params int[] elements) + { + Name = name; + Elements = new HashSet(elements); + } + public override string ToString() => Name; + // Default Equals/GetHashCode = reference-based: distinct + // instances never compare structurally equal. + } + + private sealed class OpaqueEba : IPredicateAlgebraEx + { + public OpaquePredicate Top { get; } = new OpaquePredicate("⊤", 0, 1, 2); + public OpaquePredicate Bottom { get; } = new OpaquePredicate("⊥"); + + public OpaquePredicate And(OpaquePredicate a, OpaquePredicate b) + { + var set = new HashSet(a.Elements); set.IntersectWith(b.Elements); + var arr = new int[set.Count]; int k = 0; + foreach (var e in set) arr[k++] = e; + return new OpaquePredicate($"({a.Name}∧{b.Name})", arr); + } + public OpaquePredicate Or(OpaquePredicate a, OpaquePredicate b) + { + var set = new HashSet(a.Elements); set.UnionWith(b.Elements); + var arr = new int[set.Count]; int k = 0; + foreach (var e in set) arr[k++] = e; + return new OpaquePredicate($"({a.Name}∨{b.Name})", arr); + } + public OpaquePredicate Not(OpaquePredicate a) + { + var set = new HashSet { 0, 1, 2 }; set.ExceptWith(a.Elements); + var arr = new int[set.Count]; int k = 0; + foreach (var e in set) arr[k++] = e; + return new OpaquePredicate($"¬{a.Name}", arr); + } + public bool IsSatisfiable(OpaquePredicate p) => p.Elements.Count > 0; + public bool AreEquivalent(OpaquePredicate a, OpaquePredicate b) + => a.Elements.SetEquals(b.Elements); + public bool Implies(OpaquePredicate a, OpaquePredicate b) + => a.Elements.IsSubsetOf(b.Elements); + } + + [Test] + public void Plain_Registry_DoesNotAliasOpaquePredicates() + { + var r = new ConditionRegistry(); + var p1 = new OpaquePredicate("p1", 0, 1); + var p2 = new OpaquePredicate("p2", 0, 1); // same elements, different instance + var i1 = r.Register(p1); + var i2 = r.Register(p2); + Assert.That(i2, Is.Not.EqualTo(i1)); + Assert.That(r.Count, Is.EqualTo(2)); + Assert.That(r.SolverAliasCount, Is.EqualTo(0)); + } + + [Test] + public void SolverAware_Registry_AliasesEquivalentPredicates() + { + var eba = new OpaqueEba(); + var r = new ConditionRegistry(null, eba); + var p1 = new OpaquePredicate("p1", 0, 1); + var p2 = new OpaquePredicate("p2", 0, 1); // semantically equal to p1 + var p3 = new OpaquePredicate("p3", 0, 2); // distinct + + var i1 = r.Register(p1); + var i2 = r.Register(p2); + var i3 = r.Register(p3); + + Assert.That(i2, Is.EqualTo(i1), "p1 and p2 must alias to a single index."); + Assert.That(i3, Is.Not.EqualTo(i1)); + Assert.That(r.Count, Is.EqualTo(2)); + Assert.That(r.SolverAliasCount, Is.EqualTo(1)); + } + + [Test] + public void SolverAware_Registry_RepeatedQueries_AreO1Cached() + { + var eba = new OpaqueEba(); + var r = new ConditionRegistry(null, eba); + var p1 = new OpaquePredicate("p1", 0); + var p2 = new OpaquePredicate("p2", 0); + + var i1a = r.Register(p1); + var i1b = r.Register(p1); + var i2a = r.Register(p2); + var i2b = r.Register(p2); + + Assert.That(i1a, Is.EqualTo(i1b)); + Assert.That(i2a, Is.EqualTo(i2b)); + Assert.That(i1a, Is.EqualTo(i2a)); + // Only one solver-aware alias was recorded (the first time p2 + // was registered); subsequent p2 calls hit the structural cache. + Assert.That(r.SolverAliasCount, Is.EqualTo(1)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EbaExtensionsTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EbaExtensionsTests.cs new file mode 100644 index 0000000..8833dbb --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EbaExtensionsTests.cs @@ -0,0 +1,142 @@ +using Microsoft.Accordant.ModelChecking; + +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for : capability-probing + /// extension methods over / + /// . Covers both the + /// Ex-interface dispatch path and the + /// IsSatisfiable-based fallback. + /// + [TestFixture] + public class EbaExtensionsTests + { + [Test] + public void AreEquivalent_DispatchesToExWhenAvailable() + { + var eba = new IntEba(4); + var a = new IntPredicate("a", 0, 1); + var b = new IntPredicate("b", 1, 0); + Assert.IsTrue(eba.AreEquivalent(a, b)); + Assert.IsFalse(eba.AreEquivalent(a, new IntPredicate("c", 0))); + } + + [Test] + public void Implies_DispatchesToExWhenAvailable() + { + var eba = new IntEba(4); + var a = new IntPredicate("a", 0, 1); + var b = new IntPredicate("b", 0, 1, 2); + Assert.IsTrue(eba.Implies(a, b)); + Assert.IsFalse(eba.Implies(b, a)); + } + + [Test] + public void TryGetModel_DispatchesToExWhenAvailable() + { + var eba = new IntEba(4); + var sat = new IntPredicate("p", 2, 3); + Assert.IsTrue(eba.TryGetModel(sat, out var m)); + Assert.IsTrue(eba.Models(m, sat)); + + Assert.IsFalse(eba.TryGetModel(eba.Bottom, out _)); + } + + [Test] + public void AreEquivalent_FallbackUsesIsSatisfiable() + { + var wrap = new HidingEba(new IntEba(4)); + var a = new IntPredicate("a", 1, 2); + var b = new IntPredicate("b", 2, 1); + Assert.IsTrue(wrap.AreEquivalent(a, b)); + Assert.IsFalse(wrap.AreEquivalent(a, new IntPredicate("c", 1))); + } + + [Test] + public void Implies_FallbackUsesIsSatisfiable() + { + var wrap = new HidingEba(new IntEba(4)); + var a = new IntPredicate("a", 0); + var b = new IntPredicate("b", 0, 1); + Assert.IsTrue(wrap.Implies(a, b)); + Assert.IsFalse(wrap.Implies(b, a)); + } + + [Test] + public void TryGetModel_FallbackReturnsFalse() + { + var wrap = new HidingEba(new IntEba(4)); + var sat = new IntPredicate("p", 2); + Assert.IsFalse(wrap.TryGetModel(sat, out var m)); + Assert.AreEqual(default(int), m); + } + + /// + /// Wraps an EBA but does NOT implement the Ex interfaces, forcing + /// the extension methods onto their fallback paths. + /// + private sealed class HidingEba : IEffectiveBooleanAlgebra + { + private readonly IEffectiveBooleanAlgebra _inner; + public HidingEba(IEffectiveBooleanAlgebra inner) { _inner = inner; } + public TP Top => _inner.Top; + public TP Bottom => _inner.Bottom; + public TP And(TP a, TP b) => _inner.And(a, b); + public TP Or(TP a, TP b) => _inner.Or(a, b); + public TP Not(TP a) => _inner.Not(a); + public bool IsSatisfiable(TP p) => _inner.IsSatisfiable(p); + public bool Models(TE element, TP p) => _inner.Models(element, p); + } + } + + /// + /// Tests for the EBA-aware overload. + /// + [TestFixture] + public class EreWitnessMaterialiseExTests + { + [Test] + public void Materialise_UsesEbaTryGetModel() + { + var eba = new IntEba(4); + var p0 = new IntPredicate("p0", 0, 1); + var p1 = new IntPredicate("p1", 2); + // Push: head = last pushed; ToForward reverses, so forward = [p0, p1]. + var reversed = ConsList.Empty.Push(p0).Push(p1); + var concrete = EreWitness.Materialise(reversed, eba); + Assert.AreEqual(2, concrete.Count); + Assert.IsTrue(eba.Models(concrete[0], p0)); + Assert.AreEqual(2, concrete[1]); + } + + [Test] + public void Materialise_FallsBackToChooseModelWhenEbaCannotModel() + { + var wrap = new ForwardingEba(new IntEba(4)); + var p = new IntPredicate("p", 1, 2); + var reversed = ConsList.Empty.Push(p); + var concrete = EreWitness.Materialise(reversed, wrap, + chooseModel: pred => 1); + Assert.AreEqual(1, concrete.Count); + Assert.AreEqual(1, concrete[0]); + } + + private sealed class ForwardingEba : IEffectiveBooleanAlgebra + { + private readonly IntEba _inner; + public ForwardingEba(IntEba inner) { _inner = inner; } + public IntPredicate Top => _inner.Top; + public IntPredicate Bottom => _inner.Bottom; + public IntPredicate And(IntPredicate a, IntPredicate b) => _inner.And(a, b); + public IntPredicate Or(IntPredicate a, IntPredicate b) => _inner.Or(a, b); + public IntPredicate Not(IntPredicate a) => _inner.Not(a); + public bool IsSatisfiable(IntPredicate p) => _inner.IsSatisfiable(p); + public bool Models(int e, IntPredicate p) => _inner.Models(e, p); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreComplementPushThroughTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreComplementPushThroughTests.cs new file mode 100644 index 0000000..c886abb --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreComplementPushThroughTests.cs @@ -0,0 +1,103 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + using System.Linq; + + /// + /// Pins the predicate-algebra-aware complement push-through rewrites + /// (Phase 12 / Rust EREQ port P2.4): COMPL-12 and COMPL-13. + /// + [TestFixture] + public class EreComplementPushThroughTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + private static Ere SigmaStar() + => Ere.Star(Ere.Sigma()); + + [OneTimeSetUp] + public void RegisterAlgebra() + { + Ere.DefaultBuilder.RegisterAlgebra(StatePropEba.Instance); + } + + [Test] + public void Compl_StartsWithP_RewritesToEps_Or_NotP_Sigma() + { + // ~([a] · Σ*) = ε | [¬a] · Σ* + var a = Atom(Pa); + var input = Ere.Concat(a, SigmaStar()); + var compl = Ere.Complement(input); + + // Expected shape: a Union with two operands: ε and [¬a]·Σ*. + Assert.That(compl, Is.InstanceOf>(), + $"expected a union, got {compl}"); + var u = (EreUnion)compl; + Assert.That(u.Operands.Count, Is.EqualTo(2)); + Assert.That(u.Operands.Any(o => o is EreEpsilon), Is.True, + "expected ε to be one of the union operands"); + // The other operand should be a Concat whose left atom predicate is ¬a. + var concat = u.Operands.OfType>().SingleOrDefault(); + Assert.That(concat, Is.Not.Null, "expected one [¬a]·Σ* concat"); + Assert.That(concat.Left, Is.InstanceOf>()); + var negAtom = (EreAtom)concat.Left; + Assert.That(negAtom.Predicate, Is.InstanceOf(), + "expected negated predicate"); + } + + [Test] + public void Compl_ContainsP_RewritesToNotP_Star() + { + // ~(Σ* · [a] · Σ*) = [¬a]* + var a = Atom(Pa); + var input = Ere.Concat( + SigmaStar(), + Ere.Concat(a, SigmaStar())); + var compl = Ere.Complement(input); + + Assert.That(compl, Is.InstanceOf>(), + $"expected a star, got {compl}"); + var star = (EreStar)compl; + Assert.That(star.Inner, Is.InstanceOf>()); + var atom = (EreAtom)star.Inner; + Assert.That(atom.Predicate, Is.InstanceOf(), + "star inner should be the negated atom predicate"); + } + + [Test] + public void Compl_Idempotent_DoubleNegationCancels() + { + // The new rules must not break the existing ~~R = R cancellation: + // double-complementing the contains-p shape returns it verbatim. + var a = Atom(Pa); + var input = Ere.Concat( + SigmaStar(), + Ere.Concat(a, SigmaStar())); + var doubleCompl = Ere.Complement( + Ere.Complement(input)); + // ~([¬a]*) is itself the contains-p shape; ~~R should land back at R + // semantically — pin equivalence via the model checker rather than AST. + // Cheaper structural check: re-complementing [¬a]* should yield a + // form whose Nullable, MinLen, MaxLen match the original. + Assert.That(doubleCompl.Nullable, Is.EqualTo(input.Nullable)); + Assert.That(doubleCompl.MinLen, Is.EqualTo(input.MinLen)); + } + + [Test] + public void Compl_NonMatchingShape_LeavesComplementWrapped() + { + // Concat that is *not* the canonical starts-with-p or contains-p + // shape should fall through and produce an EreComplement wrapper. + var a = Atom(Pa); + var input = Ere.Concat(a, a); // [a]·[a] + var compl = Ere.Complement(input); + Assert.That(compl, Is.InstanceOf>(), + "non-matching shapes should fall through to the EreComplement constructor"); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreDerivativePreciseEquivalenceTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreDerivativePreciseEquivalenceTests.cs new file mode 100644 index 0000000..86bfbee --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreDerivativePreciseEquivalenceTests.cs @@ -0,0 +1,184 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for + /// — the precise (CAV'26-bisim-backed) language-equivalence oracle that + /// upgrades canonical-rep aliasing beyond the cheap signature check. + /// + [TestFixture] + public class EreDerivativePreciseEquivalenceTests + { + private sealed class Prop : IEquatable, IComparable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public bool Equals(Prop other) => other != null && Name == other.Name; + public override bool Equals(object obj) => Equals(obj as Prop); + public override int GetHashCode() => Name.GetHashCode(); + public int CompareTo(Prop other) => string.Compare(Name, other?.Name, StringComparison.Ordinal); + public override string ToString() => Name; + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top { get; } = new Prop("⊤"); + public Prop Bottom { get; } = new Prop("⊥"); + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + public bool IsSatisfiable(Prop p) => p.Name != "⊥"; + public bool Models(HashSet e, Prop p) + { + if (p.Name == "⊤") return true; + if (p.Name == "⊥") return false; + if (p.Name.StartsWith("¬")) return !e.Contains(p.Name.Substring(1)); + return e.Contains(p.Name); + } + } + + private static EreDerivative> NewDeriv() + { + var eba = new PropEba(); + var reg = new ConditionRegistry(); + return new EreDerivative>(eba, reg); + } + + private static Ere A => Ere.Atom(new Prop("a")); + private static Ere B => Ere.Atom(new Prop("b")); + + [Test] + public void Precise_AgreesWithSignature_OnTriviallyEqualAndDistinct() + { + var d = NewDeriv(); + Assert.That(d.AreEquivalentPrecise(A, A), Is.True); + Assert.That(d.AreEquivalentPrecise(A, B), Is.False); + } + + [Test] + public void Precise_StrictlyMorePowerful_ThanSignature() + { + // Find a pair the cheap check misses but the precise check catches. + // (a + b)* ≡ (b + a)* — these ARE caught by ACI canonicalisation, + // so try harder: a*·a* ≡ a* vs cheap signature. + var d = NewDeriv(); + var aStar = Ere.Star(A); + var aStarTwice = Ere.Concat(aStar, aStar); + // We don't know whether the cheap check catches this — but the + // precise check MUST. + Assert.That(d.AreEquivalentPrecise(aStarTwice, aStar), Is.True); + } + + [Test] + public void Precise_DetectsKleeneUnfold() + { + // a* ≡ ε + a·a* — the constructor often folds this, but if we + // build it via a structurally-distinct path the precise check + // still confirms. + var d = NewDeriv(); + var aStar = Ere.Star(A); + var unfolded = Ere.Union(Ere.Epsilon(), + Ere.Concat(A, aStar)); + Assert.That(d.AreEquivalentPrecise(aStar, unfolded), Is.True); + } + + [Test] + public void Precise_AliasesCanonicalRep() + { + var d = NewDeriv(); + // Build two language-equivalent but structurally distinct forms. + var aStar = Ere.Star(A); + var aStarTwice = Ere.Concat(aStar, aStar); + // Force derivatives so both are in the cache. + d.Derivative(aStar); + d.Derivative(aStarTwice); + + int repBefore_aStar = d.CanonicalRepresentative(aStar); + int repBefore_two = d.CanonicalRepresentative(aStarTwice); + // If the signature didn't already merge them, exercise the + // precise oracle and verify the canonical-rep map is updated. + if (repBefore_aStar != repBefore_two) + { + Assert.That(d.AreEquivalentPrecise(aStar, aStarTwice), Is.True); + Assert.That(d.CanonicalRepresentative(aStar), + Is.EqualTo(d.CanonicalRepresentative(aStarTwice))); + } + } + + [Test] + public void Precise_NotEquivalent_DoesNotAlias() + { + var d = NewDeriv(); + Assert.That(d.AreEquivalentPrecise(A, B), Is.False); + Assert.That(d.CanonicalRepresentative(A), + Is.Not.EqualTo(d.CanonicalRepresentative(B))); + } + + [Test] + public void Precise_IsSymmetric_AndCached() + { + var d = NewDeriv(); + var aStar = Ere.Star(A); + var unfolded = Ere.Union(Ere.Epsilon(), + Ere.Concat(A, aStar)); + Assert.That(d.AreEquivalentPrecise(aStar, unfolded), Is.True); + // Reverse order — same answer, served from cache. + Assert.That(d.AreEquivalentPrecise(unfolded, aStar), Is.True); + } + + [Test] + public void Precise_TransitiveAliasing_ThroughCanonicalRep() + { + // After aliasing a≡b and b≡c, CanonicalRepresentative(a) == + // CanonicalRepresentative(c) (transitivity via path-compression). + // Construct three pairwise-equivalent regex forms. + var d = NewDeriv(); + var r1 = Ere.Star(A); // a* + var r2 = Ere.Concat(Ere.Star(A), Ere.Star(A)); // a*·a* + var r3 = Ere.Star(Ere.Star(A)); // (a*)* + + Assert.That(d.AreEquivalentPrecise(r1, r2), Is.True); + Assert.That(d.AreEquivalentPrecise(r2, r3), Is.True); + // Transitivity: + int rep1 = d.CanonicalRepresentative(r1); + int rep3 = d.CanonicalRepresentative(r3); + Assert.That(rep1, Is.EqualTo(rep3)); + } + + [Test] + public void Precise_DistinguishesInequivalent_AfterEquivalentAlias() + { + // Aliasing a*·a* ≡ a* should NOT mistakenly equate a* and b*. + var d = NewDeriv(); + var aStar = Ere.Star(A); + var aStarTwice = Ere.Concat(aStar, aStar); + var bStar = Ere.Star(B); + Assert.That(d.AreEquivalentPrecise(aStar, aStarTwice), Is.True); + Assert.That(d.AreEquivalentPrecise(aStar, bStar), Is.False); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceCheckerTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceCheckerTests.cs new file mode 100644 index 0000000..f6264d5 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceCheckerTests.cs @@ -0,0 +1,249 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class EreEquivalenceCheckerTests + { + // Reuse the same Prop/PropEba setup as EreTests. + private sealed class Prop : IEquatable, IComparable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public bool Equals(Prop other) => other != null && Name == other.Name; + public override bool Equals(object obj) => Equals(obj as Prop); + public override int GetHashCode() => Name.GetHashCode(); + public int CompareTo(Prop other) => string.Compare(Name, other?.Name, StringComparison.Ordinal); + public override string ToString() => Name; + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top { get; } = new Prop("⊤"); + public Prop Bottom { get; } = new Prop("⊥"); + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + public bool IsSatisfiable(Prop p) => p.Name != "⊥"; + public bool Models(HashSet e, Prop p) + { + if (p.Name == "⊤") return true; + if (p.Name == "⊥") return false; + if (p.Name.StartsWith("¬")) return !e.Contains(p.Name.Substring(1)); + return e.Contains(p.Name); + } + } + + private static Ere A => Ere.Atom(new Prop("a")); + private static Ere B => Ere.Atom(new Prop("b")); + private static Ere C => Ere.Atom(new Prop("c")); + + private EreEquivalenceChecker> NewChecker() + { + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + return new EreEquivalenceChecker>(deriv); + } + + [Test] + public void Trivial_StructuralEquality() + { + var c = NewChecker(); + Assert.That(c.AreEquivalent(A, A), Is.True); + Assert.That(c.AreEquivalent(Ere.Empty(), Ere.Empty()), Is.True); + Assert.That(c.AreEquivalent(Ere.Epsilon(), Ere.Epsilon()), Is.True); + } + + [Test] + public void Trivial_DistinctAtoms() + { + var c = NewChecker(); + Assert.That(c.AreEquivalent(A, B), Is.False); + } + + [Test] + public void Empty_NotEquivalentToEpsilon() + { + var c = NewChecker(); + // L(∅) = ∅, L(ε) = {""} — differ on ε. + Assert.That(c.AreEquivalent(Ere.Empty(), Ere.Epsilon()), Is.False); + } + + [Test] + public void UnionACI_IsHandledByConstructor() + { + // After ACI-normalisation Union(A,B) ≡ Union(B,A) structurally; + // the checker should still confirm equivalence (trivially). + var c = NewChecker(); + var ab = Ere.Union(A, B); + var ba = Ere.Union(B, A); + Assert.That(c.AreEquivalent(ab, ba), Is.True); + } + + [Test] + public void StarAbsorption_EquivalentForms() + { + // a* ≡ ε ∨ a·a* (Kleene's identity). + // Constructor folds a·a* + ε into a* (R + R·R* = R·R* + R; with ε → a*). + // So this is mostly a constructor check; the checker is a no-op here. + var c = NewChecker(); + var aStar = Ere.Star(A); + var unfolded = Ere.Union(Ere.Epsilon(), + Ere.Concat(A, aStar)); + Assert.That(c.AreEquivalent(aStar, unfolded), Is.True); + } + + [Test] + public void ConcatAssociativity_Equivalent() + { + // (a·b)·c ≡ a·(b·c) — both normalise to right-assoc form via the + // Concat factory. Checker confirms. + var c = NewChecker(); + var l = Ere.Concat(Ere.Concat(A, B), C); + var r = Ere.Concat(A, Ere.Concat(B, C)); + Assert.That(c.AreEquivalent(l, r), Is.True); + } + + [Test] + public void StarUnion_BothSides_Equivalent() + { + // (a + b)* ≡ (b + a)* — trivially via ACI of Union. + var c = NewChecker(); + var ab = Ere.Star(Ere.Union(A, B)); + var ba = Ere.Star(Ere.Union(B, A)); + Assert.That(c.AreEquivalent(ab, ba), Is.True); + } + + [Test] + public void DenotationallyEquivalent_StructurallyDifferent() + { + // a* vs (a + ε)* — should be equivalent (ε* contributes nothing). + // The Star factory folds (R + ε)* → R* so these end up structurally + // identical. Test that the checker confirms equivalence. + var c = NewChecker(); + var aStar = Ere.Star(A); + var aOptStar = Ere.Star(Ere.Union(A, Ere.Epsilon())); + Assert.That(c.AreEquivalent(aStar, aOptStar), Is.True); + + // Truly different (a* vs (a·a)*) — must report inequivalent. + var aaStar = Ere.Star(Ere.Concat(A, A)); + Assert.That(c.AreEquivalent(aStar, aaStar), Is.False); + } + + [Test] + public void DeMorganEquivalent_ViaComplement() + { + // ~(a + b) ≡ ~a ∩ ~b + // The Complement factory pushes De Morgan inward (per the prior + // regex-rewrites pass), so these should canonicalise to the same + // form. The checker should confirm. + var c = NewChecker(); + var lhs = Ere.Complement(Ere.Union(A, B)); + var rhs = Ere.Intersect(Ere.Complement(A), Ere.Complement(B)); + Assert.That(c.AreEquivalent(lhs, rhs), Is.True); + } + + [Test] + public void Subsumption_PvsPunionQ_Inequivalent() + { + // p ≢ p ∨ q when L(q) ⊄ L(p). + // Bisim drives through the XOR, reaches a leaf showing q-witness alive. + var c = NewChecker(); + var pq = Ere.Union(A, B); + Assert.That(c.AreEquivalent(A, pq), Is.False); + } + + [Test] + public void PaperShowcase_RepetitionVsAtom_NotEquivalent() + { + // a ≢ a + (a·a) — quick analogue of the paper's + // "a vs a | a{10000}" non-equivalence detection via the + // emptiness-fallthrough optimisation. The XOR derivative + // produces leaves like (∅ ⊕ ε) = ε (nullable) on the second + // step, so the checker concludes False fast. + var c = NewChecker(); + var aa = Ere.Concat(A, A); + var r = Ere.Union(A, aa); + Assert.That(c.AreEquivalent(A, r), Is.False); + } + + [Test] + public void PaperShowcase_LongRepetition_NotEquivalent() + { + // The actual paper showcase: a ≢ a + a^N for large N. + // The bisim alone would need ~N steps; the emptiness fall-through + // detects non-equivalence in constant time (the residual + // ∅ ⊕ a^k is non-empty for k > 0, alive immediately). + // We test moderate N to keep the test fast. + var c = NewChecker(); + const int N = 50; + Ere repeated = A; + for (int i = 1; i < N; i++) repeated = Ere.Concat(repeated, A); + var r = Ere.Union(A, repeated); + Assert.That(c.AreEquivalent(A, r), Is.False); + } + + [Test] + public void IntersectionWithUniverse_IsIdentity() + { + // R ∩ Σ* ≡ R — handled by Intersect factory (Σ* unit), trivial. + var c = NewChecker(); + var aStar = Ere.Star(A); + var withSigma = Ere.Intersect(aStar, Ere.Sigma()); + Assert.That(c.AreEquivalent(aStar, withSigma), Is.True); + } + + [Test] + public void Complement_DoubleNegation() + { + // ~~R ≡ R — already collapsed by Complement factory. + var c = NewChecker(); + var aStar = Ere.Star(A); + Assert.That( + c.AreEquivalent(aStar, + Ere.Complement(Ere.Complement(aStar))), + Is.True); + } + + [Test] + public void IsLanguageEmpty_OfIntersection() + { + // a ∩ ε ≡ ∅ (a needs one letter, ε needs zero letters). + var c = NewChecker(); + var aWithEps = Ere.Intersect(A, Ere.Epsilon()); + Assert.That(c.IsLanguageEmpty(aWithEps), Is.True); + } + + [Test] + public void IsLanguageEmpty_OfStar_False() + { + // L(a*) ⊇ {""} → not empty. + var c = NewChecker(); + Assert.That(c.IsLanguageEmpty(Ere.Star(A)), Is.False); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceDifferentialOracleTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceDifferentialOracleTests.cs new file mode 100644 index 0000000..6304ebd --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreEquivalenceDifferentialOracleTests.cs @@ -0,0 +1,262 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Differential oracle for ERE equivalence: every test case is decided + /// two independent ways and required to agree with each other AND with + /// the labelled expected answer. + /// + /// Method A: + /// — the CAV'26 bisimulation algorithm. + /// + /// Method B: two-way language-subsumption via emptiness — + /// L(p) = L(q) ⇔ L(p ∩ ¬q) = ∅ ∧ L(q ∩ ¬p) = ∅. Both halves + /// are decided with . + /// + /// The two methods exercise entirely different machinery + /// (bisim + union-find + EreXor canonicalisation vs. + /// intersect + complement + plain BFS over derivatives), so + /// disagreement reliably exposes a bug in one of them. + /// + [TestFixture] + public class EreEquivalenceDifferentialOracleTests + { + public sealed class Prop : IEquatable, IComparable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public bool Equals(Prop other) => other != null && Name == other.Name; + public override bool Equals(object obj) => Equals(obj as Prop); + public override int GetHashCode() => Name.GetHashCode(); + public int CompareTo(Prop other) => string.Compare(Name, other?.Name, StringComparison.Ordinal); + public override string ToString() => Name; + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top { get; } = new Prop("⊤"); + public Prop Bottom { get; } = new Prop("⊥"); + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + // (¬x) ∧ x → ⊥, x ∧ (¬x) → ⊥ + if (a.Name == "¬" + b.Name || b.Name == "¬" + a.Name) return Bottom; + return new Prop($"({a.Name}∧{b.Name})"); + } + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + if (a.Name == "¬" + b.Name || b.Name == "¬" + a.Name) return Top; + return new Prop($"({a.Name}∨{b.Name})"); + } + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + public bool IsSatisfiable(Prop p) => p.Name != "⊥"; + public bool Models(HashSet e, Prop p) + { + if (p.Name == "⊤") return true; + if (p.Name == "⊥") return false; + if (p.Name.StartsWith("¬")) return !e.Contains(p.Name.Substring(1)); + return e.Contains(p.Name); + } + } + + private static Ere Atom(string n) => Ere.Atom(new Prop(n)); + private static readonly Ere A = Atom("a"); + private static readonly Ere B = Atom("b"); + private static readonly Ere C = Atom("c"); + private static readonly Ere Eps = Ere.Epsilon(); + private static readonly Ere Bot = Ere.Empty(); + private static Ere Star(Ere r) => Ere.Star(r); + private static Ere Plus(params Ere[] xs) + { + var r = xs[0]; + for (int i = 1; i < xs.Length; i++) r = Ere.Union(r, xs[i]); + return r; + } + private static Ere Cat(params Ere[] xs) + { + var r = xs[xs.Length - 1]; + for (int i = xs.Length - 2; i >= 0; i--) r = Ere.Concat(xs[i], r); + return r; + } + private static Ere Not(Ere r) => Ere.Complement(r); + private static Ere Cap(Ere a, Ere b) => Ere.Intersect(a, b); + + // A test "case": label, p, q, expected equivalence. + public sealed class Case + { + public string Label { get; } + public Ere P { get; } + public Ere Q { get; } + public bool ExpectedEquivalent { get; } + public Case(string label, Ere p, Ere q, bool eq) + { Label = label; P = p; Q = q; ExpectedEquivalent = eq; } + public override string ToString() => Label; + } + + public static IEnumerable AllCases() + { + // ---- Equivalent pairs ---- + yield return new Case("identity: a = a", A, A, true); + yield return new Case("identity: ε = ε", Eps, Eps, true); + yield return new Case("identity: ⊥ = ⊥", Bot, Bot, true); + yield return new Case("Union ACI: a+b = b+a", + Plus(A, B), Plus(B, A), true); + yield return new Case("Union ACI 3: (a+b)+c = a+(b+c)", + Plus(Plus(A, B), C), Plus(A, Plus(B, C)), true); + yield return new Case("Union idempotence: a+a = a", + Plus(A, A), A, true); + yield return new Case("Concat assoc: (a·b)·c = a·(b·c)", + Cat(Cat(A, B), C), Cat(A, Cat(B, C)), true); + yield return new Case("Kleene unfold: a* = ε + a·a*", + Star(A), Plus(Eps, Cat(A, Star(A))), true); + yield return new Case("Right unfold: a* = ε + a*·a", + Star(A), Plus(Eps, Cat(Star(A), A)), true); + yield return new Case("Star idempotence: (a*)* = a*", + Star(Star(A)), Star(A), true); + yield return new Case("Star of (r+ε): (a+ε)* = a*", + Star(Plus(A, Eps)), Star(A), true); + yield return new Case("Star fusion: (a*·b*)* = (a+b)*", + Star(Cat(Star(A), Star(B))), Star(Plus(A, B)), true); + yield return new Case("Star-of-ε = ε", Star(Eps), Eps, true); + yield return new Case("Star-of-⊥ = ε", Star(Bot), Eps, true); + yield return new Case("ε·a = a", Cat(Eps, A), A, true); + yield return new Case("a·ε = a", Cat(A, Eps), A, true); + yield return new Case("⊥·a = ⊥", Cat(Bot, A), Bot, true); + yield return new Case("Distributivity: a·(b+c) = a·b + a·c", + Cat(A, Plus(B, C)), Plus(Cat(A, B), Cat(A, C)), true); + yield return new Case("Distributivity right: (a+b)·c = a·c + b·c", + Cat(Plus(A, B), C), Plus(Cat(A, C), Cat(B, C)), true); + yield return new Case("Double complement: ¬¬a = a", + Not(Not(A)), A, true); + yield return new Case("De Morgan: ¬(a+b) = ¬a ∩ ¬b", + Not(Plus(A, B)), Cap(Not(A), Not(B)), true); + yield return new Case("De Morgan: ¬(a∩b) = ¬a + ¬b", + Not(Cap(A, B)), Plus(Not(A), Not(B)), true); + yield return new Case("Σ* = ¬⊥", Ere.Sigma(), Not(Bot), true); + yield return new Case("a ∩ Σ* = a", Cap(A, Ere.Sigma()), A, true); + yield return new Case("a ∩ ⊥ = ⊥", Cap(A, Bot), Bot, true); + yield return new Case("a ∩ a = a", Cap(A, A), A, true); + yield return new Case("Sliding: a·(b·a)* = (a·b)*·a", + Cat(A, Star(Cat(B, A))), Cat(Star(Cat(A, B)), A), true); + yield return new Case("Star concat: a*·a* = a*", + Cat(Star(A), Star(A)), Star(A), true); + + // ---- Inequivalent pairs ---- + yield return new Case("distinct atoms: a ≠ b", A, B, false); + yield return new Case("a ≠ ε", A, Eps, false); + yield return new Case("a ≠ ⊥", A, Bot, false); + yield return new Case("ε ≠ ⊥", Eps, Bot, false); + yield return new Case("a* ≠ b*", Star(A), Star(B), false); + yield return new Case("a* ≠ (a·a)*", + Star(A), Star(Cat(A, A)), false); + yield return new Case("(a+b)* ≠ a* + b*", + Star(Plus(A, B)), Plus(Star(A), Star(B)), false); + yield return new Case("(a·b)* ≠ a*·b*", + Star(Cat(A, B)), Cat(Star(A), Star(B)), false); + yield return new Case("a·b ≠ b·a", + Cat(A, B), Cat(B, A), false); + yield return new Case("a·b* ≠ a*·b", + Cat(A, Star(B)), Cat(Star(A), B), false); + yield return new Case("a ≠ a+b", A, Plus(A, B), false); + yield return new Case("a+b ≠ a∩b (when both nonempty, distinct)", + Plus(A, B), Cap(A, B), false); + yield return new Case("¬a ≠ a", Not(A), A, false); + // Σ* ≠ ε (Σ* contains all words, ε only the empty one) + yield return new Case("Σ* ≠ ε", Ere.Sigma(), Eps, false); + + // ---- Subtle equivalences (good stress for the algorithm) ---- + yield return new Case("a+ε+a·a* = a*", + Plus(Plus(A, Eps), Cat(A, Star(A))), Star(A), true); + yield return new Case("(a+b)* = (a*+b*)*", + Star(Plus(A, B)), Star(Plus(Star(A), Star(B))), true); + yield return new Case("(a*·b)* ·a* = (a+b)*", + Cat(Star(Cat(Star(A), B)), Star(A)), Star(Plus(A, B)), true); + yield return new Case("Idempotent intersection with star: a* ∩ a* = a*", + Cap(Star(A), Star(A)), Star(A), true); + yield return new Case("Star of union absorbs Σ*: (a+Σ*)* = Σ*", + Star(Plus(A, Ere.Sigma())), Ere.Sigma(), true); + } + + private (EreEquivalenceChecker> equiv, + EreEmptinessChecker> empt) + MakeCheckers() + { + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + var empt = new EreEmptinessChecker>(deriv); + var equiv = new EreEquivalenceChecker>(deriv, empt); + return (equiv, empt); + } + + // L(p) ⊆ L(q) ⇔ L(p ∩ ¬q) = ∅ + private static bool Subsumes(EreEmptinessChecker> empt, + Ere p, Ere q) + => empt.IsDead(Cap(p, Not(q))); + + private static bool SubsumptionEquivalent( + EreEmptinessChecker> empt, Ere p, Ere q) + => Subsumes(empt, p, q) && Subsumes(empt, q, p); + + public static IEnumerable Cases() + { + foreach (var c in AllCases()) + yield return new TestCaseData(c).SetName(c.Label); + } + + [TestCaseSource(nameof(Cases))] + public void Bisim_AgreesWithSubsumption_AndWithExpected(Case c) + { + // Fresh checkers per case so accidental cross-case caching cannot + // mask a bug. + var (equiv, empt) = MakeCheckers(); + + bool bisim = equiv.AreEquivalent(c.P, c.Q); + bool subsumption = SubsumptionEquivalent(empt, c.P, c.Q); + + Assert.Multiple(() => + { + Assert.That(bisim, Is.EqualTo(c.ExpectedEquivalent), + $"bisim disagrees with expected for '{c.Label}'"); + Assert.That(subsumption, Is.EqualTo(c.ExpectedEquivalent), + $"subsumption disagrees with expected for '{c.Label}'"); + Assert.That(bisim, Is.EqualTo(subsumption), + $"bisim and subsumption disagree for '{c.Label}' " + + $"(bisim={bisim}, subsumption={subsumption})"); + }); + } + + // Witness-shape sanity: when the bisim says "inequivalent", a witness + // must be returned (non-null). Full semantic verification of the + // witness against L(P) △ L(Q) requires a more precise EBA than the + // toy used here (compound (a∧¬b) predicates aren't fully decided by + // string-name IsSatisfiable), so that check lives in EreWitnessTests + // with hand-picked element instantiations. + [TestCaseSource(nameof(Cases))] + public void Witness_IsReturnedForInequivalentPairs(Case c) + { + if (c.ExpectedEquivalent) Assert.Ignore("only meaningful for inequivalent pairs"); + var (equiv, _) = MakeCheckers(); + Assert.That(equiv.AreInequivalent(c.P, c.Q, out var w), Is.True); + Assert.That(w, Is.Not.Null, $"missing witness for '{c.Label}'"); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreFreePropsTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreFreePropsTests.cs new file mode 100644 index 0000000..af47d43 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreFreePropsTests.cs @@ -0,0 +1,68 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// EREQ Phase-1 (D2) tests: + /// metadata defaults to zero for all current node shapes (no + /// proposition atom exists yet — added in Phase 2), and + /// maps negative proposition + /// indices to the correct bit position. + /// + [TestFixture] + public class EreFreePropsTests + { + private sealed class P + { + public string Name { get; } + public P(string n) { Name = n; } + public override bool Equals(object obj) => obj is P o && o.Name == Name; + public override int GetHashCode() => Name.GetHashCode(); + public override string ToString() => Name; + } + + [Test] + public void BitForProp_MapsNegativeIndicesToCorrectBit() + { + Assert.That(Ere

.BitForProp(-1), Is.EqualTo(1UL)); + Assert.That(Ere

.BitForProp(-2), Is.EqualTo(2UL)); + Assert.That(Ere

.BitForProp(-3), Is.EqualTo(4UL)); + Assert.That(Ere

.BitForProp(-64), Is.EqualTo(1UL << 63)); + } + + [Test] + public void BitForProp_ThrowsOnNonNegativeIndex() + { + Assert.Throws(() => Ere

.BitForProp(0)); + Assert.Throws(() => Ere

.BitForProp(7)); + } + + [Test] + public void BitForProp_ThrowsBeyondCap() + { + Assert.Throws(() => Ere

.BitForProp(-65)); + } + + [Test] + public void FreeProps_IsZeroForCurrentShapes() + { + // Without proposition atoms (added in Phase 2), every term + // has empty FreeProps. The OR-composition contract is + // exercised in Phase 2 tests once proposition atoms exist. + var a = Ere

.Atom(new P("a")); + var b = Ere

.Atom(new P("b")); + Assert.That(Ere

.Empty().FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Epsilon().FreeProps, Is.EqualTo(0UL)); + Assert.That(a.FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Concat(a, b).FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Union(a, b).FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Intersect(a, b).FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Complement(a).FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Star(a).FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Xor(a, b).FreeProps, Is.EqualTo(0UL)); + Assert.That(Ere

.Fusion(a, b).FreeProps, Is.EqualTo(0UL)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreJsonTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreJsonTests.cs new file mode 100644 index 0000000..bdeaef3 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreJsonTests.cs @@ -0,0 +1,76 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class EreJsonTests + { + private static Ere A => Ere.Atom("a"); + private static Ere B => Ere.Atom("b"); + + private static void RoundTrip(Ere r) + { + var json = EreJson.Serialize(r); + var back = EreJson.Deserialize(json); + Assert.That(back.Equals(r), Is.True, + $"Round-trip failed. JSON: {json}\nOriginal: {r}\nGot: {back}"); + } + + [Test] public void Empty() => RoundTrip(Ere.Empty()); + [Test] public void Epsilon() => RoundTrip(Ere.Epsilon()); + [Test] public void Atom_Simple() => RoundTrip(A); + [Test] public void Concat_AB() => RoundTrip(Ere.Concat(A, B)); + [Test] public void Union_AB() => RoundTrip(Ere.Union(A, B)); + [Test] public void Intersect_AB() => RoundTrip(Ere.Intersect(A, B)); + [Test] public void Star_A() => RoundTrip(Ere.Star(A)); + [Test] public void Complement_A() => RoundTrip(Ere.Complement(A)); + [Test] public void Fusion_AB() => RoundTrip(Ere.Fusion(A, B)); + + [Test] + public void Xor_AB() + { + var r = Ere.Xor(A, B); + // r might be canonicalized — round-trip whatever it is. + RoundTrip(r); + } + + [Test] + public void Complex_Nested() + { + // (a · b*) ∪ ~(a ∩ b) + var r = Ere.Union( + Ere.Concat(A, Ere.Star(B)), + Ere.Complement(Ere.Intersect(A, B))); + RoundTrip(r); + } + + [Test] + public void Sugar_Plus_DeserializeOnly() + { + var r = EreJson.Deserialize("{\"op\":\"Plus\",\"inner\":{\"op\":\"Atom\",\"pred\":\"a\"}}"); + Assert.That(r.Equals(Ere.Plus(A)), Is.True); + } + + [Test] + public void Sugar_Optional_DeserializeOnly() + { + var r = EreJson.Deserialize("{\"op\":\"Optional\",\"inner\":{\"op\":\"Atom\",\"pred\":\"a\"}}"); + Assert.That(r.Equals(Ere.Optional(A)), Is.True); + } + + [Test] + public void Sugar_Sigma_DeserializeOnly() + { + var r = EreJson.Deserialize("{\"op\":\"Sigma\"}"); + Assert.That(r.Equals(Ere.Sigma()), Is.True); + } + + [Test] + public void PredicateWithQuotesAndBackslashes() + { + var r = Ere.Atom("p\"q\\r"); + RoundTrip(r); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreLengthBoundsTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreLengthBoundsTests.cs new file mode 100644 index 0000000..7f4d038 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreLengthBoundsTests.cs @@ -0,0 +1,134 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + ///

+ /// Pins the per-node length bounds (Phase 12 / Rust EREQ port P2.2) + /// and the intersection unsat-pruning that consumes them. Mirrors + /// Rust EREQ get_min_max_len (lib.rs:999–1038) and the + /// intersect-disjoint-length-interval rewrite. + /// + [TestFixture] + public class EreLengthBoundsTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + [Test] + public void Leaves_HaveExpectedLengthBounds() + { + var eps = Ere.Epsilon(); + Assert.That(eps.MinLen, Is.EqualTo(0)); + Assert.That(eps.MaxLen, Is.EqualTo(0)); + + var a = Atom(Pa); + Assert.That(a.MinLen, Is.EqualTo(1)); + Assert.That(a.MaxLen, Is.EqualTo(1)); + } + + [Test] + public void Concat_AddsBounds() + { + var a = Atom(Pa); + var b = Atom(Pb); + var ab = Ere.Concat(a, b); + Assert.That(ab.MinLen, Is.EqualTo(2)); + Assert.That(ab.MaxLen, Is.EqualTo(2)); + } + + [Test] + public void Star_HasZeroToInfinity() + { + var a = Atom(Pa); + var aStar = Ere.Star(a); + Assert.That(aStar.MinLen, Is.EqualTo(0)); + Assert.That(aStar.MaxLen, Is.EqualTo(int.MaxValue)); + } + + [Test] + public void Union_TakesMinOfMinsAndMaxOfMaxes() + { + var a = Atom(Pa); // (1,1) + var ab = Ere.Concat(a, Atom(Pb)); // (2,2) + var u = Ere.Union(a, ab); + Assert.That(u.MinLen, Is.EqualTo(1)); + Assert.That(u.MaxLen, Is.EqualTo(2)); + } + + [Test] + public void Intersect_TakesMaxOfMinsAndMinOfMaxes() + { + // (a + a·b) ∩ (a·b + a·b·b) ⇒ shared length range [2, 2] + var a = Atom(Pa); + var b = Atom(Pb); + var ab = Ere.Concat(a, b); + var abb = Ere.Concat(ab, b); + var left = Ere.Union(a, ab); // (1, 2) + var right = Ere.Union(ab, abb); // (2, 3) + var inter = Ere.Intersect(left, right); + // intersection length interval is (max(1,2), min(2,3)) = (2,2); + // not pruned, but bounds should agree. + Assert.That(inter.MinLen, Is.GreaterThanOrEqualTo(2)); + Assert.That(inter.MaxLen, Is.LessThanOrEqualTo(3)); + } + + [Test] + public void Intersect_DisjointLengths_PrunesToEmpty() + { + // a·b has length exactly 2; a alone has length exactly 1. + // Their length intervals are disjoint → intersection is empty. + var a = Atom(Pa); + var ab = Ere.Concat(a, Atom(Pb)); + var inter = Ere.Intersect(a, ab); + Assert.That(inter, Is.InstanceOf>(), + "intersect of (a) with (a·b) should prune to ∅ via length bounds"); + } + + [Test] + public void Intersect_DisjointLengths_LongerCase_PrunesToEmpty() + { + // a·b ∩ a·b·b : disjoint lengths (2 vs 3) ⇒ ∅ + var a = Atom(Pa); + var b = Atom(Pb); + var ab = Ere.Concat(a, b); + var abb = Ere.Concat(ab, b); + var inter = Ere.Intersect(ab, abb); + Assert.That(inter, Is.InstanceOf>()); + } + + [Test] + public void Intersect_OverlappingLengths_NotPruned() + { + // a* ∩ a·b : a* has (0,∞), a·b has (2,2). Overlap at 2. + // Intersect should not collapse to ∅ via length bounds. + var a = Atom(Pa); + var ab = Ere.Concat(a, Atom(Pb)); + var aStar = Ere.Star(a); + var inter = Ere.Intersect(aStar, ab); + // Note: the language *is* empty since a* contains no b, but + // length bounds alone cannot detect that. We assert only + // that length-bound pruning did not fire. + Assert.That(inter, Is.Not.InstanceOf>(), + "length bounds should not prune when intervals overlap"); + } + + [Test] + public void Fusion_SubtractsOneFromConcatLength() + { + // fusion glues the boundary letter: len(R:S) = len(R)+len(S)-1. + var a = Atom(Pa); + var b = Atom(Pb); + // (a·b) : (b·a) → length 2 + 2 - 1 = 3 + var ab = Ere.Concat(a, b); + var ba = Ere.Concat(b, a); + var f = Ere.Fusion(ab, ba); + Assert.That(f.MinLen, Is.EqualTo(3)); + Assert.That(f.MaxLen, Is.EqualTo(3)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreMetadataTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreMetadataTests.cs new file mode 100644 index 0000000..ae8378e --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreMetadataTests.cs @@ -0,0 +1,112 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Pins the per-node metadata cached eagerly at construction + /// (Phase 12 / Rust EREQ port P1.1–P1.3 + P1.6): + /// ContainsCompl, ContainsInter, ContainsExists, + /// Cost, and the derived IsDefinitelyAlive approximation. + /// + [TestFixture] + public class EreMetadataTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + [Test] + public void Leaves_HaveZeroFlags_AndCostOne() + { + var empty = Ere.Empty(); + var eps = Ere.Epsilon(); + var a = Atom(Pa); + + foreach (var leaf in new Ere[] { empty, eps, a }) + { + Assert.That(leaf.ContainsCompl, Is.False, $"{leaf} should not contain complement"); + Assert.That(leaf.ContainsInter, Is.False, $"{leaf} should not contain intersect"); + Assert.That(leaf.ContainsExists, Is.False, $"{leaf} should not contain exists"); + Assert.That(leaf.Cost, Is.EqualTo(1), $"{leaf} cost should be 1"); + } + + // Empty is excluded from "definitely alive" because it denotes ∅. + Assert.That(empty.IsDefinitelyAlive, Is.False); + Assert.That(eps.IsDefinitelyAlive, Is.True); + Assert.That(a.IsDefinitelyAlive, Is.True); + } + + [Test] + public void StandardFragment_IsAlive_AndFlagsZero() + { + // a·(a+ε)* — fully in the standard fragment (no ~, no ∩, no ∃) + var a = Atom(Pa); + var r = Ere.Concat(a, + Ere.Star( + Ere.Union(a, Ere.Epsilon()))); + + Assert.That(r.ContainsCompl, Is.False); + Assert.That(r.ContainsInter, Is.False); + Assert.That(r.ContainsExists, Is.False); + Assert.That(r.IsDefinitelyAlive, Is.True); + Assert.That(r.Cost, Is.GreaterThan(1)); + } + + [Test] + public void Complement_PropagatesContainsCompl_BlocksAliveFastPath() + { + var a = Atom(Pa); + var nota = Ere.Complement(a); + + Assert.That(nota.ContainsCompl, Is.True); + Assert.That(nota.ContainsInter, Is.False); + Assert.That(nota.IsDefinitelyAlive, Is.False, + "presence of complement should block the standard-fragment alive fast-path"); + + // Containing-complement bit propagates upward through Concat/Union/Star. + var wrapped = Ere.Star( + Ere.Concat(a, nota)); + Assert.That(wrapped.ContainsCompl, Is.True); + Assert.That(wrapped.IsDefinitelyAlive, Is.False); + } + + [Test] + public void Intersect_PropagatesContainsInter_BlocksAliveFastPath() + { + var a = Atom(Pa); + var b = Atom(Pb); + var ab = Ere.Intersect(a, b); + + Assert.That(ab.ContainsInter, Is.True); + Assert.That(ab.ContainsCompl, Is.False); + Assert.That(ab.IsDefinitelyAlive, Is.False, + "presence of intersection should block the standard-fragment alive fast-path"); + + var wrapped = Ere.Union(a, ab); + Assert.That(wrapped.ContainsInter, Is.True); + Assert.That(wrapped.IsDefinitelyAlive, Is.False); + } + + [Test] + public void Cost_SumsChildrenPlusOne() + { + // a·b should have cost 1+1+1 = 3. + var a = Atom(Pa); + var b = Atom(Pb); + var ab = Ere.Concat(a, b); + Assert.That(ab.Cost, Is.EqualTo(3)); + + // (a+b)·a → distributes via Concat-over-Union into (a·a + b·a): + // each branch cost 3, union cost 1+3+3 = 7. The factory's + // left-distribution rebuilds the term, so use the resulting + // structure to validate cost is composed from interned children. + var rebuilt = Ere.Concat( + Ere.Union(a, b), a); + Assert.That(rebuilt.Cost, Is.GreaterThan(1)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/ErePredicateStarRewriteTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/ErePredicateStarRewriteTests.cs new file mode 100644 index 0000000..fb9b3b9 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/ErePredicateStarRewriteTests.cs @@ -0,0 +1,134 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + using System.Linq; + + /// + /// Pins the predicate-star intersection-distribution rewrite (Phase 12 / + /// Rust EREQ port P3.2, lib.rs:2604–2612): + /// [p]* ∩ R·S ≡ ([p]*∩R) · ([p]*∩S) + /// + [TestFixture] + public class EreIntersectPredicateStarDistribTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + [OneTimeSetUp] + public void RegisterAlgebra() + { + Ere.DefaultBuilder.RegisterAlgebra(StatePropEba.Instance); + } + + [Test] + public void PStar_Inter_Concat_DistributesOverConcat() + { + // [a]* ∩ ([a]·[a]) → ([a]*∩[a]) · ([a]*∩[a]) → [a]·[a] + // The result should be a Concat (the distributed form), not an + // EreIntersect wrapper around the original Concat. + var a = Atom(Pa); + var aStar = Ere.Star(a); + var aa = Ere.Concat(a, a); + var result = Ere.Intersect(aStar, aa); + Assert.That(result, Is.Not.InstanceOf>(), + $"distribution should remove the outer EreIntersect wrapper, got {result}"); + // Semantic check: result should accept exactly the length-2 'aa'. + Assert.That(result.MinLen, Is.EqualTo(2)); + Assert.That(result.MaxLen, Is.EqualTo(2)); + } + + [Test] + public void PStar_Inter_Concat_OfDifferentAtom_PrunesToEmpty() + { + // [a]* ∩ ([b]·[b]) → ([a]*∩[b]) · ([a]*∩[b]) + // [a]* ∩ [b] is the empty language (b cannot be a — but the + // pure-symbolic StatePropEba treats a and b as independent + // satisfiable atoms, so it cannot prove emptiness at the EBA + // level. Just check the distribution fired (no outer + // EreIntersect of the form [a]* ∩ b·b). + var a = Atom(Pa); + var b = Atom(Pb); + var aStar = Ere.Star(a); + var bb = Ere.Concat(b, b); + var result = Ere.Intersect(aStar, bb); + Assert.That(result, Is.Not.InstanceOf>(), + "no EBA-level emptiness for independent atoms; distribution still applies"); + Assert.That(result, Is.Not.InstanceOf>(), + $"distribution should remove the outer Intersect wrapper, got {result}"); + } + } + + /// + /// Pins the predicate-star union merge (Phase 12 / Rust EREQ port P3.3, + /// lib.rs:2057–2061): [p]* | [q]* ≡ (p|q)* via the registered + /// predicate algebra. + /// + [TestFixture] + public class EreUnionPredicateStarMergeTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + [OneTimeSetUp] + public void RegisterAlgebra() + { + Ere.DefaultBuilder.RegisterAlgebra(StatePropEba.Instance); + } + + [Test] + public void PStar_Union_PStar_MergesViaAlgebraOr() + { + // [a]* | [b]* → (a|b)* + var aStar = Ere.Star(Atom(Pa)); + var bStar = Ere.Star(Atom(Pb)); + var u = Ere.Union(aStar, bStar); + Assert.That(u, Is.InstanceOf>(), + $"expected a single Star, got {u}"); + var s = (EreStar)u; + Assert.That(s.Inner, Is.InstanceOf>(), + "merged star inner should be a single Atom carrying (a ⊔ b)"); + var atom = (EreAtom)s.Inner; + Assert.That(atom.Predicate, Is.InstanceOf(), + "merged predicate should be the algebra Or"); + } + + [Test] + public void PStar_Union_SamePred_DedupedNotDoubled() + { + // [a]* | [a]* → [a]* (already handled by SortedSet dedup, + // but pin the joint behaviour with the new merge rule). + var aStar = Ere.Star(Atom(Pa)); + var u = Ere.Union(aStar, aStar); + Assert.That(u, Is.SameAs(aStar)); + } + + [Test] + public void PStar_Union_NonPredicateStar_LeftAlone() + { + // [a]* | ([a]·[b])* : the second operand's inner is a Concat, + // not an Atom, so the P3.3 merge must NOT fire and the union + // should remain a 2-operand EreUnion (or some other safe form). + var a = Atom(Pa); + var b = Atom(Pb); + var aStar = Ere.Star(a); + var abStar = Ere.Star(Ere.Concat(a, b)); + var u = Ere.Union(aStar, abStar); + // Either an EreUnion of two stars, or something else - but + // crucially NOT a single Star (which would imply unsound merge). + if (u is EreStar singleStar) + { + // Only safe if it's specifically the [a]* operand. + Assert.That(singleStar, Is.SameAs(aStar), + "if collapsed to a single star, must be the [a]* operand only"); + } + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreTests.cs new file mode 100644 index 0000000..0c6abdd --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreTests.cs @@ -0,0 +1,712 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class EreTests + { + private sealed class Prop : IEquatable, IComparable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public override string ToString() => Name; + public override int GetHashCode() => Name.GetHashCode(); + public override bool Equals(object obj) => Equals(obj as Prop); + public bool Equals(Prop other) => other != null && Name == other.Name; + public int CompareTo(Prop other) => string.Compare(Name, other?.Name, StringComparison.Ordinal); + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top => new Prop("⊤"); + public Prop Bottom => new Prop("⊥"); + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + public bool IsSatisfiable(Prop p) => p.Name != "⊥"; + public bool Models(HashSet e, Prop p) + { + if (p.Name == "⊤") return true; + if (p.Name == "⊥") return false; + if (p.Name.StartsWith("¬")) return !e.Contains(p.Name.Substring(1)); + return e.Contains(p.Name); + } + } + + private static Ere A => Ere.Atom(new Prop("a")); + private static Ere B => Ere.Atom(new Prop("b")); + + [Test] + public void Nullable_BasicCases() + { + Assert.That(Ere.Empty().Nullable, Is.False); + Assert.That(Ere.Epsilon().Nullable, Is.True); + Assert.That(A.Nullable, Is.False); + Assert.That(Ere.Star(A).Nullable, Is.True); + Assert.That(Ere.Concat(A, B).Nullable, Is.False); + Assert.That(Ere.Concat(Ere.Star(A), Ere.Star(B)).Nullable, Is.True); + Assert.That(Ere.Union(A, Ere.Epsilon()).Nullable, Is.True); + Assert.That(Ere.Intersect(A, Ere.Star(A)).Nullable, Is.False); + Assert.That(Ere.Complement(Ere.Epsilon()).Nullable, Is.False); + Assert.That(Ere.Complement(A).Nullable, Is.True); + } + + [Test] + public void Simplifications_Concat() + { + Assert.That(Ere.Concat(Ere.Empty(), A), Is.EqualTo(Ere.Empty())); + Assert.That(Ere.Concat(A, Ere.Empty()), Is.EqualTo(Ere.Empty())); + Assert.That(Ere.Concat(Ere.Epsilon(), A), Is.EqualTo(A)); + Assert.That(Ere.Concat(A, Ere.Epsilon()), Is.EqualTo(A)); + } + + [Test] + public void Simplifications_Star() + { + Assert.That(Ere.Star(Ere.Empty()), Is.EqualTo(Ere.Epsilon())); + Assert.That(Ere.Star(Ere.Epsilon()), Is.EqualTo(Ere.Epsilon())); + var aStar = Ere.Star(A); + Assert.That(Ere.Star(aStar), Is.EqualTo(aStar)); + } + + [Test] + public void Simplifications_Complement_Involutive() + { + var aStar = Ere.Star(A); + Assert.That(Ere.Complement(Ere.Complement(aStar)), Is.EqualTo(aStar)); + } + + [Test] + public void Simplifications_Union_Idempotent() + { + Assert.That(Ere.Union(A, A), Is.EqualTo(A)); + Assert.That(Ere.Union(A, Ere.Empty()), Is.EqualTo(A)); + Assert.That(Ere.Union(A, Ere.Sigma()), Is.EqualTo(Ere.Sigma())); + } + + [Test] + public void Simplifications_Intersect_Idempotent() + { + Assert.That(Ere.Intersect(A, A), Is.EqualTo(A)); + Assert.That(Ere.Intersect(A, Ere.Empty()), Is.EqualTo(Ere.Empty())); + Assert.That(Ere.Intersect(A, Ere.Sigma()), Is.EqualTo(A)); + } + + [Test] + public void Simplifications_ComplementaryLanguage() + { + // R ∩ ~R = ∅ + Assert.That( + Ere.Intersect(A, Ere.Complement(A)), + Is.EqualTo(Ere.Empty())); + // R + ~R = Σ* + Assert.That( + Ere.Union(A, Ere.Complement(A)), + Is.EqualTo(Ere.Sigma())); + // Survives flattening: A + B + ~A = Σ* + Assert.That( + Ere.Union(A, Ere.Union(B, Ere.Complement(A))), + Is.EqualTo(Ere.Sigma())); + } + + [Test] + public void Simplifications_Intersect_StarAbsorption() + { + // R ∩ R* = R (dual of the union-side R + R* = R*). + Assert.That( + Ere.Intersect(A, Ere.Star(A)), + Is.EqualTo(A)); + } + + [Test] + public void Simplifications_Intersect_Epsilon() + { + // ε ∩ R = ε if R nullable, ∅ otherwise. + Assert.That( + Ere.Intersect(Ere.Epsilon(), A), + Is.EqualTo(Ere.Empty())); + Assert.That( + Ere.Intersect(Ere.Epsilon(), Ere.Star(A)), + Is.EqualTo(Ere.Epsilon())); + } + + [Test] + public void Simplifications_DeMorgan() + { + // ~(R + S) = ~R ∩ ~S + var lhs1 = Ere.Complement(Ere.Union(A, B)); + var rhs1 = Ere.Intersect(Ere.Complement(A), Ere.Complement(B)); + Assert.That(lhs1, Is.EqualTo(rhs1)); + // ~(R ∩ S) = ~R + ~S + var lhs2 = Ere.Complement(Ere.Intersect(A, B)); + var rhs2 = Ere.Union(Ere.Complement(A), Ere.Complement(B)); + Assert.That(lhs2, Is.EqualTo(rhs2)); + } + + [Test] + public void Derivative_Memoisation_ReusesResult() + { + // Repeated calls on the same Ere instance must return the same + // hash-consed TransitionTerm (reference equality). + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var r = Ere.Concat(Ere.Star(A), B); + var d1 = deriv.Derivative(r); + var d2 = deriv.Derivative(r); + Assert.That(ReferenceEquals(d1, d2), Is.True, + "Memoised derivative should return the same instance."); + } + + [Test] + public void Derivative_Equivalence_DetectedViaBehaviorSignature() + { + // a + b and b + a should canonicalize to the same Ere already + // (ACI in Union) — so the test just sanity-checks AreEquivalent + // on regexes that are syntactically distinct but derivative-equal. + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + // (A + B) and (A + B + ∅) — the ∅ is dropped by Union.Create, so + // these end up as the same Id. Use them as a smoke test. + var u1 = Ere.Union(A, B); + var u2 = Ere.Union(Ere.Union(A, B), Ere.Empty()); + Assert.That(deriv.AreEquivalent(u1, u2), Is.True); + + // (A·B*) and (A · (B*·B*)) — the latter normalises to (A·B*) via + // the R*·R* = R* rewrite. Equivalent at the Ere level too. + var bStar = Ere.Star(B); + var r1 = Ere.Concat(A, bStar); + var r2 = Ere.Concat(A, Ere.Concat(bStar, bStar)); + Assert.That(deriv.AreEquivalent(r1, r2), Is.True); + + // Sanity: clearly inequivalent regexes are not equivalent. + Assert.That(deriv.AreEquivalent(A, B), Is.False); + } + + [Test] + public void Derivative_Atom() + { + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var dA = deriv.Derivative(A); + // Evaluate against a-true and a-false elements + var aTrue = new HashSet { "a" }; + var aFalse = new HashSet(); + Assert.That(dA.Evaluate(aTrue, reg, eba), Is.EqualTo(Ere.Epsilon())); + Assert.That(dA.Evaluate(aFalse, reg, eba), Is.EqualTo(Ere.Empty())); + } + + [Test] + public void Derivative_Star_PreservesSelf() + { + // ∂(a*) on letter 'a' should give ε · a* = a* (concat simplification) + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var aStar = Ere.Star(A); + var d = deriv.Derivative(aStar); + var aTrue = new HashSet { "a" }; + Assert.That(d.Evaluate(aTrue, reg, eba), Is.EqualTo(aStar)); + // On non-a, should give ∅ · a* = ∅ + Assert.That(d.Evaluate(new HashSet(), reg, eba), Is.EqualTo(Ere.Empty())); + } + + [Test] + public void Derivative_Concat_NullableLeft() + { + // ∂(a* · b) on 'a' = (∂(a*) · b) ∨ ∂(b) since a* is nullable + // = a* · b ∨ ∅ = a*·b (on 'a') + // on 'b' = (∂(a*)·b on 'b': ∅·b=∅) ∨ (∂(b) on 'b': ε) = ε + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var r = Ere.Concat(Ere.Star(A), B); + var d = deriv.Derivative(r); + var aOnly = new HashSet { "a" }; + var bOnly = new HashSet { "b" }; + Assert.That(d.Evaluate(aOnly, reg, eba), Is.EqualTo(r)); + // b alone: a* doesn't match, so ∂(a*·b)=ε + Assert.That(d.Evaluate(bOnly, reg, eba), Is.EqualTo(Ere.Epsilon())); + } + + [Test] + public void Derivative_Union() + { + // ∂(a + b) on 'a' = ε ∨ ∅ = ε + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var d = deriv.Derivative(Ere.Union(A, B)); + Assert.That(d.Evaluate(new HashSet { "a" }, reg, eba), Is.EqualTo(Ere.Epsilon())); + Assert.That(d.Evaluate(new HashSet { "b" }, reg, eba), Is.EqualTo(Ere.Epsilon())); + Assert.That(d.Evaluate(new HashSet(), reg, eba), Is.EqualTo(Ere.Empty())); + } + [Test] + public void Simplifications_Star_StarConcatStar() + { + // R* · R* ≡ R* + var aStar = Ere.Star(A); + Assert.That(Ere.Concat(aStar, aStar), Is.EqualTo(aStar)); + + // R* · (R* · X) ≡ R* · X — right-associated form + var rhs = Ere.Concat(aStar, B); + Assert.That(Ere.Concat(aStar, rhs), Is.EqualTo(rhs)); + + // Σ* · Σ* ≡ Σ* (special case of the above) + var sigma = Ere.Sigma(); + var sigmaStar = Ere.Star(sigma); + Assert.That(Ere.Concat(sigmaStar, sigmaStar), Is.EqualTo(sigmaStar)); + } + + [Test] + public void Simplifications_Concat_NormalisesToRightAssociated() + { + // (a·b)·c, a·(b·c), and the same with deeper left-leaning + // structures all normalise to the canonical right-associated + // form via the Concat factory's recursive rewrite + // (x·y)·z → x·(y·z) + // Hash-cons interning then guarantees reference equality, so + // trivially-equivalent associativity variants collapse to a + // single Ere node rather than two structurally-distinct atoms. + var C = Ere.Atom(new Prop("c")); + var D = Ere.Atom(new Prop("d")); + + // (a·b)·c vs a·(b·c) + var ab_c = Ere.Concat(Ere.Concat(A, B), C); + var a_bc = Ere.Concat(A, Ere.Concat(B, C)); + Assert.That(ab_c, Is.SameAs(a_bc), + "(a·b)·c and a·(b·c) must hash-cons to the same instance"); + + // (a·b)·(c·d) vs a·(b·(c·d)) + var ab_cd = Ere.Concat( + Ere.Concat(A, B), + Ere.Concat(C, D)); + var a_b_cd = Ere.Concat(A, + Ere.Concat(B, Ere.Concat(C, D))); + Assert.That(ab_cd, Is.SameAs(a_b_cd), + "(a·b)·(c·d) must collapse to a·(b·(c·d))"); + + // Deep left-leaning: ((a·b)·c)·d vs a·(b·(c·d)) + var abc_d = Ere.Concat( + Ere.Concat(Ere.Concat(A, B), C), D); + Assert.That(abc_d, Is.SameAs(a_b_cd), + "((a·b)·c)·d must collapse to a·(b·(c·d))"); + + // Structural witness: the top-level Left is the atomic A. + Assert.That(((EreConcat)a_b_cd).Left, Is.SameAs(A), + "right-associated form has an atomic Left at the top"); + } + + [Test] + public void Simplifications_Union_StarAbsorbs() + { + // R + R* ≡ R* + var aStar = Ere.Star(A); + Assert.That(Ere.Union(A, aStar), Is.EqualTo(aStar)); + + // ε + R* ≡ R* + Assert.That(Ere.Union(Ere.Epsilon(), aStar), Is.EqualTo(aStar)); + + // R·R* + R* ≡ R* (R+ ⊆ R*) + var rPlus = Ere.Concat(A, aStar); + Assert.That(Ere.Union(rPlus, aStar), Is.EqualTo(aStar)); + + // Other operands survive the absorption. + var bStar = Ere.Star(B); + var u = Ere.Union(A, Ere.Union(aStar, bStar)); + Assert.That(u, Is.EqualTo(Ere.Union(aStar, bStar))); + } + + [Test] + public void Simplifications_Star_DropsEpsilon() + { + // (R + ε)* ≡ R* + var rPlusEps = Ere.Union(A, Ere.Epsilon()); + Assert.That(Ere.Star(rPlusEps), Is.EqualTo(Ere.Star(A))); + + // (R + S + ε)* ≡ (R + S)* + var union = Ere.Union(Ere.Union(A, B), Ere.Epsilon()); + Assert.That(Ere.Star(union), Is.EqualTo(Ere.Star(Ere.Union(A, B)))); + } + + // ---------- Fusion (Section 7.3, JACM extension) ---------- + + [Test] + public void Fusion_Simplifications_Boundary() + { + // ∅ : R = ∅, R : ∅ = ∅ + Assert.That(Ere.Fusion(Ere.Empty(), A), Is.EqualTo(Ere.Empty())); + Assert.That(Ere.Fusion(A, Ere.Empty()), Is.EqualTo(Ere.Empty())); + // ε : R = ∅, R : ε = ∅ — fusion requires a shared letter, ε has none. + Assert.That(Ere.Fusion(Ere.Epsilon(), A), Is.EqualTo(Ere.Empty())); + Assert.That(Ere.Fusion(A, Ere.Epsilon()), Is.EqualTo(Ere.Empty())); + } + + [Test] + public void Fusion_Nullable_AlwaysFalse() + { + // nullable(R : S) = false (eq. 31) + var aStar = Ere.Star(A); + var bStar = Ere.Star(B); + Assert.That(Ere.Fusion(aStar, bStar).Nullable, Is.False); + } + + [Test] + public void OneStep_BasicCases() + { + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + // OneStep(∅) = ⊥, OneStep(ε) = ⊥ + Assert.That(deriv.OneStep(Ere.Empty()).Name, Is.EqualTo("⊥")); + Assert.That(deriv.OneStep(Ere.Epsilon()).Name, Is.EqualTo("⊥")); + // OneStep(p) = p + Assert.That(deriv.OneStep(A).Name, Is.EqualTo("a")); + // OneStep(R*) = OneStep(R) + Assert.That(deriv.OneStep(Ere.Star(A)).Name, Is.EqualTo("a")); + // OneStep(R + S) = OneStep(R) ∨ OneStep(S) + Assert.That(deriv.OneStep(Ere.Union(A, B)).Name, Is.EqualTo("(a∨b)")); + // OneStep(R · S): only the nullable factor's OneStep flows through. + // OneStep(a · b) = ⊥ (neither side nullable) + Assert.That(deriv.OneStep(Ere.Concat(A, B)).Name, Is.EqualTo("⊥")); + // OneStep(a* · b) = ⊥ ∨ b = b (a* nullable, b not nullable) + Assert.That(deriv.OneStep(Ere.Concat(Ere.Star(A), B)).Name, Is.EqualTo("b")); + // OneStep(R : S) = OneStep(R) ∧ OneStep(S) + Assert.That(deriv.OneStep(Ere.Fusion(A, B)).Name, Is.EqualTo("(a∧b)")); + } + + [Test] + public void Fusion_Derivative_SingleLetter() + { + // For atoms a, b: L(a : b) = { v | v=a (length 1, i=0), v[..0]=a∈L(a), v[0..]=a∈L(b) } + // = single-letter words satisfying a ∧ b. + // Hence ∂(a:b) on { a,b } = ε (accepting) + // ∂(a:b) on { a } = ∅ + // ∂(a:b) on { b } = ∅ + // ∂(a:b) on { } = ∅ + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var d = deriv.Derivative(Ere.Fusion(A, B)); + Assert.That(d.Evaluate(new HashSet { "a", "b" }, reg, eba), Is.EqualTo(Ere.Epsilon())); + Assert.That(d.Evaluate(new HashSet { "a" }, reg, eba), Is.EqualTo(Ere.Empty())); + Assert.That(d.Evaluate(new HashSet { "b" }, reg, eba), Is.EqualTo(Ere.Empty())); + Assert.That(d.Evaluate(new HashSet(), reg, eba), Is.EqualTo(Ere.Empty())); + } + + [Test] + public void Fusion_Derivative_Example7_1_Equivalence() + { + // From the JACM ext. Example 7.1: R = α*:β* ≡ S = α*·(α∧β)·β*. + // We check membership equivalence on a few representative words by + // chaining derivatives (over predicate-atoms α=a, β=b). + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var aStar = Ere.Star(A); + var bStar = Ere.Star(B); + + var R = Ere.Fusion(aStar, bStar); + var aAndB = Ere.Intersect(A, B); + var S = Ere.Concat(aStar, Ere.Concat(aAndB, bStar)); + + var ab = new HashSet { "a", "b" }; + var aOnly = new HashSet { "a" }; + var bOnly = new HashSet { "b" }; + + // The single letter satisfying both a and b is in both languages. + // After ∂ on {a,b}, residual is nullable. + var dR1 = deriv.Derivative(R).Evaluate(ab, reg, eba); + var dS1 = deriv.Derivative(S).Evaluate(ab, reg, eba); + Assert.That(dR1.Nullable, Is.True, "R should accept the singleton {a,b}"); + Assert.That(dS1.Nullable, Is.True, "S should accept the singleton {a,b}"); + + // The word [a,a,{a,b},b] is in both languages (α-prefix, shared α∧β, β-suffix). + Ere rPos = R, sPos = S; + foreach (var letter in new[] { aOnly, aOnly, ab, bOnly }) + { + rPos = deriv.Derivative(rPos).Evaluate(letter, reg, eba); + sPos = deriv.Derivative(sPos).Evaluate(letter, reg, eba); + } + Assert.That(rPos.Nullable, Is.True, "R should accept a·a·(a∧b)·b"); + Assert.That(sPos.Nullable, Is.True, "S should accept a·a·(a∧b)·b"); + + // [a,b] alone (no shared letter) is in neither: the fusion needs one + // position where both α and β hold. + var rNeg = deriv.Derivative(deriv.Derivative(R).Evaluate(aOnly, reg, eba)) + .Evaluate(bOnly, reg, eba); + var sNeg = deriv.Derivative(deriv.Derivative(S).Evaluate(aOnly, reg, eba)) + .Evaluate(bOnly, reg, eba); + Assert.That(rNeg.Nullable, Is.False, "R should reject a·b (no shared letter)"); + Assert.That(sNeg.Nullable, Is.False, "S should reject a·b (no shared letter)"); + } + [Test] + public void Fusion_Derivative_Example7_1_LanguageEquivalence() + { + // Full language equivalence α* : β* ≡ α* · (α∧β) · β* via mutual + // subsumption: each language minus the other is empty. We decide + // emptiness by Brzozowski-derivative state-space exploration — + // L(X) = ∅ iff no reachable state of X is nullable. + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var aStar = Ere.Star(A); + var bStar = Ere.Star(B); + var aAndB = Ere.Intersect(A, B); + + var R = Ere.Fusion(aStar, bStar); // α* : β* + var S = Ere.Concat(aStar, Ere.Concat(aAndB, bStar)); // α*·(α∧β)·β* + + Assert.That(SubsumedBy(S, R, deriv), Is.True, "L(S) ⊆ L(R)"); + Assert.That(SubsumedBy(R, S, deriv), Is.True, "L(R) ⊆ L(S)"); + } + + [Test] + public void Xor_Identity_DropsEmpty() + { + // R ⊕ ⊥ ≡ R + Assert.That(Ere.Xor(A, Ere.Empty()), Is.EqualTo(A)); + Assert.That(Ere.Xor(Ere.Empty(), B), Is.EqualTo(B)); + Assert.That(Ere.Xor(Ere.Empty(), Ere.Empty()), + Is.EqualTo(Ere.Empty())); + } + + [Test] + public void Xor_SelfInverse_PairCancellation() + { + // R ⊕ R ≡ ⊥ + Assert.That(Ere.Xor(A, A), Is.EqualTo(Ere.Empty())); + // A ⊕ B ⊕ A ≡ B (cancellation across nesting) + var ab = Ere.Xor(A, B); + Assert.That(Ere.Xor(ab, A), Is.EqualTo(B)); + } + + [Test] + public void Xor_ComplementLift_PairCancellation() + { + // ~R ⊕ ~S ≡ R ⊕ S + var notA = Ere.Complement(A); + var notB = Ere.Complement(B); + Assert.That(Ere.Xor(notA, notB), Is.EqualTo(Ere.Xor(A, B))); + + // ~R ⊕ R ≡ Σ* + Assert.That(Ere.Xor(notA, A), Is.EqualTo(Ere.Sigma())); + + // Σ* ⊕ R ≡ ~R + Assert.That(Ere.Xor(Ere.Sigma(), A), Is.EqualTo(notA)); + + // Σ* ⊕ Σ* ≡ ⊥ + Assert.That(Ere.Xor(Ere.Sigma(), Ere.Sigma()), + Is.EqualTo(Ere.Empty())); + } + + [Test] + public void Xor_ComplementLift_SingleOperand() + { + // R ⊕ ~S ≡ ~(R ⊕ S) — single XOR node with Negated=true (no wrapper). + var x = Ere.Xor(A, Ere.Complement(B)); + Assert.That(x, Is.InstanceOf>()); + var node = (EreXor)x; + Assert.That(node.Negated, Is.True); + Assert.That(node.Operands.Count, Is.EqualTo(2)); + // Equivalent to ~(A ⊕ B). + Assert.That(x, Is.EqualTo(Ere.Complement(Ere.Xor(A, B)))); + } + + [Test] + public void Xor_ComplementOfXor_AbsorbedByNegatedFlag() + { + // ~(A ⊕ B) is stored as EreXor with Negated=true, not as Complement(Xor). + var xab = Ere.Xor(A, B); + var nxab = Ere.Complement(xab); + Assert.That(nxab, Is.InstanceOf>()); + Assert.That(((EreXor)nxab).Negated, Is.True); + + // Double complement returns the original (reference-equal via hash-consing). + Assert.That(Ere.Complement(nxab), Is.SameAs(xab)); + } + + [Test] + public void Xor_Flattening_OperandsSortedAndDistinct() + { + // (A ⊕ B) ⊕ (B ⊕ C) = A ⊕ C (pair-cancellation on B). + var ab = Ere.Xor(A, B); + var C = Ere.Atom(new Prop("c")); + var bc = Ere.Xor(B, C); + Assert.That(Ere.Xor(ab, bc), Is.EqualTo(Ere.Xor(A, C))); + } + + [Test] + public void Xor_Nullable_ParityOfOperands() + { + // a (non-nullable) ⊕ a* (nullable) → nullable. + var x = Ere.Xor(A, Ere.Star(A)); + Assert.That(x.Nullable, Is.True); + + // a* ⊕ b* (both nullable) → non-nullable. + var x2 = Ere.Xor(Ere.Star(A), Ere.Star(B)); + Assert.That(x2.Nullable, Is.False); + + // Three-way XNOR: ~(a ⊕ a* ⊕ b*) — parity of (false, true, true)=false, + // then negated → true. + var x3 = Ere.Xnor(A, Ere.Xor(Ere.Star(A), Ere.Star(B))); + Assert.That(x3.Nullable, Is.True); + } + + [Test] + public void Xnor_IsComplementOfXor() + { + Assert.That(Ere.Xnor(A, B), + Is.EqualTo(Ere.Complement(Ere.Xor(A, B)))); + } + + [Test] + public void Derivative_Xor_CommutesWithDerivative() + { + // δ(A ⊕ B) on input 'a' should match (δA ⊕ δB) on 'a'. + // δA on 'a' = ε, δB on 'a' = ∅ → ε ⊕ ∅ = ε + // δA on 'b' = ∅, δB on 'b' = ε → ∅ ⊕ ε = ε + // δ(A⊕B) on neither = ∅ ⊕ ∅ = ∅ + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var xab = Ere.Xor(A, B); + var d = deriv.Derivative(xab); + Assert.That(d.Evaluate(new HashSet { "a" }, reg, eba), + Is.EqualTo(Ere.Epsilon())); + Assert.That(d.Evaluate(new HashSet { "b" }, reg, eba), + Is.EqualTo(Ere.Epsilon())); + Assert.That(d.Evaluate(new HashSet { "a", "b" }, reg, eba), + Is.EqualTo(Ere.Empty())); + Assert.That(d.Evaluate(new HashSet(), reg, eba), + Is.EqualTo(Ere.Empty())); + } + + [Test] + public void Derivative_Xnor_NegatesLeaves() + { + // δ(A ⊙ B) = ~(δA ⊕ δB). On 'a': ~ε = ~ε (which is non-nullable Σ*\ε). + // We don't strictly need to validate that exact form; what matters + // is that the result equals Complement of the XOR derivative. + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var xab = Ere.Xor(A, B); + var xnab = Ere.Xnor(A, B); + var dXor = deriv.Derivative(xab); + var dXnor = deriv.Derivative(xnab); + + // The two transition terms should be exact negations leaf-wise. + foreach (var input in new[] + { + new HashSet { "a" }, new HashSet { "b" }, + new HashSet { "a", "b" }, new HashSet() + }) + { + var l1 = dXor.Evaluate(input, reg, eba); + var l2 = dXnor.Evaluate(input, reg, eba); + Assert.That(l2, Is.EqualTo(Ere.Complement(l1)), + $"On input {string.Join(",", input)}: δ(A⊙B) should be ~δ(A⊕B)"); + } + } + + [Test] + public void Derivative_Xor_Self_IsZero() + { + // A ⊕ A normalises to ⊥ at construction; derivative is ⊥. + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + + var xaa = Ere.Xor(A, A); + Assert.That(xaa, Is.EqualTo(Ere.Empty())); + var d = deriv.Derivative(xaa); + Assert.That(d.Evaluate(new HashSet { "a" }, reg, eba), + Is.EqualTo(Ere.Empty())); + } + + /// + private static bool SubsumedBy( + Ere s, Ere r, + EreDerivative> deriv) + { + var diff = Ere.Intersect(s, Ere.Complement(r)); + return IsEmpty(diff, deriv); + } + + private static bool IsEmpty( + Ere regex, + EreDerivative> deriv, + int stateLimit = 200) + { + var seen = new HashSet> { regex }; + var work = new Queue>(); + work.Enqueue(regex); + while (work.Count > 0) + { + var q = work.Dequeue(); + if (q.Nullable) return false; // accepting state reached + if (q is EreEmpty) continue; // dead, no successors + var d = deriv.Derivative(q); + foreach (var next in CollectLeaves(d)) + { + if (next is EreEmpty) continue; + if (seen.Add(next)) + { + if (seen.Count > stateLimit) + throw new Exception( + $"State-space explosion (> {stateLimit}); refusing to enumerate further."); + work.Enqueue(next); + } + } + } + return true; + } + + private static IEnumerable> CollectLeaves(TransitionTerm> t) + { + if (t is TransitionTermLeaf> leaf) { yield return leaf.Value; yield break; } + var ite = (TransitionTermIte>)t; + foreach (var l in CollectLeaves(ite.Hi)) yield return l; + foreach (var l in CollectLeaves(ite.Lo)) yield return l; + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionContainsMergeTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionContainsMergeTests.cs new file mode 100644 index 0000000..7f69162 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionContainsMergeTests.cs @@ -0,0 +1,80 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Pins the contains-pattern merge under Union (Phase 12 P2.3; + /// Rust EREQ SU-5 at lib.rs:2081–2086): + /// Σ*·R·Σ* + Σ*·S·Σ* ≡ Σ*·(R+S)·Σ*. + /// Particularly relevant for "contains-body" style regexes from + /// MSO/RLTL encodings (mk_contains in Rust EREQ). + /// + [TestFixture] + public class EreUnionContainsMergeTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + private static readonly StateProp Pc = new StateProp("c", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + private static Ere SigmaStar() + => Ere.Star(Ere.Sigma()); + + private static Ere Contains(Ere body) + { + var s = SigmaStar(); + return Ere.Concat(s, Ere.Concat(body, s)); + } + + [Test] + public void TwoContainsPatterns_MergeBodies() + { + var a = Atom(Pa); + var b = Atom(Pb); + + var ca = Contains(a); + var cb = Contains(b); + + var union = Ere.Union(ca, cb); + var expected = Contains(Ere.Union(a, b)); + + Assert.That(union, Is.SameAs(expected), + "Σ*·a·Σ* + Σ*·b·Σ* should merge to Σ*·(a+b)·Σ*."); + } + + [Test] + public void ThreeContainsPatterns_MergeAll() + { + var a = Atom(Pa); + var b = Atom(Pb); + var c = Atom(Pc); + + var union = Ere.Union( + Contains(a), + Ere.Union(Contains(b), Contains(c))); + + var expectedBody = Ere.Union(a, + Ere.Union(b, c)); + var expected = Contains(expectedBody); + + Assert.That(union, Is.SameAs(expected), + "three contains-patterns should merge into one Σ*·(a+b+c)·Σ*."); + } + + [Test] + public void IdenticalContainsPatterns_DedupViaUnion() + { + var a = Atom(Pa); + var ca = Contains(a); + + var union = Ere.Union(ca, ca); + Assert.That(union, Is.SameAs(ca), + "Σ*·a·Σ* + Σ*·a·Σ* should collapse to a single Σ*·a·Σ* " + + "(union ACI dedup runs before contains-merge)."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionHeadFactoringTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionHeadFactoringTests.cs new file mode 100644 index 0000000..18d1b16 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionHeadFactoringTests.cs @@ -0,0 +1,103 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Pins Union head-factoring (Phase 12 P2.1; Rust EREQ SU-7 at + /// lib.rs:2118–2121): H·T₁ + H·T₂ + … + H·Tₙ ≡ H·(T₁+T₂+…+Tₙ). + /// Useful when derivative classes share the same head predicate. + /// + [TestFixture] + public class EreUnionHeadFactoringTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + private static readonly StateProp Pc = new StateProp("c", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + [Test] + public void TwoConcatsSharingHead_Factor() + { + // a·b + a·c → a·(b+c) + var a = Atom(Pa); + var b = Atom(Pb); + var c = Atom(Pc); + + var lhs = Ere.Concat(a, b); + var rhs = Ere.Concat(a, c); + var union = Ere.Union(lhs, rhs); + + var expected = Ere.Concat(a, + Ere.Union(b, c)); + Assert.That(union, Is.SameAs(expected), + "a·b + a·c should factor as a·(b+c)."); + } + + [Test] + public void DistinctHeads_DoNotFactor() + { + // a·b + c·b: heads differ; should not factor (we don't do + // common-tail factoring — that's a separate rule). + var a = Atom(Pa); + var b = Atom(Pb); + var c = Atom(Pc); + + var lhs = Ere.Concat(a, b); + var rhs = Ere.Concat(c, b); + var union = Ere.Union(lhs, rhs); + + Assert.That(union, Is.Not.SameAs(lhs)); + Assert.That(union, Is.Not.SameAs(rhs)); + Assert.That(union.ToString(), Does.Contain("+"), + "distinct heads should leave a binary union shape"); + } + + [Test] + public void ThreeWayFactoring() + { + // a·b + a·c + a·(b·c) → a·(b + c + b·c) + var a = Atom(Pa); + var b = Atom(Pb); + var c = Atom(Pc); + + var u = Ere.Union( + Ere.Concat(a, b), + Ere.Union( + Ere.Concat(a, c), + Ere.Concat(a, + Ere.Concat(b, c)))); + + var expectedTail = Ere.Union( + b, Ere.Union(c, + Ere.Concat(b, c))); + var expected = Ere.Concat(a, expectedTail); + Assert.That(u, Is.SameAs(expected)); + } + + [Test] + public void MixedConcatAndNonConcat_FactorsOnlyMatchingGroup() + { + // a·b + a·c + d → a·(b+c) + d + var a = Atom(Pa); + var b = Atom(Pb); + var c = Atom(Pc); + var d = Ere.Atom( + new StatePredAtom(new StateProp("d", _ => true))); + + var u = Ere.Union( + Ere.Concat(a, b), + Ere.Union( + Ere.Concat(a, c), + d)); + + var expected = Ere.Union( + Ere.Concat(a, Ere.Union(b, c)), + d); + Assert.That(u, Is.SameAs(expected)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionPlusCollapseTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionPlusCollapseTests.cs new file mode 100644 index 0000000..3092acf --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionPlusCollapseTests.cs @@ -0,0 +1,85 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Pins the Union rewrites for R⁺ collapses (Phase 12 P1.4 + P1.5, + /// mirroring Rust EREQ lib.rs:3549–3559): + /// + /// ε | R·R* ≡ R* + /// R* | R·R* ≡ R* + /// + /// + [TestFixture] + public class EreUnionPlusCollapseTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + [Test] + public void Epsilon_Plus_RPlus_CollapsesToStar() + { + // R⁺ = R · R* + var a = Atom(Pa); + var aStar = Ere.Star(a); + var aPlus = Ere.Concat(a, aStar); + + var union = Ere.Union(Ere.Epsilon(), aPlus); + + Assert.That(union, Is.SameAs(aStar), + "ε + a·a* should collapse to a*."); + } + + [Test] + public void Star_Plus_RPlus_CollapsesToStar() + { + var a = Atom(Pa); + var aStar = Ere.Star(a); + var aPlus = Ere.Concat(a, aStar); + + var union = Ere.Union(aStar, aPlus); + + Assert.That(union, Is.SameAs(aStar), + "a* + a·a* should collapse to a*."); + } + + [Test] + public void DifferentBody_DoesNotCollapse() + { + // ε + b·b* collapses to b* (same body) — sanity. + // ε + a·b* must NOT collapse to b* (a ≠ b). + var a = Atom(Pa); + var b = Atom(Pb); + var bStar = Ere.Star(b); + + var nonPlus = Ere.Concat(a, bStar); + var union = Ere.Union(Ere.Epsilon(), nonPlus); + + // The result must include both ε and a·b* somehow — not just bStar. + Assert.That(union, Is.Not.SameAs(bStar), + "Collapse must require Concat's left to equal the star's inner."); + } + + [Test] + public void TripleUnion_StarAndPlusAndOther() + { + // a* + a·a* + b → a* + b + var a = Atom(Pa); + var b = Atom(Pb); + var aStar = Ere.Star(a); + var aPlus = Ere.Concat(a, aStar); + + var union = Ere.Union( + Ere.Union(aStar, aPlus), b); + var expected = Ere.Union(aStar, b); + + Assert.That(union, Is.SameAs(expected), + "a* + a·a* + b should reduce to a* + b."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarAbsorptionTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarAbsorptionTests.cs new file mode 100644 index 0000000..49195f9 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarAbsorptionTests.cs @@ -0,0 +1,105 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Pins the Σ*-prefix Union absorption rule: + /// R · Σ* · T | Σ* · T ≡ Σ* · T. + /// + /// Soundness: any word in L(R · Σ* · T) decomposes as + /// r · w · t with r ∈ L(R), w ∈ Σ*, t ∈ L(T); + /// re-association puts (r · w) in Σ*, so the word also + /// lies in L(Σ* · T). + /// + /// Why it matters: this is the rewrite that collapses the + /// regex-concat encoding of ∧ᵢ GFpᵢ from ~2ⁿ derivative + /// classes down to the optimal n+1. The scaling probe + /// Report_DnfLeaves_vs_RltlRegex_Scaling shows the effect on + /// the model-checker-relevant negated form. + /// + [TestFixture] + public class EreUnionSigmaStarAbsorptionTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + private static Ere SigmaStar() + => Ere.Star(Ere.Sigma()); + + [Test] + public void SigmaStarS_AbsorbsRSigmaStarS() + { + var sStar = SigmaStar(); + var a = Atom(Pa); + var b = Atom(Pb); + + // big = Σ* · b + var big = Ere.Concat(sStar, b); + // small = a · Σ* · b = a · big (right-associated) + var small = Ere.Concat(a, big); + + var union = Ere.Union(big, small); + Assert.That(union, Is.SameAs(big), + "Σ*·b should absorb a·Σ*·b in a union."); + } + + [Test] + public void SigmaStarST_AbsorbsRSigmaStarST() + { + var sStar = SigmaStar(); + var a = Atom(Pa); + var b = Atom(Pb); + + // big = Σ* · a · b + var big = Ere.Concat(sStar, + Ere.Concat(a, b)); + // small = b · big = b · Σ* · a · b + var small = Ere.Concat(b, big); + + var union = Ere.Union(big, small); + Assert.That(union, Is.SameAs(big), + "Σ*·a·b should absorb b·Σ*·a·b in a union."); + } + + [Test] + public void ChainedConcat_RegexFairnessProgressClasses() + { + // r1 = Σ*·a·Σ*·b (one "progress step" remaining) + // r2 = Σ*·b (zero "progress steps" remaining; deepest) + // The user's headline case: r1 + r2 should collapse to r2, + // because L(r1) ⊆ L(r2). + var sStar = SigmaStar(); + var a = Atom(Pa); + var b = Atom(Pb); + + var r2 = Ere.Concat(sStar, b); + var r1 = Ere.Concat(sStar, + Ere.Concat(a, r2)); + + var union = Ere.Union(r1, r2); + Assert.That(union, Is.SameAs(r2), + "Σ*·a·Σ*·b + Σ*·b should collapse to Σ*·b."); + } + + [Test] + public void NonSigmaPrefix_DoesNotAbsorb() + { + // a·b + b must NOT collapse: L(a·b) ⊄ L(b). + // This guards against an over-eager generalisation that + // would drop the Σ* requirement. + var a = Atom(Pa); + var b = Atom(Pb); + + var small = Ere.Concat(a, b); + var union = Ere.Union(small, b); + Assert.That(union, Is.Not.SameAs(small)); + Assert.That(union, Is.Not.SameAs(b), + "a·b + b must remain a union; absorption requires Σ* in the 'big' operand."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarTailSubsumptionTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarTailSubsumptionTests.cs new file mode 100644 index 0000000..aeebf0e --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreUnionSigmaStarTailSubsumptionTests.cs @@ -0,0 +1,91 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + using System.Linq; + + /// + /// Pins the Σ*-tail structural subsumption rewrite (Phase 12 / Rust + /// EREQ port P2.5b, lib.rs:2104-2114): + /// Σ*·t1 + Σ*·t2 ≡ Σ*·t1 when t2 = …·t1 structurally. + /// Any word ending in t2 = X·t1 also ends in t1, so the longer-tailed + /// disjunct is subsumed by the shorter-tailed one. + /// + [TestFixture] + public class EreUnionSigmaStarTailSubsumptionTests + { + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static Ere Atom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + + private static Ere SigmaStar() + => Ere.Star(Ere.Sigma()); + + [Test] + public void TailSubsumption_DropsLongerEndsWith() + { + // Σ*·a ∪ Σ*·(b·a) → Σ*·a + var sStar = SigmaStar(); + var endsWithA = Ere.Concat(sStar, Atom(Pa)); + var endsWithBA = Ere.Concat( + sStar, + Ere.Concat(Atom(Pb), Atom(Pa))); + var u = Ere.Union(endsWithA, endsWithBA); + Assert.That(u, Is.SameAs(endsWithA), + $"expected Σ*·(b·a) to be subsumed by Σ*·a, got {u}"); + } + + [Test] + public void TailSubsumption_HeadFactoredWhenNoSuffixRelation() + { + // Σ*·a vs Σ*·b: neither tail is a structural suffix of the + // other, but they share head Σ*, so head-factoring (P2.1) folds + // them into Σ*·(a|b). Either way, the P2.5b subsumption rule + // must NOT incorrectly drop one of them. + var sStar = SigmaStar(); + var endsWithA = Ere.Concat(sStar, Atom(Pa)); + var endsWithB = Ere.Concat(sStar, Atom(Pb)); + var u = Ere.Union(endsWithA, endsWithB); + // Head-factored canonical shape: Σ*·(a|b). + Assert.That(u, Is.InstanceOf>()); + var c = (EreConcat)u; + Assert.That(c.Right, Is.InstanceOf>(), + "tail should be (a|b) union"); + } + + [Test] + public void TailSubsumption_TransitiveDropsLongest() + { + // Σ*·a , Σ*·(b·a) , Σ*·(b·b·a) → Σ*·a (a is suffix of all) + var sStar = SigmaStar(); + var a = Atom(Pa); + var b = Atom(Pb); + var endsA = Ere.Concat(sStar, a); + var endsBA = Ere.Concat(sStar, Ere.Concat(b, a)); + var endsBBA = Ere.Concat(sStar, Ere.Concat(b, Ere.Concat(b, a))); + var u12 = Ere.Union(endsA, endsBA); + var u = Ere.Union(u12, endsBBA); + Assert.That(u, Is.SameAs(endsA), + $"expected all longer-tailed Σ* operands to be subsumed by Σ*·a, got {u}"); + } + + [Test] + public void TailSubsumption_NonSigmaStarPrefixIsNotDropped() + { + // a·a ∪ Σ*·a : the first does not have a Σ* prefix, so the + // P2.5b rewrite does not apply. Result should remain a Union + // (the existing Σ*-prefix absorption may rearrange it, but + // must not collapse to either operand). + var a = Atom(Pa); + var aa = Ere.Concat(a, a); + var sStarA = Ere.Concat(SigmaStar(), a); + var u = Ere.Union(aa, sStarA); + // The Σ*-prefix absorption rule fires here: a·a · ε vs Σ*·a; + // not the P2.5b case. Just check we didn't bogusly drop one. + Assert.That(u, Is.Not.InstanceOf>()); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/EreWitnessTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreWitnessTests.cs new file mode 100644 index 0000000..b9b96ac --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/EreWitnessTests.cs @@ -0,0 +1,240 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class EreWitnessTests + { + private sealed class Prop : IEquatable, IComparable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public bool Equals(Prop other) => other != null && Name == other.Name; + public override bool Equals(object obj) => Equals(obj as Prop); + public override int GetHashCode() => Name.GetHashCode(); + public int CompareTo(Prop other) => string.Compare(Name, other?.Name, StringComparison.Ordinal); + public override string ToString() => Name; + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top { get; } = new Prop("⊤"); + public Prop Bottom { get; } = new Prop("⊥"); + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + public bool IsSatisfiable(Prop p) => p.Name != "⊥"; + public bool Models(HashSet e, Prop p) + { + if (p.Name == "⊤") return true; + if (p.Name == "⊥") return false; + if (p.Name.StartsWith("¬")) return !e.Contains(p.Name.Substring(1)); + return e.Contains(p.Name); + } + } + + private static Ere Atom(string n) => Ere.Atom(new Prop(n)); + + private static (EreEquivalenceChecker> equiv, + EreEmptinessChecker> empt, + PropEba eba) MakeCheckers() + { + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new EreDerivative>(eba, reg); + var empt = new EreEmptinessChecker>(deriv); + var equiv = new EreEquivalenceChecker>(deriv, empt); + return (equiv, empt, eba); + } + + // Materialise a positive-literal predicate by picking the obvious + // singleton; ⊤ and ¬x pick the empty set. + private static HashSet Pick(Prop p) + { + var n = p.Name; + if (n == "⊤") return new HashSet(); + if (n == "⊥") throw new InvalidOperationException("unsat"); + if (n.StartsWith("¬")) return new HashSet(); + if (n.StartsWith("(") && n.Contains("∧")) + { + // very simple conjunction parser: split top-level by ∧. + var inner = n.Substring(1, n.Length - 2); + var set = new HashSet(); + foreach (var part in inner.Split('∧')) + if (!part.StartsWith("¬")) set.Add(part); + return set; + } + return new HashSet { n }; + } + + // --- ConsList witness shape --- + + [Test] + public void Empty_NonEmptyEpsilon_EmptyWitness() + { + var (_, empt, _) = MakeCheckers(); + Assert.That(empt.NonEmpty(Ere.Epsilon(), ConsList.Empty, + out var w), Is.True); + Assert.That(w.IsEmpty, Is.True); + } + + [Test] + public void Empty_DeadReturnsFalse() + { + var (_, empt, _) = MakeCheckers(); + Assert.That(empt.NonEmpty(Ere.Empty(), ConsList.Empty, + out var w), Is.False); + Assert.That(w, Is.Null); + } + + [Test] + public void Empty_SingleAtomWitnessLength1() + { + var (_, empt, _) = MakeCheckers(); + Assert.That(empt.NonEmpty(Atom("a"), ConsList.Empty, + out var w), Is.True); + Assert.That(w.Count, Is.EqualTo(1)); + Assert.That(w.Head.Name, Does.Contain("a")); + } + + [Test] + public void Empty_ConcatWitnessLength3() + { + // a·b·c — accepted word has 3 symbols matching a, b, c respectively. + var (_, empt, _) = MakeCheckers(); + var r = Ere.Concat(Atom("a"), Ere.Concat(Atom("b"), Atom("c"))); + Assert.That(empt.NonEmpty(r, ConsList.Empty, out var w), Is.True); + Assert.That(w.Count, Is.EqualTo(3)); + // Forward order via Reverse: a, b, c. + var fwd = w.Reverse().ToList(); + Assert.That(fwd[0].Name, Does.Contain("a")); + Assert.That(fwd[1].Name, Does.Contain("b")); + Assert.That(fwd[2].Name, Does.Contain("c")); + } + + [Test] + public void Empty_PrefixThreaded() + { + // NonEmpty with a non-empty prefix appends (in reverse) onto the + // new witness — prefix should remain as the tail. + var (_, empt, _) = MakeCheckers(); + var prefix = ConsList.Empty.Push(new Prop("x")); + Assert.That(empt.NonEmpty(Atom("a"), prefix, out var w), Is.True); + Assert.That(w.Count, Is.EqualTo(2)); + // Head = most recent (a), tail = prefix (x). + Assert.That(w.Head.Name, Does.Contain("a")); + Assert.That(w.Tail.Head.Name, Is.EqualTo("x")); + } + + // --- Equivalence-checker witness --- + + [Test] + public void Inequivalent_DistinctAtoms() + { + var (eq, _, eba) = MakeCheckers(); + Assert.That(eq.AreInequivalent(Atom("a"), Atom("b"), out var w), Is.True); + // Witness is a 1-letter word distinguishing the two. + Assert.That(w.Count, Is.EqualTo(1)); + var elem = Pick(w.Head); + // The element satisfies exactly one of a, b. + Assert.That(eba.Models(elem, new Prop("a")) ^ eba.Models(elem, new Prop("b")), + Is.True); + } + + [Test] + public void Inequivalent_SubsumptionPvsPunionQ() + { + // a ≢ a + b — witness should be in L(b) \ L(a). + var (eq, _, eba) = MakeCheckers(); + var pq = Ere.Union(Atom("a"), Atom("b")); + Assert.That(eq.AreInequivalent(Atom("a"), pq, out var w), Is.True); + Assert.That(w.Count, Is.EqualTo(1)); + // The witness symbol must NOT satisfy a (else accepted by both); + // it must satisfy b (else accepted by neither). + var elem = Pick(w.Head); + Assert.That(eba.Models(elem, new Prop("a")), Is.False); + Assert.That(eba.Models(elem, new Prop("b")), Is.True); + } + + [Test] + public void Inequivalent_StarVsDoubleStar() + { + // a* ≢ (a·a)* — witness must be an odd-length a^k word + // accepted by a* but not (a·a)*. + var (eq, _, eba) = MakeCheckers(); + var aStar = Ere.Star(Atom("a")); + var aaStar = Ere.Star(Ere.Concat(Atom("a"), Atom("a"))); + Assert.That(eq.AreInequivalent(aStar, aaStar, out var w), Is.True); + Assert.That(w.Count % 2, Is.EqualTo(1), "witness length must be odd"); + // Every symbol must satisfy a. + foreach (var p in w) + { + Assert.That(eba.Models(Pick(p), new Prop("a")), Is.True); + } + } + + [Test] + public void Equivalent_NoWitness() + { + var (eq, _, _) = MakeCheckers(); + Assert.That(eq.AreInequivalent(Atom("a"), Atom("a"), out var w), Is.False); + Assert.That(w, Is.Null); + } + + [Test] + public void Equivalent_AciNoWitness() + { + var (eq, _, _) = MakeCheckers(); + var l = Ere.Union(Atom("a"), Atom("b")); + var r = Ere.Union(Atom("b"), Atom("a")); + Assert.That(eq.AreInequivalent(l, r, out var w), Is.False); + Assert.That(w, Is.Null); + } + + // --- Materialisation helper --- + + [Test] + public void Materialise_ForwardOrder() + { + var (_, empt, _) = MakeCheckers(); + var r = Ere.Concat(Atom("a"), Atom("b")); + Assert.That(empt.NonEmpty(r, ConsList.Empty, out var w), Is.True); + var elems = EreWitness.Materialise(w, Pick); + Assert.That(elems.Count, Is.EqualTo(2)); + Assert.That(elems[0], Does.Contain("a")); + Assert.That(elems[1], Does.Contain("b")); + } + + [Test] + public void Materialise_EmptyOrNull() + { + Assert.That(EreWitness.ToForward((ConsList)null), Is.Empty); + Assert.That(EreWitness.ToForward(ConsList.Empty), Is.Empty); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/FairnessTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/FairnessTests.cs new file mode 100644 index 0000000..878179c --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/FairnessTests.cs @@ -0,0 +1,252 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Rltl; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for fairness-aware checking via + /// reached through + /// and + /// . + /// + /// The classical scenario used throughout is a one-state system with + /// two outgoing actions: + /// + /// step_loop: self-loop at s0. + /// step_progress: takes s0 → s1 where the goal + /// holds. + /// + /// Without fairness, the self-loop run is a counterexample to + /// F goal. Under weak fairness on step_progress the + /// self-loop cycle is unfair (step_progress is continuously enabled at + /// s0 but never taken inside the cycle), so it is no longer a + /// valid counterexample and F goal holds. + /// + [TestFixture] + public class FairnessTests + { + #region Test infrastructure + + private sealed class TestState : State + { + public string Label { get; } + public int Value { get; } + + public TestState(string label, int value) + { + Label = label; + Value = value; + } + + protected override void CloneInternal(Dictionary map) + => map[this] = new TestState(Label, Value); + + protected override void LockComponents(HashSet visited) { } + + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) + => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class StepLoop : IStepFunction + { + public string StepFunctionId => "step_loop"; + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> p) => null; + } + + private sealed class StepProgress : IStepFunction + { + public string StepFunctionId => "step_progress"; + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> p) => null; + } + + /// + /// Build the two-state system described in the class summary. + /// Returns s0. + /// + private static StateGraphNode BuildSystem() + { + var s0State = new TestState("s0", 0); s0State.Freeze(); + var s1State = new TestState("s1", 99); s1State.Freeze(); + + var s0 = new StateGraphNode + { + State = s0State, + StepFunctions = new List { new StepLoop(), new StepProgress() }, + Edges = new List() + }; + var s1 = new StateGraphNode + { + State = s1State, + StepFunctions = new List(), + Edges = new List() + }; + + s0.Edges.Add(new StateGraphEdge { Target = s0, StepFunction = new StepLoop() }); + s0.Edges.Add(new StateGraphEdge { Target = s1, StepFunction = new StepProgress() }); + + return s0; + } + + private static StateProp Goal => new StateProp( + "goal", s => ((TestState)s).Value == 99); + + #endregion + + #region SymbolicLtlCheck + Fairness + + [Test] + public void Ltl_F_Goal_Violated_Without_Fairness() + { + var s0 = BuildSystem(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(Goal))); + + var result = SymbolicLtlCheck.Check(s0, phi); + Assert.That(result.Valid, Is.False, + "Without fairness, the s0 self-loop violates F goal."); + } + + [Test] + public void Ltl_F_Goal_Holds_Under_WeakFairAll() + { + var s0 = BuildSystem(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(Goal))); + + var result = SymbolicLtlCheck.Check(s0, phi, maxDepth: 0, + fairness: Fairness.WeakFairAll); + Assert.That(result.Valid, Is.True, + "Under weak fairness, step_progress is continuously enabled at s0 " + + "and must be taken, so F goal holds."); + } + + [Test] + public void Ltl_F_Goal_Holds_Under_WeakFair_On_StepProgress_Only() + { + var s0 = BuildSystem(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(Goal))); + + var fairness = Fairness.WeakFair(); + var result = SymbolicLtlCheck.Check(s0, phi, maxDepth: 0, fairness: fairness); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Ltl_F_Goal_Violated_Under_WeakFair_On_StepLoop_Only() + { + // Fairness only on step_loop says nothing about step_progress; + // the self-loop run still takes step_loop infinitely often and + // is therefore fair w.r.t. this constraint — still a counterexample. + var s0 = BuildSystem(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(Goal))); + + var fairness = Fairness.WeakFair(); + var result = SymbolicLtlCheck.Check(s0, phi, maxDepth: 0, fairness: fairness); + Assert.That(result.Valid, Is.False); + } + + #endregion + + #region SymbolicRltlCheck + Fairness + + [Test] + public void Rltl_F_Goal_Holds_Under_WeakFairAll() + { + var s0 = BuildSystem(); + var phi = RltlFormula.Eventually(RltlFormula.Prop(s => ((TestState)s).Value == 99, "goal")); + + var result = RltlCheck.Check(s0, phi, maxDepth: 0, fairness: Fairness.WeakFairAll); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Rltl_F_Goal_Violated_Without_Fairness() + { + var s0 = BuildSystem(); + var phi = RltlFormula.Eventually(RltlFormula.Prop(s => ((TestState)s).Value == 99, "goal")); + + var result = RltlCheck.Check(s0, phi); + Assert.That(result.Valid, Is.False); + } + + [Test] + public void Rltl_InfinitelyOften_Goal_Holds_Under_StrongFair_On_StepProgress() + { + // ◇□¬goal = ¬□◇ goal. Under strong fairness on step_progress, + // step_progress is always enabled at s0, so it must be taken + // infinitely often, hence we visit s1 (goal) infinitely often. + // BUT s1 has no outgoing edges so the run will stutter at s1 + // forever — goal holds infinitely often trivially. + var s0 = BuildSystem(); + var phi = RltlFormula.InfinitelyOften( + RltlFormula.Prop(s => ((TestState)s).Value == 99, "goal")); + + var fairness = Fairness.StrongFair(); + var result = RltlCheck.Check(s0, phi, maxDepth: 0, fairness: fairness); + Assert.That(result.Valid, Is.True); + } + + #endregion + + #region BadCycle population on symbolic counterexamples + + /// + /// SccProductCheck (fairness path) must attach a system-level + /// to + /// so the + /// enabled-but-not-taken hint fires on parity with the + /// explicit-LTL backend. + /// + [Test] + public void SccProductCheck_Failure_Populates_BadCycle_With_System_Nodes() + { + var s0 = BuildSystem(); + var phi = Ltl.Eventually( + Ltl.Atom(new StatePredAtom(Goal))); + + // Fairness only on StepLoop leaves the self-loop run fair → counterexample. + var result = SymbolicLtlCheck.Check(s0, phi, maxDepth: 0, + fairness: Fairness.WeakFair()); + + Assert.That(result.Valid, Is.False); + Assert.That(result.BadCycle, Is.Not.Null, + "SccProductCheck should attach the system-projected SCC to BadCycle."); + Assert.That(result.BadCycle.Nodes, Is.Not.Empty); + Assert.That(result.BadCycle.HasCycle, Is.True); + Assert.That(result.BadCycle.Nodes.Any(n => ReferenceEquals(n, s0)), Is.True, + "Projected SCC must include the s0 self-loop node."); + } + + /// + /// NestedDfsCheck (no-fairness path through SymbolicRltlCheck) must + /// likewise populate . + /// + [Test] + public void NestedDfsCheck_Failure_Populates_BadCycle_With_System_Nodes() + { + var s0 = BuildSystem(); + var phi = RltlFormula.Eventually( + RltlFormula.Prop(s => ((TestState)s).Value == 99, "goal")); + + var result = RltlCheck.Check(s0, phi); + + Assert.That(result.Valid, Is.False); + Assert.That(result.BadCycle, Is.Not.Null, + "NestedDfsCheck should reconstruct the cycle and populate BadCycle."); + Assert.That(result.BadCycle.Nodes, Is.Not.Empty); + Assert.That(result.BadCycle.HasCycle, Is.True); + Assert.That(result.BadCycle.Nodes.Any(n => ReferenceEquals(n, s0)), Is.True); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/JacmExample51EndToEndTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/JacmExample51EndToEndTests.cs new file mode 100644 index 0000000..9b87ec6 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/JacmExample51EndToEndTests.cs @@ -0,0 +1,183 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Integration tests showing that the weak-equivalence breakpoint merge + /// from fires under + /// the production now that its + /// is propositionally precise + /// (todo statepred-precise-sat): the JACM Example 5.1 reduction + /// of G(Fa ∧ F¬a) reaches its 3-state minimum, and a real + /// model-program check delivers the correct verdict with the merge on. + /// + [TestFixture] + public class JacmExample51EndToEndTests + { + private sealed class TestState : State + { + public string Label { get; } + public bool A { get; } + public TestState(string label, bool a) { Label = label; A = a; } + protected override void CloneInternal(Dictionary map) + => map[this] = new TestState(Label, A); + protected override void LockComponents(HashSet visited) { } + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) => Label; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class TestStep : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + public TestStep(string id) { StepFunctionId = id; StepFunctionIdHash = id.GetHashCode(); } + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + private static StateGraphNode Node(TestState s) + { + s.Freeze(); + return new StateGraphNode + { + State = s, + StepFunctions = new List { new TestStep("step") }, + Edges = new List() + }; + } + + private static void Edge(StateGraphNode from, StateGraphNode to) + => from.Edges.Add(new StateGraphEdge { Target = to, StepFunction = new TestStep("step") }); + + /// + /// End-to-end model checking: the model alternates a / ¬a forever, + /// so it satisfies G(Fa ∧ F¬a). Run the check with the weak- + /// equivalence merge enabled; the production + /// (precise on the propositional fragment) must give a correct + /// "valid" verdict. + /// + [Test] + public void GFaFNa_AlternatingModel_ValidUnderMerge() + { + // Two-state cycle: s0 (a) ↔ s1 (¬a). + var s0 = Node(new TestState("s0", a: true)); + var s1 = Node(new TestState("s1", a: false)); + Edge(s0, s1); + Edge(s1, s0); + + var aProp = new StateProp("a", st => ((TestState)st).A); + var alg = RltlAlgebra.Default; + var formula = alg.Globally( + alg.And( + alg.Eventually(Rltl.Atom(new StatePredAtom(aProp))), + alg.Eventually(alg.NegAtom(new StatePredAtom(aProp))))); + + var withMerge = SymbolicRltlCheck.Check( + s0, formula, mergeWeakEquivalent: true); + var withoutMerge = SymbolicRltlCheck.Check( + s0, formula); + + Assert.That(withMerge.Valid, Is.True, "G(Fa∧F¬a) holds on alternating model."); + Assert.That(withoutMerge.Valid, Is.True, "Same verdict without merge."); + } + + /// + /// End-to-end counterexample: a model that eventually stops emitting + /// a (transitions to a permanent ¬a loop) violates + /// G(Fa ∧ F¬a). The check must report invalid with a + /// counterexample, both with and without the merge. + /// + [Test] + public void GFaFNa_EventuallyStuckModel_InvalidUnderMerge() + { + // s0 (a) → s1 (¬a) → s1 forever ⇒ Fa is satisfied (a at s0) + // but G F a is violated (no a after the first step). + var s0 = Node(new TestState("s0", a: true)); + var s1 = Node(new TestState("s1", a: false)); + Edge(s0, s1); + Edge(s1, s1); + + var aProp = new StateProp("a", st => ((TestState)st).A); + var alg = RltlAlgebra.Default; + var formula = alg.Globally( + alg.And( + alg.Eventually(Rltl.Atom(new StatePredAtom(aProp))), + alg.Eventually(alg.NegAtom(new StatePredAtom(aProp))))); + + var withMerge = SymbolicRltlCheck.Check( + s0, formula, mergeWeakEquivalent: true); + var withoutMerge = SymbolicRltlCheck.Check( + s0, formula); + + Assert.That(withMerge.Valid, Is.False); + Assert.That(withoutMerge.Valid, Is.False); + Assert.That(withMerge.Trace, Is.Not.Null); + } + + /// + /// Direct state-count probe of the production pipeline: build the + /// RLTL → ABW → IncrementalAE NBW for G(Fa ∧ F¬a) under + /// with all canonicalisers (ERE, RLTL, + /// and the weak-equivalent breakpoint merger) enabled. The merge + /// must collapse the reachable BP states to exactly 3 (JACM Ex. 5.1). + /// + [Test] + public void GFaFNa_ProductionEba_MergesTo3() + { + var eba = StatePropEba.Instance; + var aProp = new StateProp("a", st => true); + var atomA = new StatePredAtom(aProp); + + var registry = new ConditionRegistry( + EqualityComparer.Default); + var ed = new EreDerivative(eba, registry); + var ereCanon = new EreCanonicalizer( + new EreEquivalenceChecker(ed)); + var ralg = new RltlAlgebra(eba, ereCanon); + var rltlCanon = new RltlCanonicalizer(eba, ralg); + var deriv = new RltlDerivative( + eba, registry, ereCanon, rltlCanon); + + var formula = ralg.Globally( + ralg.And( + ralg.Eventually(Rltl.Atom(atomA)), + ralg.Eventually(ralg.NegAtom(atomA)))); + + var abw = deriv.ToABW(formula); + var merger = new RltlBreakpointCanonicalizer(eba, ralg); + var ae = new IncrementalAE>( + abw, merger.Canonicalize); + var nbw = ae.ToNBW(); + + var seen = new HashSet>>( + BreakpointState>.GetEqualityComparer()); + var queue = new Queue>>(nbw.InitialStates); + foreach (var s in nbw.InitialStates) seen.Add(s); + while (queue.Count > 0) + { + var s = queue.Dequeue(); + foreach (var term in nbw.GetTransition(s)) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) queue.Enqueue(succ); + } + + TestContext.WriteLine( + $"G(Fa ∧ F¬a) with production StatePropEba + full canonicaliser stack: {seen.Count} BP state(s)."); + foreach (var s in seen) + { + var macro = string.Join(",", s.Macrostate.Select(f => f.ToString())); + var oblig = string.Join(",", s.Obligation.Select(f => f.ToString())); + TestContext.WriteLine($" S={{{macro}}} O={{{oblig}}} accepting={s.Obligation.IsEmpty}"); + } + + Assert.That(seen.Count, Is.EqualTo(3), + "Production EBA + precise IsSatisfiable + weak-equivalent BP merge must yield the JACM Ex. 5.1 minimum (3 states)."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/LtlIntegrationTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/LtlIntegrationTests.cs new file mode 100644 index 0000000..704c7ee --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/LtlIntegrationTests.cs @@ -0,0 +1,481 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Integration tests: LTL formula → symbolic derivative → ABW → Æ → NBW → DOT. + /// Validates correctness of the full pipeline using simple LTL properties. + /// + [TestFixture] + public class LtlIntegrationTests + { + // Simple predicate type: named propositions + private sealed class Prop : IEquatable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public override string ToString() => Name; + public override int GetHashCode() => Name.GetHashCode(); + public override bool Equals(object obj) => Equals(obj as Prop); + public bool Equals(Prop other) => other != null && Name == other.Name; + } + + // EBA over finite set of propositions {a, b} + // Predicates are represented as sets of "true" propositions (minterms approach) + // For simplicity, use the predicates directly and evaluate them against valuations. + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + // We use Prop objects as atomic predicates. + // An element (valuation) is a set of proposition names that are true. + // A Prop p is satisfied by valuation v iff p.Name ∈ v. + // + // For the EBA, we need And/Or/Not of predicates. + // We'll use a wrapper: Prop can be atomic, or combined via compound predicates. + // For simplicity in tests, we only use atomic predicates and let the + // transition term ITE structure handle the Boolean combinations. + + public Prop Top => new Prop("⊤"); + public Prop Bottom => new Prop("⊥"); + + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + + public bool IsSatisfiable(Prop predicate) + { + return predicate.Name != "⊥"; + } + + public bool Models(HashSet element, Prop predicate) + { + if (predicate.Name == "⊤") return true; + if (predicate.Name == "⊥") return false; + if (predicate.Name.StartsWith("¬")) + return !element.Contains(predicate.Name.Substring(1)); + // Compound predicates — simple parse for test purposes + if (predicate.Name.StartsWith("(") && predicate.Name.Contains("∧")) + { + var parts = SplitCompound(predicate.Name, "∧"); + return parts.All(p => Models(element, new Prop(p))); + } + if (predicate.Name.StartsWith("(") && predicate.Name.Contains("∨")) + { + var parts = SplitCompound(predicate.Name, "∨"); + return parts.Any(p => Models(element, new Prop(p))); + } + return element.Contains(predicate.Name); + } + + private static string[] SplitCompound(string s, string op) + { + // Remove outer parens and split on op + s = s.Substring(1, s.Length - 2); + return s.Split(new[] { op }, StringSplitOptions.None); + } + } + + private PropEba _eba; + private LtlAlgebra _alg; + private ConditionRegistry _registry; + private Prop _a, _b; + + [SetUp] + public void Setup() + { + _eba = new PropEba(); + _alg = new LtlAlgebra(_eba); + _registry = new ConditionRegistry(); + _a = new Prop("a"); + _b = new Prop("b"); + } + + private LtlDerivative> MakeDerivative() + => new LtlDerivative>(_eba, _registry); + + #region LTL Formula Tests + + [Test] + public void Ltl_True_False_Atoms() + { + var t = Ltl.True(); + var f = Ltl.False(); + var a = Ltl.Atom(_a); + var na = _alg.NegAtom(_a); + + Assert.That(t, Is.InstanceOf>()); + Assert.That(f, Is.InstanceOf>()); + Assert.That(a.ToString(), Is.EqualTo("a")); + Assert.That(na.ToString(), Is.EqualTo("¬a")); + } + + [Test] + public void Ltl_Negation_PushesThrough() + { + var a = Ltl.Atom(_a); + var na = _alg.Not(a); + Assert.That(na, Is.InstanceOf>()); + Assert.That(((LtlAtom)na).Predicate, Is.EqualTo(_eba.Not(_a))); + + // ¬(a U b) = ¬a R ¬b + var until = Ltl.Until(Ltl.Atom(_a), Ltl.Atom(_b)); + var negUntil = _alg.Not(until); + Assert.That(negUntil, Is.InstanceOf>()); + } + + [Test] + public void Ltl_And_ACI_Normalization() + { + var a = Ltl.Atom(_a); + var b = Ltl.Atom(_b); + + // Idempotent: a ∧ a = a + var aa = _alg.And(a, a); + Assert.That(aa, Is.EqualTo(a)); + + // Commutative: a ∧ b = b ∧ a + var ab = _alg.And(a, b); + var ba = _alg.And(b, a); + Assert.That(ab, Is.EqualTo(ba)); + + // Identity: a ∧ ⊤ = a + var at = _alg.And(a, Ltl.True()); + Assert.That(at, Is.EqualTo(a)); + + // Zero: a ∧ ⊥ = ⊥ + var af = _alg.And(a, Ltl.False()); + Assert.That(af, Is.InstanceOf>()); + } + + [Test] + public void Ltl_Or_ACI_Normalization() + { + var a = Ltl.Atom(_a); + var b = Ltl.Atom(_b); + + // Idempotent: a ∨ a = a + var aa = _alg.Or(a, a); + Assert.That(aa, Is.EqualTo(a)); + + // Identity: a ∨ ⊥ = a + var af = _alg.Or(a, Ltl.False()); + Assert.That(af, Is.EqualTo(a)); + + // Zero: a ∨ ⊤ = ⊤ + var at = _alg.Or(a, Ltl.True()); + Assert.That(at, Is.InstanceOf>()); + } + + [Test] + public void Ltl_Eventually_Globally_Sugar() + { + var a = Ltl.Atom(_a); + + var fa = Ltl.Eventually(a); + Assert.That(fa, Is.InstanceOf>()); + Assert.That(((LtlUntil)fa).Left, Is.InstanceOf>()); + Assert.That(((LtlUntil)fa).Right, Is.EqualTo(a)); + + var ga = Ltl.Globally(a); + Assert.That(ga, Is.InstanceOf>()); + Assert.That(((LtlRelease)ga).Left, Is.InstanceOf>()); + Assert.That(((LtlRelease)ga).Right, Is.EqualTo(a)); + } + + [Test] + public void Ltl_Equality_And_Comparison() + { + var a1 = Ltl.Atom(_a); + var a2 = Ltl.Atom(_a); + Assert.That(a1, Is.EqualTo(a2)); + Assert.That(a1.GetHashCode(), Is.EqualTo(a2.GetHashCode())); + + var b = Ltl.Atom(_b); + Assert.That(a1, Is.Not.EqualTo(b)); + + // Compare is consistent (deterministic ordering) + Assert.That(a1.CompareTo(a2), Is.EqualTo(0)); + } + + #endregion + + #region Symbolic Derivative Tests + + [Test] + public void Derivative_TrueAndFalse() + { + var d = MakeDerivative(); + var dTrue = d.Derivative(Ltl.True()); + var dFalse = d.Derivative(Ltl.False()); + + // ∂(⊤) should be Top (Dnf.True leaf) + Assert.That(dTrue, Is.InstanceOf>>>()); + var leafTrue = ((TransitionTermLeaf>>)dTrue).Value; + Assert.That(leafTrue.IsTrue, Is.True); + + // ∂(⊥) should be Bottom (Dnf.False leaf) + Assert.That(dFalse, Is.InstanceOf>>>()); + var leafFalse = ((TransitionTermLeaf>>)dFalse).Value; + Assert.That(leafFalse.IsFalse, Is.True); + } + + [Test] + public void Derivative_Atom() + { + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var da = d.Derivative(a); + + // ∂(a) = ITE(a, ⊤, ⊥) + Assert.That(da, Is.InstanceOf>>>()); + var ite = (TransitionTermIte>>)da; + Assert.That(((TransitionTermLeaf>>)ite.Hi).Value.IsTrue, Is.True); + Assert.That(((TransitionTermLeaf>>)ite.Lo).Value.IsFalse, Is.True); + } + + [Test] + public void Derivative_NegAtom() + { + var d = MakeDerivative(); + var na = _alg.NegAtom(_a); + var dna = d.Derivative(na); + + // After EBA fusion, NegAtom(a) = Atom(¬a), so derivative is ITE(¬a, ⊤, ⊥) + Assert.That(dna, Is.InstanceOf>>>()); + var ite = (TransitionTermIte>>)dna; + Assert.That(((TransitionTermLeaf>>)ite.Hi).Value.IsTrue, Is.True); + Assert.That(((TransitionTermLeaf>>)ite.Lo).Value.IsFalse, Is.True); + } + + [Test] + public void Derivative_Next() + { + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var xa = Ltl.Next(a); + var dxa = d.Derivative(xa); + + // ∂(Xa) = atom(a) — a leaf containing Dnf with singleton clause {a} + Assert.That(dxa, Is.InstanceOf>>>()); + var leaf = ((TransitionTermLeaf>>)dxa).Value; + Assert.That(leaf.ClauseCount, Is.EqualTo(1)); + Assert.That(leaf.Clauses[0].Count, Is.EqualTo(1)); + Assert.That(leaf.Clauses[0].First(), Is.EqualTo(a)); + } + + [Test] + public void Derivative_Until() + { + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var b = Ltl.Atom(_b); + var aUb = Ltl.Until(a, b); + + var daUb = d.Derivative(aUb); + + // ∂(a U b) = ∂(b) ∨ (∂(a) ∧ atom(a U b)) + // = ITE(b, ⊤, ⊥) ∨ (ITE(a, ⊤, ⊥) ∧ atom(aUb)) + // Should be an ITE with conditions for a and b + Assert.That(daUb, Is.Not.Null); + // Verify it's not trivially bottom or top + var leaves = daUb.GetDistinctLeaves().ToList(); + Assert.That(leaves.Count, Is.GreaterThan(0)); + } + + #endregion + + #region ABW Construction Tests + + [Test] + public void ABW_FromEventually_a() + { + // Fa = ⊤ U a + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var fa = Ltl.Eventually(a); + + var abw = d.ToABW(fa); + + Assert.That(abw.InitialState, Is.EqualTo(abw.DnfAlgebra.Atom(fa))); + // Fa is an Until formula, so NOT accepting + Assert.That(abw.IsAccepting(fa), Is.False); + // True is accepting + Assert.That(abw.IsAccepting(Ltl.True()), Is.True); + + // Compute transition for initial state + var trans = abw.GetTransition(fa); + Assert.That(trans, Is.Not.Null); + } + + [Test] + public void ABW_FromGlobally_a() + { + // Ga = ⊥ R a + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var ga = Ltl.Globally(a); + + var abw = d.ToABW(ga); + + Assert.That(abw.InitialState, Is.EqualTo(abw.DnfAlgebra.Atom(ga))); + // Ga is a Release formula, which IS accepting + Assert.That(abw.IsAccepting(ga), Is.True); + + var trans = abw.GetTransition(ga); + Assert.That(trans, Is.Not.Null); + } + + #endregion + + #region Full Pipeline: LTL → ABW → Æ → NBW + + [Test] + public void Pipeline_Fa_EventuallyA() + { + // Fa = ⊤ U a: "a must eventually hold" + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var fa = Ltl.Eventually(a); + + var abw = d.ToABW(fa); + var nbw = AlternationElimination.Eliminate, Ltl>(abw); + + Assert.That(nbw, Is.Not.Null); + Assert.That(nbw.InitialStates.Count, Is.GreaterThan(0)); + Assert.That(nbw.States.Count, Is.GreaterThan(0)); + + // NBW should have some accepting states + var acceptingStates = nbw.States.Where(s => nbw.IsAccepting(s)).ToList(); + Assert.That(acceptingStates.Count, Is.GreaterThan(0), + "Fa should produce NBW with accepting states (breakpoint reached)"); + } + + [Test] + public void Pipeline_Ga_GloballyA() + { + // Ga = ⊥ R a: "a must always hold" + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var ga = Ltl.Globally(a); + + var abw = d.ToABW(ga); + var nbw = AlternationElimination.Eliminate, Ltl>(abw); + + Assert.That(nbw, Is.Not.Null); + Assert.That(nbw.States.Count, Is.GreaterThan(0)); + } + + [Test] + public void Pipeline_GFa_InfinitelyOftenA() + { + // GFa = G(Fa) = ⊥ R (⊤ U a): "a holds infinitely often" + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var gfa = Ltl.Globally(Ltl.Eventually(a)); + + var abw = d.ToABW(gfa); + var nbw = AlternationElimination.Eliminate, Ltl>(abw); + + Assert.That(nbw, Is.Not.Null); + Assert.That(nbw.States.Count, Is.GreaterThan(0)); + } + + [Test] + public void Pipeline_G_AImpliesFb() + { + // G(a → Fb): "whenever a holds, b must eventually hold" + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var b = Ltl.Atom(_b); + var fb = Ltl.Eventually(b); + var aImplFb = _alg.Implies(a, fb); + var formula = Ltl.Globally(aImplFb); + + var abw = d.ToABW(formula); + var nbw = AlternationElimination.Eliminate, Ltl>(abw); + + Assert.That(nbw, Is.Not.Null); + Assert.That(nbw.States.Count, Is.GreaterThan(0)); + + // This is a more complex formula — NBW should have at least 1 state + Assert.That(nbw.States.Count, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public void Pipeline_Xa_NextA() + { + // Xa: "a holds in the next step" + var d = MakeDerivative(); + var a = Ltl.Atom(_a); + var xa = Ltl.Next(a); + + var abw = d.ToABW(xa); + var nbw = AlternationElimination.Eliminate, Ltl>(abw); + + Assert.That(nbw, Is.Not.Null); + Assert.That(nbw.States.Count, Is.GreaterThan(0)); + } + + #endregion + + #region DOT Visualization Tests (skipped — visualization not ported) + + // BuchiVisualization / DotRenderer not ported to Accordant. + // These tests are intentionally removed. + + #endregion + + #region Accepting State Semantics + + [Test] + public void IsAccepting_UntilIsNotAccepting() + { + var a = Ltl.Atom(_a); + var b = Ltl.Atom(_b); + var until = Ltl.Until(a, b); + + Assert.That(LtlDerivative>.IsAccepting(until), Is.False); + } + + [Test] + public void IsAccepting_NonUntilIsAccepting() + { + var a = Ltl.Atom(_a); + Assert.That(LtlDerivative>.IsAccepting(a), Is.True); + Assert.That(LtlDerivative>.IsAccepting(Ltl.True()), Is.True); + Assert.That(LtlDerivative>.IsAccepting(Ltl.False()), Is.True); + Assert.That(LtlDerivative>.IsAccepting( + Ltl.Globally(a)), Is.True); + Assert.That(LtlDerivative>.IsAccepting( + Ltl.Next(a)), Is.True); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/LtlSerializationAndAETests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/LtlSerializationAndAETests.cs new file mode 100644 index 0000000..457da2c --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/LtlSerializationAndAETests.cs @@ -0,0 +1,512 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for LTL JSON serialization, incremental Æ, and correctness validation + /// comparing batch vs incremental alternation elimination. + /// + [TestFixture] + public class LtlSerializationAndAETests + { + private static readonly LtlAlgebra SAlg = + new LtlAlgebra(StringFreeAlgebra.Instance); + + #region JSON Serialization Tests + + [Test] + public void Json_RoundTrip_True() + { + var f = Ltl.True(); + var json = LtlJson.Serialize(f); + Assert.That(json, Is.EqualTo("{\"op\":\"True\"}")); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_False() + { + var f = Ltl.False(); + var json = LtlJson.Serialize(f); + Assert.That(json, Is.EqualTo("{\"op\":\"False\"}")); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_Atom() + { + var f = Ltl.Atom("request"); + var json = LtlJson.Serialize(f); + Assert.That(json, Does.Contain("\"Atom\"")); + Assert.That(json, Does.Contain("\"request\"")); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_NegAtom() + { + var f = SAlg.NegAtom("busy"); + var json = LtlJson.Serialize(f); + // With EBA fusion, NegAtom("busy") => Atom(StringFreeAlgebra.Not("busy")) = Atom("¬busy") + Assert.That(json, Does.Contain("\"Atom\"")); + Assert.That(json, Does.Contain("¬busy")); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_Next() + { + var f = Ltl.Next(Ltl.Atom("a")); + var json = LtlJson.Serialize(f); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_Until() + { + var f = Ltl.Until(Ltl.Atom("a"), Ltl.Atom("b")); + var json = LtlJson.Serialize(f); + Assert.That(json, Does.Contain("\"Until\"")); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_Release() + { + var f = Ltl.Release(Ltl.Atom("a"), Ltl.Atom("b")); + var json = LtlJson.Serialize(f); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_And() + { + var f = SAlg.And(Ltl.Atom("a"), Ltl.Atom("b")); + var json = LtlJson.Serialize(f); + // With EBA fusion over StringFreeAlgebra, And(Atom("a"),Atom("b")) + // collapses to a single Atom("(a ∧ b)"). + Assert.That(json, Does.Contain("\"Atom\"")); + Assert.That(json, Does.Contain("∧")); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_Or() + { + var f = SAlg.Or(Ltl.Atom("a"), Ltl.Atom("b")); + var json = LtlJson.Serialize(f); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + } + + [Test] + public void Json_RoundTrip_ComplexFormula() + { + // G(a → F b) + var a = Ltl.Atom("a"); + var b = Ltl.Atom("b"); + var formula = Ltl.Globally(SAlg.Implies(a, Ltl.Eventually(b))); + var json = LtlJson.Serialize(formula); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(formula)); + } + + [Test] + public void Json_Deserialize_Sugar_Eventually() + { + var json = "{\"op\":\"Eventually\",\"inner\":{\"op\":\"Atom\",\"pred\":\"a\"}}"; + var f = LtlJson.Deserialize(json); + var expected = Ltl.Eventually(Ltl.Atom("a")); + Assert.That(f, Is.EqualTo(expected)); + } + + [Test] + public void Json_Deserialize_Sugar_Globally() + { + var json = "{\"op\":\"Globally\",\"inner\":{\"op\":\"Atom\",\"pred\":\"a\"}}"; + var f = LtlJson.Deserialize(json); + var expected = Ltl.Globally(Ltl.Atom("a")); + Assert.That(f, Is.EqualTo(expected)); + } + + [Test] + public void Json_Deserialize_Sugar_Implies() + { + var json = "{\"op\":\"Implies\",\"left\":{\"op\":\"Atom\",\"pred\":\"a\"},\"right\":{\"op\":\"Atom\",\"pred\":\"b\"}}"; + var f = LtlJson.Deserialize(json); + var expected = SAlg.Implies(Ltl.Atom("a"), Ltl.Atom("b")); + Assert.That(f, Is.EqualTo(expected)); + } + + [Test] + public void Json_SpecialChars_InPredicate() + { + var f = Ltl.Atom("x > 0"); + var json = LtlJson.Serialize(f); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(f)); + Assert.That(((LtlAtom)f2).Predicate, Is.EqualTo("x > 0")); + } + + [Test] + public void Json_NestedFormula_GFaAndGFb() + { + // GFa ∧ GFb + var a = Ltl.Atom("a"); + var b = Ltl.Atom("b"); + var formula = SAlg.And( + Ltl.Globally(Ltl.Eventually(a)), + Ltl.Globally(Ltl.Eventually(b))); + var json = LtlJson.Serialize(formula); + var f2 = LtlJson.Deserialize(json); + Assert.That(f2, Is.EqualTo(formula)); + } + + #endregion + + #region Incremental Æ Tests + + // Simple proposition EBA for testing + private sealed class Prop : IEquatable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public override string ToString() => Name; + public override int GetHashCode() => Name.GetHashCode(); + public override bool Equals(object obj) => Equals(obj as Prop); + public bool Equals(Prop other) => other != null && Name == other.Name; + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top => new Prop("⊤"); + public Prop Bottom => new Prop("⊥"); + + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + + public bool IsSatisfiable(Prop predicate) => predicate.Name != "⊥"; + + public bool Models(HashSet element, Prop predicate) + { + if (predicate.Name == "⊤") return true; + if (predicate.Name == "⊥") return false; + if (predicate.Name.StartsWith("¬")) + return !element.Contains(predicate.Name.Substring(1)); + return element.Contains(predicate.Name); + } + } + + private PropEba _eba; + private LtlAlgebra _alg; + private ConditionRegistry _registry; + + [SetUp] + public void Setup() + { + _eba = new PropEba(); + _alg = new LtlAlgebra(_eba); + _registry = new ConditionRegistry(); + } + + private LtlDerivative> MakeDerivative() + => new LtlDerivative>(_eba, _registry); + + [Test] + public void IncrementalAE_Fa_ProducesNBW() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var fa = Ltl.Eventually(a); + var abw = d.ToABW(fa); + + var incAE = new IncrementalAE, Ltl>(abw); + var nbw = incAE.ToNBW(); + + Assert.That(nbw.InitialStates.Count, Is.EqualTo(1)); + Assert.That(incAE.IsAccepting(incAE.InitialState), Is.True, + "Initial state has empty obligation, so it's accepting"); + + // Get transition for initial state — this triggers lazy computation + var trans = nbw.GetTransition(incAE.InitialState); + Assert.That(trans, Is.Not.Null); + Assert.That(trans.Count, Is.GreaterThan(0)); + Assert.That(incAE.ComputedStateCount, Is.EqualTo(1)); + } + + [Test] + public void IncrementalAE_Ga_ProducesNBW() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var ga = Ltl.Globally(a); + var abw = d.ToABW(ga); + + var incAE = new IncrementalAE, Ltl>(abw); + var nbw = incAE.ToNBW(); + + var trans = nbw.GetTransition(incAE.InitialState); + Assert.That(trans.Count, Is.GreaterThan(0)); + } + + [Test] + public void IncrementalAE_LazyComputation_OnlyExploredStates() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var b = Ltl.Atom(new Prop("b")); + var formula = Ltl.Globally(_alg.Implies(a, Ltl.Eventually(b))); + var abw = d.ToABW(formula); + + var incAE = new IncrementalAE, Ltl>(abw); + var nbw = incAE.ToNBW(); + + // Before any transitions are requested, nothing is computed + Assert.That(incAE.ComputedStateCount, Is.EqualTo(0)); + + // Get initial transition + nbw.GetTransition(incAE.InitialState); + Assert.That(incAE.ComputedStateCount, Is.EqualTo(1)); + + // Exploring more states should increase the count + var explored = AlternationElimination.Explore(nbw, maxStates: 10); + Assert.That(incAE.ComputedStateCount, Is.GreaterThan(1)); + } + + [Test] + public void IncrementalAE_MatchesBatch_Fa() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var fa = Ltl.Eventually(a); + var abw = d.ToABW(fa); + + // Batch + var batchNbw = AlternationElimination.Eliminate, Ltl>(abw); + var batchStates = AlternationElimination.Explore(batchNbw); + + // Need fresh ABW for incremental (registries shared but ABW caches are independent) + var d2 = new LtlDerivative>(_eba, _registry); + var abw2 = d2.ToABW(fa); + var incAE = new IncrementalAE, Ltl>(abw2); + var incNbw = incAE.ToNBW(); + var incStates = AlternationElimination.Explore(incNbw); + + // Both should discover the same number of states + Assert.That(incStates.Count, Is.EqualTo(batchStates.Count), + "Incremental and batch Æ should produce same number of reachable states"); + } + + [Test] + public void IncrementalAE_MatchesBatch_Ga() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var ga = Ltl.Globally(a); + var abw = d.ToABW(ga); + + var batchNbw = AlternationElimination.Eliminate, Ltl>(abw); + var batchStates = AlternationElimination.Explore(batchNbw); + + var d2 = new LtlDerivative>(_eba, _registry); + var abw2 = d2.ToABW(ga); + var incAE = new IncrementalAE, Ltl>(abw2); + var incNbw = incAE.ToNBW(); + var incStates = AlternationElimination.Explore(incNbw); + + Assert.That(incStates.Count, Is.EqualTo(batchStates.Count)); + } + + [Test] + public void IncrementalAE_MatchesBatch_GFa() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var gfa = Ltl.Globally(Ltl.Eventually(a)); + var abw = d.ToABW(gfa); + + var batchNbw = AlternationElimination.Eliminate, Ltl>(abw); + var batchStates = AlternationElimination.Explore(batchNbw); + + var d2 = new LtlDerivative>(_eba, _registry); + var abw2 = d2.ToABW(gfa); + var incAE = new IncrementalAE, Ltl>(abw2); + var incNbw = incAE.ToNBW(); + var incStates = AlternationElimination.Explore(incNbw); + + Assert.That(incStates.Count, Is.EqualTo(batchStates.Count)); + } + + // ----- DnfLeaves knob: eagerAntimirov=false on batch and incremental ----- + + [Test] + public void DnfLeaves_BatchAndIncremental_MatchEagerStateCount_OnGFAndFairnessFormulas() + { + var a = Ltl.Atom(new Prop("a")); + var b = Ltl.Atom(new Prop("b")); + var c = Ltl.Atom(new Prop("c")); + var formulas = new (string name, Ltl phi)[] + { + ("Fa", Ltl.Eventually(a)), + ("Ga", Ltl.Globally(a)), + ("GFa", Ltl.Globally(Ltl.Eventually(a))), + ("G(a->Fb)", Ltl.Globally(_alg.Implies(a, Ltl.Eventually(b)))), + ("GFa & GFb", _alg.And( + Ltl.Globally(Ltl.Eventually(a)), + Ltl.Globally(Ltl.Eventually(b)))), + ("GFa&GFb&GFc", _alg.And(_alg.And( + Ltl.Globally(Ltl.Eventually(a)), + Ltl.Globally(Ltl.Eventually(b))), + Ltl.Globally(Ltl.Eventually(c)))), + }; + + foreach (var (name, phi) in formulas) + { + var dE = new LtlDerivative>(_eba, _registry); + var eagerBatch = AlternationElimination.Explore( + AlternationElimination.Eliminate, Ltl>( + dE.ToABW(phi), eagerAntimirov: true)).Count; + + var dD = new LtlDerivative>(_eba, _registry); + var dnfBatch = AlternationElimination.Explore( + AlternationElimination.Eliminate, Ltl>( + dD.ToABW(phi), eagerAntimirov: false)).Count; + + var dI = new LtlDerivative>(_eba, _registry); + var incAE = new IncrementalAE, Ltl>( + dI.ToABW(phi), eagerAntimirov: false); + var dnfInc = AlternationElimination.Explore(incAE.ToNBW()).Count; + + Assert.That(dnfBatch, Is.EqualTo(eagerBatch), + $"DnfLeaves batch should match eager batch on {name}"); + Assert.That(dnfInc, Is.EqualTo(eagerBatch), + $"DnfLeaves incremental should match eager batch on {name}"); + } + } + + [Test] + public void IncrementalAE_MatchesBatch_G_AImplFb() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var b = Ltl.Atom(new Prop("b")); + var formula = Ltl.Globally(_alg.Implies(a, Ltl.Eventually(b))); + var abw = d.ToABW(formula); + + var batchNbw = AlternationElimination.Eliminate, Ltl>(abw); + var batchStates = AlternationElimination.Explore(batchNbw); + + var d2 = new LtlDerivative>(_eba, _registry); + var abw2 = d2.ToABW(formula); + var incAE = new IncrementalAE, Ltl>(abw2); + var incNbw = incAE.ToNBW(); + var incStates = AlternationElimination.Explore(incNbw); + + Assert.That(incStates.Count, Is.EqualTo(batchStates.Count)); + } + + [Test] + public void IncrementalAE_AcceptingStates_Consistent() + { + var d = MakeDerivative(); + var a = Ltl.Atom(new Prop("a")); + var fa = Ltl.Eventually(a); + var abw = d.ToABW(fa); + + var incAE = new IncrementalAE, Ltl>(abw); + var nbw = incAE.ToNBW(); + var states = AlternationElimination.Explore(nbw); + + // Verify accepting states have empty obligation + foreach (var s in states) + { + bool acc = nbw.IsAccepting(s); + Assert.That(acc, Is.EqualTo(s.Obligation.IsEmpty), + $"State {s}: IsAccepting={acc} but O.IsEmpty={s.Obligation.IsEmpty}"); + } + } + + #endregion + + #region Cross-validation: Batch vs Incremental accepting state agreement + + [Test] + public void CrossValidation_AcceptingStatesMatch_Fa() + { + ValidateAcceptingStatesMatch( + Ltl.Eventually(Ltl.Atom(new Prop("a")))); + } + + [Test] + public void CrossValidation_AcceptingStatesMatch_GFa() + { + ValidateAcceptingStatesMatch( + Ltl.Globally(Ltl.Eventually(Ltl.Atom(new Prop("a"))))); + } + + [Test] + public void CrossValidation_AcceptingStatesMatch_aUb() + { + ValidateAcceptingStatesMatch( + Ltl.Until( + Ltl.Atom(new Prop("a")), + Ltl.Atom(new Prop("b")))); + } + + private void ValidateAcceptingStatesMatch(Ltl formula) + { + var d1 = new LtlDerivative>(_eba, _registry); + var abw1 = d1.ToABW(formula); + var batchNbw = AlternationElimination.Eliminate, Ltl>(abw1); + var batchStates = AlternationElimination.Explore(batchNbw); + int batchAccepting = batchStates.Count(s => batchNbw.IsAccepting(s)); + + var d2 = new LtlDerivative>(_eba, _registry); + var abw2 = d2.ToABW(formula); + var incAE = new IncrementalAE, Ltl>(abw2); + var incNbw = incAE.ToNBW(); + var incStates = AlternationElimination.Explore(incNbw); + int incAccepting = incStates.Count(s => incNbw.IsAccepting(s)); + + Assert.That(incAccepting, Is.EqualTo(batchAccepting), + $"Formula {formula}: batch has {batchAccepting} accepting, incremental has {incAccepting}"); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/MacrostateTransitionMergeEndToEndTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/MacrostateTransitionMergeEndToEndTests.cs new file mode 100644 index 0000000..0740169 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/MacrostateTransitionMergeEndToEndTests.cs @@ -0,0 +1,185 @@ +using Microsoft.Accordant; + +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Bdd; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// End-to-end tests for the macrostate transition-merge knob + /// (SymbolicRltlCheck.Check(..., subsumeMacrostate: true)), + /// which now wires . + /// + /// + /// The rule collapses universal copies with identical transition + /// terms and matching colour. On GFa ∧ GF(¬a) (JACM + /// Example 5.1) this fires; on GFa ∧ GFb ∧ GFc it does not + /// (the three F-states have distinct deltas). + /// + /// + [TestFixture] + public class MacrostateTransitionMergeEndToEndTests + { + private static readonly BddStatePropEba Eba = BddStatePropEba.Instance; + private static readonly StateProp A = new StateProp("a", _ => true); + private static readonly StateProp B = new StateProp("b", _ => true); + private static readonly StateProp C = new StateProp("c", _ => true); + + private static Rltl GFaGFbGFc() + { + var alg = new RltlAlgebra(Eba); + var Fa = alg.Eventually(alg.Atom(new StatePredAtom(A))); + var Fb = alg.Eventually(alg.Atom(new StatePredAtom(B))); + var Fc = alg.Eventually(alg.Atom(new StatePredAtom(C))); + return alg.And(alg.Globally(Fa), alg.And(alg.Globally(Fb), alg.Globally(Fc))); + } + + private static Rltl GFa_And_GFnota() + { + var alg = new RltlAlgebra(Eba); + var a = Rltl.Atom(new StatePredAtom(A)); + var nota = Rltl.Atom(Eba.Not(new StatePredAtom(A))); + return alg.And(alg.Globally(alg.Eventually(a)), + alg.Globally(alg.Eventually(nota))); + } + + // Build the breakpoint NBW for φ directly (no negation) with the + // requested knobs and report the number of reachable BP states. + private static int CountReachableBp( + Rltl phi, bool subsume, int hardCap) + { + var registry = new ConditionRegistry( + EqualityComparer.Default); + var deriv = new RltlDerivative(Eba, registry, null, null); + var abw = deriv.ToABW(phi); + + Func>, MacroReduction>> reducer = null; + if (subsume) + reducer = new RltlMacrostateTransitionMerge(abw.GetTransition).Reduce; + + var ae = new IncrementalAE>( + abw, breakpointCanonicalizer: null, macroReducer: reducer); + var nbw = ae.ToNBW(); + + var cmp = BreakpointState>.GetEqualityComparer(); + var seen = new HashSet>>(cmp); + var queue = new Queue>>(); + foreach (var s in nbw.InitialStates) + if (seen.Add(s)) queue.Enqueue(s); + while (queue.Count > 0 && seen.Count < hardCap) + { + var s = queue.Dequeue(); + foreach (var tt in nbw.GetTransition(s)) + foreach (var leaf in tt.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) queue.Enqueue(succ); + } + return seen.Count; + } + + // Returns (anyNonEmptyObligation, reachableCount) — the soundness + // probe: if every reachable BP has O = ∅ then the NBW is universal, + // which would indicate an unsound reduction. + private static (bool anyObligation, int count) WalkBp( + Rltl phi, bool subsume, int hardCap) + { + var registry = new ConditionRegistry( + EqualityComparer.Default); + var deriv = new RltlDerivative(Eba, registry, null, null); + var abw = deriv.ToABW(phi); + Func>, MacroReduction>> reducer = null; + if (subsume) + reducer = new RltlMacrostateTransitionMerge(abw.GetTransition).Reduce; + var ae = new IncrementalAE>(abw, null, reducer); + var nbw = ae.ToNBW(); + var cmp = BreakpointState>.GetEqualityComparer(); + var seen = new HashSet>>(cmp); + var queue = new Queue>>(); + foreach (var s in nbw.InitialStates) + if (seen.Add(s)) queue.Enqueue(s); + bool any = false; + while (queue.Count > 0 && seen.Count < hardCap) + { + var s = queue.Dequeue(); + if (!s.Obligation.IsEmpty) any = true; + foreach (var tt in nbw.GetTransition(s)) + foreach (var leaf in tt.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) queue.Enqueue(succ); + } + return (any, seen.Count); + } + + // --------------- GFa ∧ GF(¬a) — JACM Example 5.1 --------------- + + [Test] + public void TransitionMerge_OnGFaAndGFnota_IsSound() + { + var (any, n) = WalkBp(GFa_And_GFnota(), subsume: true, hardCap: 200); + TestContext.Out.WriteLine($"GFa ∧ GF(¬a): BPs={n} anyNonEmptyObligation={any}"); + Assert.That(any, Is.True, + "Under the structural transition-merge rule at least one " + + "reachable BP must carry a non-empty obligation."); + } + + [Test] + public void TransitionMerge_OnGFaAndGFnota_DoesNotGrowState() + { + var off = CountReachableBp(GFa_And_GFnota(), subsume: false, hardCap: 200); + var on = CountReachableBp(GFa_And_GFnota(), subsume: true, hardCap: 200); + TestContext.Out.WriteLine($"GFa ∧ GF(¬a): off={off} merge={on}"); + Assert.That(on, Is.LessThanOrEqualTo(off)); + } + + // --------------- GFa ∧ GFb ∧ GFc (no merge fires) --------------- + + [Test] + public void TransitionMerge_OnGFaGFbGFc_IsSound_NoChange() + { + var off = CountReachableBp(GFaGFbGFc(), subsume: false, hardCap: 200); + var on = CountReachableBp(GFaGFbGFc(), subsume: true, hardCap: 200); + TestContext.Out.WriteLine($"GFa ∧ GFb ∧ GFc: off={off} merge={on}"); + // The three F-states have distinct deltas → no collapse → equal counts. + Assert.That(on, Is.EqualTo(off)); + + var (any, _) = WalkBp(GFaGFbGFc(), subsume: true, hardCap: 200); + Assert.That(any, Is.True, "NBW must not be universal."); + } + + // --------------- Differential corpus: merge never grows state --------------- + + private static Rltl[] DifferentialCorpus() + { + var alg = new RltlAlgebra(Eba); + var a = alg.Atom(new StatePredAtom(A)); + var b = alg.Atom(new StatePredAtom(B)); + var Fa = alg.Eventually(a); + var Ga = alg.Globally(a); + return new[] + { + a, + Fa, + Ga, + alg.And(Fa, alg.Eventually(b)), + alg.Or(Ga, alg.Globally(b)), + alg.Globally(Fa), + alg.And(alg.Globally(Fa), alg.Globally(alg.Eventually(b))), + alg.Until(a, b), + alg.Release(a, b), + alg.Next(alg.And(a, b)), + }; + } + + [Test, TestCaseSource(nameof(DifferentialCorpus))] + public void TransitionMerge_DoesNotEnlargeBpCount(Rltl phi) + { + int off = CountReachableBp(phi, subsume: false, hardCap: 500); + int on = CountReachableBp(phi, subsume: true, hardCap: 500); + Assert.That(on, Is.LessThanOrEqualTo(off), + $"Merge must not enlarge the BP count for φ={phi}"); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/NbwProductTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/NbwProductTests.cs new file mode 100644 index 0000000..4bc3db4 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/NbwProductTests.cs @@ -0,0 +1,552 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for NbwProduct: intersection of two symbolic NBWs. + /// Validates the breakpoint construction by building NBWs from LTL formulas + /// and checking that the product accepts exactly the intersection language. + /// + [TestFixture] + public class NbwProductTests + { + private sealed class Prop : IEquatable, IComparable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public override string ToString() => Name; + public override int GetHashCode() => Name.GetHashCode(); + public override bool Equals(object obj) => Equals(obj as Prop); + public bool Equals(Prop other) => other != null && Name == other.Name; + public int CompareTo(Prop other) => string.Compare(Name, other?.Name, StringComparison.Ordinal); + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top => new Prop("⊤"); + public Prop Bottom => new Prop("⊥"); + + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + + public bool IsSatisfiable(Prop predicate) => predicate.Name != "⊥"; + + public bool Models(HashSet element, Prop predicate) + { + if (predicate.Name == "⊤") return true; + if (predicate.Name == "⊥") return false; + if (predicate.Name.StartsWith("¬")) + return !element.Contains(predicate.Name.Substring(1)); + if (predicate.Name.StartsWith("(") && predicate.Name.Contains("∧")) + { + var parts = predicate.Name.Substring(1, predicate.Name.Length - 2) + .Split(new[] { "∧" }, StringSplitOptions.None); + return parts.All(p => Models(element, new Prop(p))); + } + if (predicate.Name.StartsWith("(") && predicate.Name.Contains("∨")) + { + var parts = predicate.Name.Substring(1, predicate.Name.Length - 2) + .Split(new[] { "∨" }, StringSplitOptions.None); + return parts.Any(p => Models(element, new Prop(p))); + } + return element.Contains(predicate.Name); + } + } + + private PropEba _eba; + private LtlAlgebra _alg; + private ConditionRegistry _registry; + private Prop _a, _b; + + [SetUp] + public void Setup() + { + _eba = new PropEba(); + _alg = new LtlAlgebra(_eba); + _registry = new ConditionRegistry(); + _a = new Prop("a"); + _b = new Prop("b"); + } + + private SymbolicNBW, BreakpointState>> BuildNbw(Ltl formula) + { + var deriv = new LtlDerivative>(_eba, _registry); + var abw = deriv.ToABW(formula); + var ae = new IncrementalAE, Ltl>(abw); + return ae.ToNBW(); + } + + private IComparer>> BpComparer + => BreakpointState>.GetComparer(Comparer>.Default); + + /// + /// Explores the reachable states of an NBW by following transitions. + /// Returns the set of states and count of transitions. + /// + private (int stateCount, int transCount) ExploreNbw( + SymbolicNBW, TState> nbw) + { + var visited = new HashSet(); + var worklist = new Queue(); + int transitions = 0; + + foreach (var init in nbw.InitialStates) + { + if (visited.Add(init)) + worklist.Enqueue(init); + } + + while (worklist.Count > 0) + { + var state = worklist.Dequeue(); + var trans = nbw.GetTransition(state); + foreach (var term in trans) + { + transitions++; + foreach (var leaf in term.GetDistinctLeaves()) + { + foreach (var succ in leaf) + { + if (visited.Add(succ)) + worklist.Enqueue(succ); + } + } + } + } + + return (visited.Count, transitions); + } + + /// + /// Simulates an NBW on a finite word prefix (repeated as omega-word). + /// Returns true if any run visits an accepting state infinitely often + /// (checked by repeating the word and tracking acceptance). + /// + private bool Accepts( + SymbolicNBW, TState> nbw, + HashSet[] word, int repetitions = 10) + { + // Current frontier of (state, acceptCount) pairs + var frontier = new HashSet(); + foreach (var init in nbw.InitialStates) + frontier.Add(init); + + int totalAcceptSeen = 0; + for (int rep = 0; rep < repetitions; rep++) + { + foreach (var letter in word) + { + var nextFrontier = new HashSet(); + foreach (var state in frontier) + { + var trans = nbw.GetTransition(state); + foreach (var term in trans) + { + var successors = EvaluateTerm(term, letter, nbw.Eba); + foreach (var succ in successors) + nextFrontier.Add(succ); + } + } + frontier = nextFrontier; + if (frontier.Count == 0) return false; + + // Count accepting states in frontier + foreach (var s in frontier) + if (nbw.IsAccepting(s)) + totalAcceptSeen++; + } + } + + // If accepting states are visited proportionally to repetitions, likely accepts + return totalAcceptSeen >= repetitions; + } + + private static IEnumerable EvaluateTerm( + TransitionTerm> term, HashSet letter, + IEffectiveBooleanAlgebra> eba) + { + if (term is TransitionTermLeaf> leaf) + return leaf.Value; + var ite = (TransitionTermIte>)term; + // We need to get the condition — but we don't have the registry here. + // Use a different approach: collect all leaves reachable under the valuation. + return CollectLeavesForValuation(term, letter, eba); + } + + private static IEnumerable CollectLeavesForValuation( + TransitionTerm> term, HashSet letter, + IEffectiveBooleanAlgebra> eba) + { + if (term is TransitionTermLeaf> leaf) + return leaf.Value; + // For ITE nodes, we'd need the condition registry. Use structural approach instead. + // Since we're testing, we can collect from both branches conservatively. + // Actually for proper evaluation, we need the registry. Skip this approach. + // Instead, in tests we'll verify structural properties. + return Enumerable.Empty(); + } + + #region Basic Product Tests + + [Test] + public void Product_GFa_And_GFb_HasStates() + { + // GFa ∩ GFb = infinitely often a AND infinitely often b + var gfa = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_a))); + var gfb = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_b))); + + var nbw1 = BuildNbw(gfa); + var nbw2 = BuildNbw(gfb); + + var product = NbwProduct.Intersect(nbw1, nbw2, + BpComparer, + BpComparer); + + // Product should have initial states + Assert.That(product.InitialStates.Count, Is.GreaterThan(0)); + + // Explore reachable states + var (stateCount, transCount) = ExploreNbw(product); + Assert.That(stateCount, Is.GreaterThan(0)); + Assert.That(transCount, Is.GreaterThan(0)); + + // Should have some accepting states (flag=2) + bool hasAccepting = false; + var visited = new HashSet>, BreakpointState>>>(); + var wl = new Queue>, BreakpointState>>>(); + foreach (var s in product.InitialStates) + { + if (visited.Add(s)) wl.Enqueue(s); + } + while (wl.Count > 0) + { + var s = wl.Dequeue(); + if (product.IsAccepting(s)) hasAccepting = true; + foreach (var term in product.GetTransition(s)) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var succ in leaf) + if (visited.Add(succ)) + wl.Enqueue(succ); + } + Assert.That(hasAccepting, Is.True, "Product of GFa and GFb should have accepting states"); + } + + [Test] + public void Product_Ga_And_Gb_Yields_GaAndGb() + { + // Ga ∩ Gb = G(a∧b) — always a and always b = always both + var ga = Ltl.Globally(Ltl.Atom(_a)); + var gb = Ltl.Globally(Ltl.Atom(_b)); + + var nbw1 = BuildNbw(ga); + var nbw2 = BuildNbw(gb); + + var product = NbwProduct.Intersect(nbw1, nbw2, + BpComparer, + BpComparer); + + var (stateCount, _) = ExploreNbw(product); + // G(a) has few states, G(b) has few states, product should be small + Assert.That(stateCount, Is.GreaterThan(0)); + Assert.That(product.InitialStates.Count, Is.GreaterThan(0)); + } + + [Test] + public void Product_Fa_And_Fb_IsNotEmpty() + { + // Fa ∩ Fb — eventually a and eventually b + var fa = Ltl.Eventually(Ltl.Atom(_a)); + var fb = Ltl.Eventually(Ltl.Atom(_b)); + + var nbw1 = BuildNbw(fa); + var nbw2 = BuildNbw(fb); + + var product = NbwProduct.Intersect(nbw1, nbw2, + BpComparer, + BpComparer); + + var (stateCount, _) = ExploreNbw(product); + Assert.That(stateCount, Is.GreaterThan(0)); + } + + [Test] + public void Product_Ga_And_FNota_IsEmpty() + { + // Ga ∩ F(¬a) = ∅ — always a AND eventually not a is impossible + var ga = Ltl.Globally(Ltl.Atom(_a)); + var fna = Ltl.Eventually(_alg.NegAtom(_a)); + + var nbw1 = BuildNbw(ga); + var nbw2 = BuildNbw(fna); + + var product = NbwProduct.Intersect(nbw1, nbw2, + BpComparer, + BpComparer); + + // The product may have reachable states, but no accepting cycles. + // We verify by checking that the language is empty: no reachable accepting state + // that is part of a cycle. + var (stateCount, _) = ExploreNbw(product); + // Product may still have states (dead ends), but should be constructed + Assert.That(product.InitialStates.Count, Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void Product_SameFormula_EquivalentToOriginal() + { + // NBW(φ) ∩ NBW(φ) should recognize the same language as NBW(φ) + var formula = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_a))); + + var nbw = BuildNbw(formula); + var product = NbwProduct.Intersect(nbw, nbw, + BpComparer, + BpComparer); + + var (origStates, _) = ExploreNbw(nbw); + var (prodStates, _) = ExploreNbw(product); + + // Product of same NBW with itself should have states ≤ |Q|² * 3 + Assert.That(prodStates, Is.LessThanOrEqualTo(origStates * origStates * 3)); + Assert.That(prodStates, Is.GreaterThan(0)); + } + + [Test] + public void Product_InitialStates_CrossProduct() + { + var ga = Ltl.Globally(Ltl.Atom(_a)); + var gb = Ltl.Globally(Ltl.Atom(_b)); + + var nbw1 = BuildNbw(ga); + var nbw2 = BuildNbw(gb); + + var product = NbwProduct.Intersect(nbw1, nbw2, + BpComparer, + BpComparer); + + // Initial states should be cross product of NBW1 and NBW2 initials + Assert.That(product.InitialStates.Count, + Is.EqualTo(nbw1.InitialStates.Count * nbw2.InitialStates.Count)); + } + + [Test] + public void Product_AcceptingStates_HaveFlag2() + { + var gfa = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_a))); + + var nbw = BuildNbw(gfa); + var product = NbwProduct.Intersect(nbw, nbw, + BpComparer, + BpComparer); + + // All accepting states should have flag == 2 + var visited = new HashSet>, BreakpointState>>>(); + var wl = new Queue>, BreakpointState>>>(); + foreach (var s in product.InitialStates) + if (visited.Add(s)) wl.Enqueue(s); + while (wl.Count > 0) + { + var s = wl.Dequeue(); + if (product.IsAccepting(s)) + Assert.That(s.Flag, Is.EqualTo(2), $"Accepting state {s} should have flag=2"); + foreach (var term in product.GetTransition(s)) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var succ in leaf) + if (visited.Add(succ)) + wl.Enqueue(succ); + } + } + + [Test] + public void Product_FlagAdvancement_Works() + { + // Verify that flags cycle: 0 → 1 (when F₁ seen) → 2 (when F₂ seen) → 0 + var gfa = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_a))); + var gfb = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_b))); + + var nbw1 = BuildNbw(gfa); + var nbw2 = BuildNbw(gfb); + + var product = NbwProduct.Intersect(nbw1, nbw2, + BpComparer, + BpComparer); + + // Explore and check that all three flag values are reachable + var flagsSeen = new HashSet(); + var visited = new HashSet>, BreakpointState>>>(); + var wl = new Queue>, BreakpointState>>>(); + foreach (var s in product.InitialStates) + if (visited.Add(s)) wl.Enqueue(s); + while (wl.Count > 0) + { + var s = wl.Dequeue(); + flagsSeen.Add(s.Flag); + foreach (var term in product.GetTransition(s)) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var succ in leaf) + if (visited.Add(succ)) + wl.Enqueue(succ); + } + + // For GFa ∩ GFb, we expect all three flags to be reachable + Assert.That(flagsSeen, Does.Contain(0), "Flag 0 should be reachable"); + Assert.That(flagsSeen, Does.Contain(1), "Flag 1 should be reachable"); + Assert.That(flagsSeen, Does.Contain(2), "Flag 2 should be reachable"); + } + + #endregion + + #region Comparison with Æ (conjunction via ABW) + + [Test] + public void Product_Vs_Conjunction_BothNonEmpty() + { + // Compare: NbwProduct(NBW(φ), NBW(ψ)) vs Æ(ABW(φ ∧ ψ)) + // Both should produce non-empty NBWs for satisfiable conjunctions + var a = Ltl.Atom(_a); + var b = Ltl.Atom(_b); + var gfa = Ltl.Globally(Ltl.Eventually(a)); + var gfb = Ltl.Globally(Ltl.Eventually(b)); + + // Direct product + var nbw1 = BuildNbw(gfa); + var nbw2 = BuildNbw(gfb); + var product = NbwProduct.Intersect(nbw1, nbw2, + BpComparer, + BpComparer); + + // Conjunction via Æ + var conjunction = _alg.And(gfa, gfb); + var nbwConj = BuildNbw(conjunction); + + var (prodStates, _) = ExploreNbw(product); + var (conjStates, _) = ExploreNbw(nbwConj); + + // Both should have reachable states (non-empty language) + Assert.That(prodStates, Is.GreaterThan(0), "Product should have states"); + Assert.That(conjStates, Is.GreaterThan(0), "Conjunction NBW should have states"); + } + + #endregion + + #region Æ-Based Product (Section 5.3) Tests + + /// + /// Æ-based product: NbwAeProduct.Product(N₁, N₂) = AElim(N₁ ∧ N₂). + /// Sanity check on Ga × Gb: the product NBW has reachable states + /// (the language G(a∧b) is non-empty), and stays within the + /// JACM Corollary 5.x bound of 4·|Q₁|·|Q₂| breakpoint states. + /// + [Test] + public void AeProduct_Ga_Gb_NonEmptyAndWithinBound() + { + var ga = Ltl.Globally(Ltl.Atom(_a)); + var gb = Ltl.Globally(Ltl.Atom(_b)); + + var nbw1 = BuildNbw(ga); + var nbw2 = BuildNbw(gb); + int n1States = ExploreNbw(nbw1).stateCount; + int n2States = ExploreNbw(nbw2).stateCount; + + var prod = NbwAeProduct.Product(nbw1, nbw2, + BpComparer, BpComparer); + + var (prodStates, prodTrans) = ExploreNbw(prod); + TestContext.WriteLine( + $"Ga×Gb (Æ): {n1States}×{n2States} = bound {4 * n1States * n2States}, actual {prodStates} BPs, {prodTrans} trans."); + + Assert.That(prodStates, Is.GreaterThan(0), "Æ-product must have reachable states."); + Assert.That(prodStates, Is.LessThanOrEqualTo(4 * n1States * n2States), + "JACM Corollary 5.x: |Q_{N₁×N₂}| ≤ 4·|Q₁|·|Q₂|."); + } + + /// + /// GFa × GFb: a non-trivial fairness-style product. Æ construction + /// must produce reachable accepting BPs (acceptance fires when the + /// breakpoint resets, capturing both fairness obligations). + /// + [Test] + public void AeProduct_GFa_GFb_HasAcceptingBp() + { + var gfa = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_a))); + var gfb = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_b))); + + var nbw1 = BuildNbw(gfa); + var nbw2 = BuildNbw(gfb); + + var prod = NbwAeProduct.Product(nbw1, nbw2, + BpComparer, BpComparer); + + var visited = new HashSet>, BreakpointState>>>>( + BreakpointState>, BreakpointState>>>.GetEqualityComparer()); + var wl = new Queue>, BreakpointState>>>>(); + foreach (var s in prod.InitialStates) if (visited.Add(s)) wl.Enqueue(s); + int safety = 1000; + bool hasAccepting = false; + while (wl.Count > 0 && safety-- > 0) + { + var s = wl.Dequeue(); + if (prod.IsAccepting(s)) hasAccepting = true; + foreach (var tt in prod.GetTransition(s)) + foreach (var leaf in tt.GetDistinctLeaves()) + foreach (var succ in leaf) + if (visited.Add(succ)) wl.Enqueue(succ); + } + TestContext.WriteLine($"GFa×GFb (Æ): {visited.Count} BPs reachable."); + Assert.That(hasAccepting, Is.True, + "Æ-product of GFa × GFb must have at least one accepting BP."); + } + + /// + /// Cross-check vs classical : both products + /// must agree on emptiness (here non-emptiness). + /// + [Test] + public void AeProduct_VsClassical_AgreeOnNonEmptiness_GFa_GFb() + { + var gfa = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_a))); + var gfb = Ltl.Globally(Ltl.Eventually(Ltl.Atom(_b))); + + var nbw1 = BuildNbw(gfa); + var nbw2 = BuildNbw(gfb); + + var classical = NbwProduct.Intersect(nbw1, nbw2, BpComparer, BpComparer); + var ae = NbwAeProduct.Product(nbw1, nbw2, BpComparer, BpComparer); + + var (cStates, _) = ExploreNbw(classical); + var (aStates, _) = ExploreNbw(ae); + TestContext.WriteLine($"Classical: {cStates} BPs. Æ: {aStates} BPs."); + + Assert.That(cStates, Is.GreaterThan(0)); + Assert.That(aStates, Is.GreaterThan(0)); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/NestedDfsCheckTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/NestedDfsCheckTests.cs new file mode 100644 index 0000000..f015e54 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/NestedDfsCheckTests.cs @@ -0,0 +1,365 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for the Nested DFS (Algorithm B) LTL emptiness check. + /// Mirrors the cases in to assert + /// equivalent semantics between the Tarjan-based Check and the + /// nested-DFS-based CheckNDFS. + /// + [TestFixture] + public class NestedDfsCheckTests + { + #region Test Infrastructure + + private sealed class TestState : State + { + public string Label { get; set; } + public int Value { get; set; } + + public TestState(string label, int value = 0) + { + Label = label; + Value = value; + } + + protected override void CloneInternal(Dictionary clonedMap) + { + var clone = new TestState(Label, Value); + clonedMap[this] = clone; + } + + protected override void LockComponents(HashSet visited) { } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class TestStepFunction : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + + public TestStepFunction(string id) + { + StepFunctionId = id; + StepFunctionIdHash = id.GetHashCode(); + } + + public IList Apply(IState state, + IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + private static StateGraphNode MakeNode(TestState state, params string[] sfIds) + { + state.Freeze(); + return new StateGraphNode + { + State = state, + StepFunctions = sfIds.Select(id => (IStepFunction)new TestStepFunction(id)).ToList(), + Edges = new List() + }; + } + + private static void AddEdge(StateGraphNode from, StateGraphNode to, string sfId = "step") + { + from.Edges.Add(new StateGraphEdge + { + Target = to, + StepFunction = new TestStepFunction(sfId) + }); + } + + private static StateProp Prop(string name, Func eval) + => new StateProp(name, eval); + + private static Ltl Atom(StateProp p) + => Ltl.Atom(new StatePredAtom(p)); + + private static Ltl G(Ltl f) + => Ltl.Globally(f); + + private static Ltl F(Ltl f) + => Ltl.Eventually(f); + + private static Ltl Implies(Ltl a, Ltl b) + => LtlAlgebra.Default.Implies(a, b); + + #endregion + + #region Safety properties + + [Test] + public void NDFS_Ga_Satisfied_AllStatesHaveA() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 2)); + var s2 = MakeNode(new TestState("s2", 3)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s2); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(Atom(p))); + Assert.That(result.Valid, Is.True, "G(val>0) should hold"); + } + + [Test] + public void NDFS_Ga_Violated_OneStateDoesNotHaveA() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(Atom(p))); + Assert.That(result.Valid, Is.False, "G(val>0) should be violated"); + Assert.That(result.Trace, Is.Not.Null); + Assert.That(result.Trace.Any(t => t.IsInCycle), Is.True, + "Counterexample should have a cycle portion"); + } + + #endregion + + #region Liveness properties + + [Test] + public void NDFS_Fa_Satisfied_EventuallyReachesA() + { + var s0 = MakeNode(new TestState("s0", 0)); + var s1 = MakeNode(new TestState("s1", 0)); + var s2 = MakeNode(new TestState("s2", 99)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s2); + + var p = Prop("goal", s => ((TestState)s).Value == 99); + var result = SymbolicLtlCheck.CheckNDFS(s0, F(Atom(p))); + Assert.That(result.Valid, Is.True, "F(goal) should hold"); + } + + [Test] + public void NDFS_Fa_Violated_NeverReachesA() + { + var s0 = MakeNode(new TestState("s0", 0)); + var s1 = MakeNode(new TestState("s1", 1)); + AddEdge(s0, s1); + AddEdge(s1, s0); + + var p = Prop("goal", s => ((TestState)s).Value == 99); + var result = SymbolicLtlCheck.CheckNDFS(s0, F(Atom(p))); + Assert.That(result.Valid, Is.False, "F(goal) should be violated in a goal-free cycle"); + Assert.That(result.Trace.Any(t => t.IsInCycle), Is.True); + } + + [Test] + public void NDFS_GFa_Satisfied_InfinitelyOften() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s0); + + var p = Prop("a", s => ((TestState)s).Value == 1); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(F(Atom(p)))); + Assert.That(result.Valid, Is.True, "GF(a) should hold in cycle visiting a"); + } + + [Test] + public void NDFS_GFa_Violated_EventuallyNeverA() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("a", s => ((TestState)s).Value == 1); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(F(Atom(p)))); + Assert.That(result.Valid, Is.False, + "GF(a) should be violated when system eventually loops at ¬a forever"); + } + + [Test] + public void NDFS_G_AImplFb_Satisfied() + { + // s0(a,¬b) → s1(¬a,¬b) → s2(¬a,b) → s2 + // Property G(a → Fb) + var s0 = MakeNode(new TestState("s0", 10)); + var s1 = MakeNode(new TestState("s1", 0)); + var s2 = MakeNode(new TestState("s2", 1)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s2); + + var a = Prop("a", s => ((TestState)s).Value >= 10); + var b = Prop("b", s => ((TestState)s).Value == 1); + var prop = G(Implies(Atom(a), F(Atom(b)))); + + var result = SymbolicLtlCheck.CheckNDFS(s0, prop); + Assert.That(result.Valid, Is.True, "G(a→Fb) should hold"); + } + + [Test] + public void NDFS_G_AImplFb_Violated() + { + // s0(a) → s1(¬a,¬b) → s1: a occurs but b never does after. + var s0 = MakeNode(new TestState("s0", 10)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var a = Prop("a", s => ((TestState)s).Value >= 10); + var b = Prop("b", s => ((TestState)s).Value == 1); + var prop = G(Implies(Atom(a), F(Atom(b)))); + + var result = SymbolicLtlCheck.CheckNDFS(s0, prop); + Assert.That(result.Valid, Is.False, "G(a→Fb) should be violated"); + } + + #endregion + + #region Counterexample structure + + [Test] + public void NDFS_Violation_TraceHasPrefixAndCycle() + { + // s0 → s1 → s2 → s2 with property G(val>0) and s2 has val=0 + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 1)); + var s2 = MakeNode(new TestState("s2", 0)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s2); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(Atom(p))); + + Assert.That(result.Valid, Is.False); + Assert.That(result.Trace, Is.Not.Null); + Assert.That(result.Trace.Count, Is.GreaterThan(0)); + Assert.That(result.Trace.Any(t => !t.IsInCycle), Is.True, "should have prefix"); + Assert.That(result.Trace.Any(t => t.IsInCycle), Is.True, "should have cycle"); + } + + #endregion + + #region Edge cases + + [Test] + public void NDFS_SingleState_SelfLoop_PropertyHolds() + { + var s0 = MakeNode(new TestState("s0", 1)); + AddEdge(s0, s0); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(Atom(p))); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void NDFS_SingleState_NoEdges_StutterSemantics() + { + var s0 = MakeNode(new TestState("s0", 1)); + // No outgoing edges → NDFS adds stutter self-loop. + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(Atom(p))); + Assert.That(result.Valid, Is.True, "G(val>0) holds under stutter at a state where val>0"); + } + + [Test] + public void NDFS_TrueProperty_AlwaysHolds() + { + var s0 = MakeNode(new TestState("s0", 0)); + AddEdge(s0, s0); + + var result = SymbolicLtlCheck.CheckNDFS(s0, Ltl.True()); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void NDFS_FalseProperty_AlwaysFails() + { + var s0 = MakeNode(new TestState("s0", 0)); + AddEdge(s0, s0); + + var result = SymbolicLtlCheck.CheckNDFS(s0, Ltl.False()); + Assert.That(result.Valid, Is.False); + } + + [Test] + public void NDFS_BoundedDepth_NoViolationWithinBound() + { + // Linear path of depth > maxDepth where violation is beyond the bound. + var nodes = new List(); + for (int i = 0; i < 8; i++) + nodes.Add(MakeNode(new TestState($"s{i}", i < 5 ? 1 : 0))); + for (int i = 0; i < nodes.Count - 1; i++) + AddEdge(nodes[i], nodes[i + 1]); + AddEdge(nodes[^1], nodes[^1]); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + // With maxDepth=3, violation at s5 is unreachable; stutter at s3 (val=1) is OK. + var result = SymbolicLtlCheck.CheckNDFS(nodes[0], G(Atom(p)), maxDepth: 3); + Assert.That(result.Valid, Is.True); + } + + #endregion + + #region Cross-validation with Tarjan implementation + + [Test] + public void NDFS_Agrees_With_Tarjan_OnSeveralCases() + { + // A small battery of structurally distinct graphs/properties to + // confirm CheckNDFS and Check produce the same Valid verdict. + + StateGraphNode BuildLinearSelfLoop(int[] vals) + { + var ns = vals.Select((v, i) => MakeNode(new TestState($"n{i}", v))).ToList(); + for (int i = 0; i < ns.Count - 1; i++) AddEdge(ns[i], ns[i + 1]); + AddEdge(ns[^1], ns[^1]); + return ns[0]; + } + + StateGraphNode BuildTwoCycle(int v0, int v1) + { + var a = MakeNode(new TestState("a", v0)); + var b = MakeNode(new TestState("b", v1)); + AddEdge(a, b); AddEdge(b, a); + return a; + } + + var pVpos = Prop("v>0", s => ((TestState)s).Value > 0); + var pGoal = Prop("g", s => ((TestState)s).Value == 99); + var pA = Prop("a", s => ((TestState)s).Value == 1); + + AssertAgree(BuildLinearSelfLoop(new[] { 1 }), G(Atom(pVpos)), "Ga-sat-single"); + AssertAgree(BuildLinearSelfLoop(new[] { 1, 0 }), G(Atom(pVpos)), "Ga-vio"); + AssertAgree(BuildLinearSelfLoop(new[] { 0, 0, 99 }), F(Atom(pGoal)), "Fa-sat"); + AssertAgree(BuildTwoCycle(0, 1), F(Atom(pGoal)), "Fa-vio"); + AssertAgree(BuildTwoCycle(1, 0), G(F(Atom(pA))), "GFa-sat"); + AssertAgree(BuildLinearSelfLoop(new[] { 1, 0 }), G(F(Atom(pA))), "GFa-vio"); + } + + private static void AssertAgree(StateGraphNode root, Ltl phi, string label) + { + // Re-clone the graph isn't necessary: Check is read-only over the graph. + var tarjan = SymbolicLtlCheck.Check(root, phi); + var ndfs = SymbolicLtlCheck.CheckNDFS(root, phi); + Assert.That(ndfs.Valid, Is.EqualTo(tarjan.Valid), + $"Case '{label}': NDFS and Tarjan must agree on validity"); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/PredCompareTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/PredCompareTests.cs new file mode 100644 index 0000000..dd5b5bc --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/PredCompareTests.cs @@ -0,0 +1,306 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Regression tests asserting that distinct hash-colliding predicates are + /// NOT merged when combined via boolean operations. Under the EBA-fusion + /// design, And/Or of two atomic LTL/RLTL formulas pushes the combination + /// down to the predicate algebra; the responsibility for keeping distinct + /// operands distinct therefore lives in the EBA's And/Or implementations. + /// + [TestFixture] + public class PredCompareTests + { + // A predicate type whose hashcode is constant: every instance collides. + private class CollidingProp : IEquatable, IComparable + { + public string Name { get; } + public CollidingProp(string name) { Name = name; } + public override int GetHashCode() => 42; // always collides + public virtual bool Equals(CollidingProp other) => + other != null && other.GetType() == GetType() && Name == other.Name; + public override bool Equals(object obj) => Equals(obj as CollidingProp); + public virtual int CompareTo(CollidingProp other) => + other == null ? 1 : string.Compare(Name, other.Name, StringComparison.Ordinal); + public override string ToString() => Name; + } + + private sealed class StructNot : CollidingProp + { + public CollidingProp Inner { get; } + public StructNot(CollidingProp inner) : base("¬" + inner) { Inner = inner; } + public override bool Equals(CollidingProp other) => + other is StructNot n && Inner.Equals(n.Inner); + } + + private sealed class StructAnd : CollidingProp + { + public IReadOnlyList Operands { get; } + public StructAnd(IReadOnlyList ops) + : base("∧(" + string.Join(",", ops) + ")") { Operands = ops; } + public override bool Equals(CollidingProp other) => + other is StructAnd a && a.Operands.SequenceEqual(Operands); + } + + private sealed class StructOr : CollidingProp + { + public IReadOnlyList Operands { get; } + public StructOr(IReadOnlyList ops) + : base("∨(" + string.Join(",", ops) + ")") { Operands = ops; } + public override bool Equals(CollidingProp other) => + other is StructOr o && o.Operands.SequenceEqual(Operands); + } + + private sealed class CollidingPropEba : IPredicateAlgebra + { + public CollidingProp Top { get; } = new CollidingProp("⊤"); + public CollidingProp Bottom { get; } = new CollidingProp("⊥"); + + public CollidingProp And(CollidingProp a, CollidingProp b) + { + var ops = new List(); + CollectAnd(a, ops); CollectAnd(b, ops); + var dedup = Dedup(ops); + return dedup.Count == 1 ? dedup[0] : new StructAnd(dedup); + } + + public CollidingProp Or(CollidingProp a, CollidingProp b) + { + var ops = new List(); + CollectOr(a, ops); CollectOr(b, ops); + var dedup = Dedup(ops); + return dedup.Count == 1 ? dedup[0] : new StructOr(dedup); + } + + public CollidingProp Not(CollidingProp a) => + a is StructNot n ? n.Inner : new StructNot(a); + + public bool IsSatisfiable(CollidingProp p) => true; + + private static void CollectAnd(CollidingProp f, List acc) + { + if (f is StructAnd a) acc.AddRange(a.Operands); + else acc.Add(f); + } + + private static void CollectOr(CollidingProp f, List acc) + { + if (f is StructOr o) acc.AddRange(o.Operands); + else acc.Add(f); + } + + private static List Dedup(List ops) + { + var result = new List(); + foreach (var o in ops) + if (!result.Any(r => r.Equals(o))) result.Add(o); + result.Sort((x, y) => x.CompareTo(y)); + return result; + } + } + + // A predicate type that hash-collides AND does not implement IComparable + // (so we exercise the ToString tiebreak fallback). + private class CollidingNoCompare : IEquatable + { + public string Name { get; } + public CollidingNoCompare(string name) { Name = name; } + public override int GetHashCode() => 42; + public virtual bool Equals(CollidingNoCompare other) => + other != null && other.GetType() == GetType() && Name == other.Name; + public override bool Equals(object obj) => Equals(obj as CollidingNoCompare); + public override string ToString() => Name; + } + + private sealed class NCStructOr : CollidingNoCompare + { + public IReadOnlyList Operands { get; } + public NCStructOr(IReadOnlyList ops) + : base("∨(" + string.Join(",", ops) + ")") { Operands = ops; } + public override bool Equals(CollidingNoCompare other) => + other is NCStructOr o && o.Operands.SequenceEqual(Operands); + } + + private sealed class NCStructAnd : CollidingNoCompare + { + public IReadOnlyList Operands { get; } + public NCStructAnd(IReadOnlyList ops) + : base("∧(" + string.Join(",", ops) + ")") { Operands = ops; } + public override bool Equals(CollidingNoCompare other) => + other is NCStructAnd a && a.Operands.SequenceEqual(Operands); + } + + private sealed class NCStructNot : CollidingNoCompare + { + public CollidingNoCompare Inner { get; } + public NCStructNot(CollidingNoCompare inner) : base("¬" + inner) { Inner = inner; } + public override bool Equals(CollidingNoCompare other) => + other is NCStructNot n && Inner.Equals(n.Inner); + } + + private sealed class CollidingNoCompareEba : IPredicateAlgebra + { + public CollidingNoCompare Top { get; } = new CollidingNoCompare("⊤"); + public CollidingNoCompare Bottom { get; } = new CollidingNoCompare("⊥"); + + public CollidingNoCompare And(CollidingNoCompare a, CollidingNoCompare b) + { + var ops = new List(); + CollectAnd(a, ops); CollectAnd(b, ops); + var dedup = Dedup(ops); + return dedup.Count == 1 ? dedup[0] : new NCStructAnd(dedup); + } + + public CollidingNoCompare Or(CollidingNoCompare a, CollidingNoCompare b) + { + var ops = new List(); + CollectOr(a, ops); CollectOr(b, ops); + var dedup = Dedup(ops); + return dedup.Count == 1 ? dedup[0] : new NCStructOr(dedup); + } + + public CollidingNoCompare Not(CollidingNoCompare a) => + a is NCStructNot n ? n.Inner : new NCStructNot(a); + + public bool IsSatisfiable(CollidingNoCompare p) => true; + + private static void CollectAnd(CollidingNoCompare f, List acc) + { + if (f is NCStructAnd a) acc.AddRange(a.Operands); + else acc.Add(f); + } + + private static void CollectOr(CollidingNoCompare f, List acc) + { + if (f is NCStructOr o) acc.AddRange(o.Operands); + else acc.Add(f); + } + + private static List Dedup(List ops) + { + var result = new List(); + foreach (var o in ops) + if (!result.Any(r => r.Equals(o))) result.Add(o); + // No IComparable on CollidingNoCompare — use ToString tiebreak. + result.Sort((x, y) => string.Compare(x.ToString(), y.ToString(), StringComparison.Ordinal)); + return result; + } + } + + [Test] + public void LtlOr_DistinctHashCollidingAtoms_AreNotMerged() + { + var a = new CollidingProp("a"); + var b = new CollidingProp("b"); + Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); + Assert.That(a, Is.Not.EqualTo(b)); + + var alg = new LtlAlgebra(new CollidingPropEba()); + var or = alg.Or(alg.Atom(a), alg.Atom(b)); + + // EBA fuses the two atoms into a single LtlAtom carrying a StructOr predicate. + Assert.That(or, Is.InstanceOf>()); + var pred = ((LtlAtom)or).Predicate; + Assert.That(pred, Is.InstanceOf()); + Assert.That(((StructOr)pred).Operands.Count, Is.EqualTo(2)); + } + + [Test] + public void LtlAnd_DistinctHashCollidingAtoms_AreNotMerged() + { + var a = new CollidingProp("a"); + var b = new CollidingProp("b"); + + var alg = new LtlAlgebra(new CollidingPropEba()); + var and = alg.And(alg.Atom(a), alg.Atom(b)); + + Assert.That(and, Is.InstanceOf>()); + var pred = ((LtlAtom)and).Predicate; + Assert.That(pred, Is.InstanceOf()); + Assert.That(((StructAnd)pred).Operands.Count, Is.EqualTo(2)); + } + + [Test] + public void LtlOr_IdempotenceStillHolds_ForEqualPredicates() + { + var a1 = new CollidingProp("a"); + var a2 = new CollidingProp("a"); // distinct ref, equal value + var alg = new LtlAlgebra(new CollidingPropEba()); + var or = alg.Or(alg.Atom(a1), alg.Atom(a2)); + // Should collapse to a single atom. + Assert.That(or, Is.InstanceOf>()); + } + + [Test] + public void EreUnion_DistinctHashCollidingAtoms_AreNotMerged() + { + var a = new CollidingProp("a"); + var b = new CollidingProp("b"); + + var u = Ere.Union(Ere.Atom(a), Ere.Atom(b)); + Assert.That(u, Is.InstanceOf>()); + Assert.That(((EreUnion)u).Operands.Count, Is.EqualTo(2)); + } + + [Test] + public void EreIntersect_DistinctHashCollidingAtoms_AreNotMerged() + { + var a = new CollidingProp("a"); + var b = new CollidingProp("b"); + + var i = Ere.Intersect(Ere.Atom(a), Ere.Atom(b)); + Assert.That(i, Is.InstanceOf>()); + Assert.That(((EreIntersect)i).Operands.Count, Is.EqualTo(2)); + } + + [Test] + public void RltlOr_DistinctHashCollidingAtoms_AreNotMerged() + { + var a = new CollidingProp("a"); + var b = new CollidingProp("b"); + + var alg = new RltlAlgebra(new CollidingPropEba()); + var or = alg.Or(alg.Atom(a), alg.Atom(b)); + Assert.That(or, Is.InstanceOf>()); + var pred = ((RltlAtom)or).Predicate; + Assert.That(pred, Is.InstanceOf()); + Assert.That(((StructOr)pred).Operands.Count, Is.EqualTo(2)); + } + + [Test] + public void NoComparable_HashCollision_StillNotMerged_ViaToStringFallback() + { + var a = new CollidingNoCompare("a"); + var b = new CollidingNoCompare("b"); + Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); + + var alg = new LtlAlgebra(new CollidingNoCompareEba()); + var or = alg.Or(alg.Atom(a), alg.Atom(b)); + + Assert.That(or, Is.InstanceOf>()); + var pred = ((LtlAtom)or).Predicate; + Assert.That(pred, Is.InstanceOf()); + Assert.That(((NCStructOr)pred).Operands.Count, Is.EqualTo(2)); + } + + [Test] + public void Ordering_IsConsistentWithEquality() + { + // Compare(a,b)==0 iff Equals(a,b) — the contract SortedSet relies on. + var a1 = Ltl.Atom(new CollidingProp("a")); + var a2 = Ltl.Atom(new CollidingProp("a")); + var b = Ltl.Atom(new CollidingProp("b")); + + Assert.That(a1.CompareTo(a2), Is.EqualTo(0)); + Assert.That(a1.Equals(a2), Is.True); + + Assert.That(a1.CompareTo(b), Is.Not.EqualTo(0)); + Assert.That(a1.Equals(b), Is.False); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlBreakpointCanonicalizerTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlBreakpointCanonicalizerTests.cs new file mode 100644 index 0000000..eef9bdf --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlBreakpointCanonicalizerTests.cs @@ -0,0 +1,172 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for the weak-equivalent breakpoint canonicaliser + /// () and its wiring + /// into : on-the- + /// fly merging of language-equivalent breakpoint states (S,O) + /// during the alternation-elimination construction, implementing the JACM + /// Example 5.1 state-reduction step. + /// + [TestFixture] + public class RltlBreakpointCanonicalizerTests + { + private IntEba _eba; + private RltlAlgebra _algebra; + private IntPredicate _p; + + [SetUp] + public void Setup() + { + _eba = new IntEba(2); + _algebra = new RltlAlgebra(_eba); + _p = new IntPredicate("a", 0); + } + + [Test] + public void Canon_StructurallyEqualBPs_AliasReflexively() + { + var merger = new RltlBreakpointCanonicalizer(_eba, _algebra); + var Fp = _algebra.Eventually(_algebra.Atom(_p)); + var cmp = Comparer>.Create((x, y) => x.GetHashCode().CompareTo(y.GetHashCode())); + var s = new StateSet>(new[] { Fp }, cmp); + var o = StateSet>.Empty(cmp); + var bp1 = new BreakpointState>(s, o); + var bp2 = new BreakpointState>(s, o); + + var r1 = merger.Canonicalize(bp1); + var r2 = merger.Canonicalize(bp2); + Assert.That(ReferenceEquals(r1, r2), Is.True); + Assert.That(merger.ClassCount, Is.EqualTo(1)); + } + + [Test] + public void Canon_LanguageEquivalentBPs_AreMerged() + { + // Two BPs whose macrostate conjunctions are language-equivalent + // (G p and G G p) and obligations are both empty. + var merger = new RltlBreakpointCanonicalizer(_eba, _algebra); + var p = _algebra.Atom(_p); + var Gp = _algebra.Globally(p); + var GGp = _algebra.Globally(Gp); + + Assert.That(ReferenceEquals(Gp, GGp), Is.False); + Assert.That(RltlLanguageEquivalence.AreEquivalent(_eba, _algebra, Gp, GGp), Is.True, + "Prerequisite: G p ≡ G G p."); + + var cmp = Comparer>.Create((x, y) => x.GetHashCode().CompareTo(y.GetHashCode())); + var emptyO = StateSet>.Empty(cmp); + var bpGp = new BreakpointState>( + new StateSet>(new[] { Gp }, cmp), emptyO); + var bpGGp = new BreakpointState>( + new StateSet>(new[] { GGp }, cmp), emptyO); + + var r1 = merger.Canonicalize(bpGp); + var r2 = merger.Canonicalize(bpGGp); + Assert.That(ReferenceEquals(r1, r2), Is.True); + Assert.That(merger.ClassCount, Is.EqualTo(1)); + } + + [Test] + public void Canon_LanguageInequivalentBPs_StayDistinct() + { + // F p vs G p are language-inequivalent. + var merger = new RltlBreakpointCanonicalizer(_eba, _algebra); + var p = _algebra.Atom(_p); + var Fp = _algebra.Eventually(p); + var Gp = _algebra.Globally(p); + var cmp = Comparer>.Create((x, y) => x.GetHashCode().CompareTo(y.GetHashCode())); + var emptyO = StateSet>.Empty(cmp); + var bpF = new BreakpointState>( + new StateSet>(new[] { Fp }, cmp), emptyO); + var bpG = new BreakpointState>( + new StateSet>(new[] { Gp }, cmp), emptyO); + + var r1 = merger.Canonicalize(bpF); + var r2 = merger.Canonicalize(bpG); + Assert.That(ReferenceEquals(r1, r2), Is.False); + Assert.That(merger.ClassCount, Is.EqualTo(2)); + } + + [Test] + public void Canon_DifferentObligations_StayDistinct() + { + // Same S, different O — must remain distinct because the + // obligation tracks Büchi acceptance. + var merger = new RltlBreakpointCanonicalizer(_eba, _algebra); + var p = _algebra.Atom(_p); + var Gp = _algebra.Globally(p); + var cmp = Comparer>.Create((x, y) => x.GetHashCode().CompareTo(y.GetHashCode())); + var S = new StateSet>(new[] { Gp }, cmp); + var emptyO = StateSet>.Empty(cmp); + var nonEmptyO = new StateSet>(new[] { Gp }, cmp); + var bp1 = new BreakpointState>(S, emptyO); + var bp2 = new BreakpointState>(S, nonEmptyO); + + var r1 = merger.Canonicalize(bp1); + var r2 = merger.Canonicalize(bp2); + Assert.That(ReferenceEquals(r1, r2), Is.False); + Assert.That(merger.ClassCount, Is.EqualTo(2)); + } + + [Test] + public void IncrementalAE_WithMerger_GFaFNa_Collapses_8_To_3() + { + // Full integration: G(Fa ∧ F¬a) under the IntEba precise oracle. + // Without the merger the RLTL pipeline produces 5 reachable BP + // states; with the merger it reaches the JACM Example 5.1 minimum + // of 3. + var ralg = new RltlAlgebra(_eba); + var rltl = ralg.Globally( + ralg.And( + ralg.Eventually(ralg.Atom(_p)), + ralg.Eventually(ralg.NegAtom(_p)))); + + int Run(bool merge) + { + var registry = new ConditionRegistry(); + var ed = new EreDerivative(_eba, registry); + var ereCanon = new EreCanonicalizer( + new EreEquivalenceChecker(ed)); + var ra = new RltlAlgebra(_eba, ereCanon); + var rltlCanon = new RltlCanonicalizer(_eba, ra); + var deriv = new RltlDerivative(_eba, registry, ereCanon, rltlCanon); + var abw = deriv.ToABW(rltl); + Func>, BreakpointState>> bpCanon = null; + if (merge) + { + var merger = new RltlBreakpointCanonicalizer(_eba, ra); + bpCanon = merger.Canonicalize; + } + var ae = new IncrementalAE>(abw, bpCanon); + var nbw = ae.ToNBW(); + + var seen = new HashSet>>( + BreakpointState>.GetEqualityComparer()); + var queue = new Queue>>(nbw.InitialStates); + foreach (var s in nbw.InitialStates) seen.Add(s); + while (queue.Count > 0) + { + var s = queue.Dequeue(); + foreach (var term in nbw.GetTransition(s)) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) queue.Enqueue(succ); + } + return seen.Count; + } + + int noMerge = Run(merge: false); + int merged = Run(merge: true); + Assert.That(merged, Is.LessThanOrEqualTo(noMerge)); + Assert.That(merged, Is.EqualTo(3), + "JACM Example 5.1: the weak-equivalent merge collapses to 3 reachable NBW states."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlCanonicalizerTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlCanonicalizerTests.cs new file mode 100644 index 0000000..4884608 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlCanonicalizerTests.cs @@ -0,0 +1,110 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for the RLTL-level canonicaliser + /// () and its wiring into + /// for "symbolic NBW state + /// minimisation by precise equivalence" (todo + /// equiv-nbw-state-min). + /// + [TestFixture] + public class RltlCanonicalizerTests + { + private IntEba _eba; + private RltlAlgebra _algebra; + private IntPredicate _p, _q; + + [SetUp] + public void Setup() + { + _eba = new IntEba(3); + _algebra = new RltlAlgebra(_eba); + _p = new IntPredicate("p", 0); + _q = new IntPredicate("q", 1); + } + + [Test] + public void Canon_TwoFormulas_StructurallyEqual_AliasReflexively() + { + var canon = new RltlCanonicalizer(_eba, _algebra); + var f = Rltl.Eventually(Rltl.Atom(_p)); + Assert.That(canon.Canonicalize(f), Is.SameAs(f)); + Assert.That(canon.Canonicalize(f), Is.SameAs(f)); + Assert.That(canon.ClassCount, Is.EqualTo(1)); + } + + [Test] + public void Canon_GG_Equivalent_To_G() + { + // G G p ≡ G p but structurally distinct (RltlAlgebra does not + // simplify nested Globally). + var p = Rltl.Atom(_p); + var Gp = Rltl.Globally(p); + var GGp = Rltl.Globally(Gp); + + Assert.That(ReferenceEquals(Gp, GGp), Is.False, + "Prerequisite: G p and G G p must be structurally distinct."); + Assert.That(RltlLanguageEquivalence.AreEquivalent(_eba, _algebra, Gp, GGp), + Is.True, "Prerequisite: G p and G G p must be language-equivalent."); + + var canon = new RltlCanonicalizer(_eba, _algebra); + var r1 = canon.Canonicalize(Gp); + var r2 = canon.Canonicalize(GGp); + Assert.That(ReferenceEquals(r1, r2), Is.True); + Assert.That(canon.ClassCount, Is.EqualTo(1)); + } + + [Test] + public void Canon_DistinctClasses_StayDistinct() + { + // F p and F q are language-inequivalent. + var Fp = Rltl.Eventually(Rltl.Atom(_p)); + var Fq = Rltl.Eventually(Rltl.Atom(_q)); + + var canon = new RltlCanonicalizer(_eba, _algebra); + var r1 = canon.Canonicalize(Fp); + var r2 = canon.Canonicalize(Fq); + Assert.That(ReferenceEquals(r1, r2), Is.False); + Assert.That(canon.ClassCount, Is.EqualTo(2)); + } + + [Test] + public void Derivative_WithRltlCanon_CollapsesEquivalentAtoms() + { + // Build two top-level RLTL formulas that are language-equivalent + // but use distinct AST shapes for some subformula. After one + // derivative step, the residual atoms must coincide. + var p = Rltl.Atom(_p); + var Gp = Rltl.Globally(p); + var GGp = Rltl.Globally(Gp); + + var registry = new ConditionRegistry( + EqualityComparer.Default); + var canon = new RltlCanonicalizer(_eba, _algebra); + var d = new RltlDerivative(_eba, registry, null, canon); + + // Touch both forms via the derivative engine. After exploring + // both, their canonical residual sets coincide. + var d1 = d.Derivative(Gp); + var d2 = d.Derivative(GGp); + + var atoms1 = new HashSet>(); + foreach (var leaf in d1.GetDistinctLeaves()) + foreach (var st in leaf.GetAllStates()) + atoms1.Add(canon.Canonicalize(st)); + var atoms2 = new HashSet>(); + foreach (var leaf in d2.GetDistinctLeaves()) + foreach (var st in leaf.GetAllStates()) + atoms2.Add(canon.Canonicalize(st)); + + foreach (var s in atoms1) + Assert.That(atoms2, Does.Contain(s)); + foreach (var s in atoms2) + Assert.That(atoms1, Does.Contain(s)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlColourTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlColourTests.cs new file mode 100644 index 0000000..d4159dd --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlColourTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Accordant; + +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant.ModelChecking.Bdd; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class RltlColourTests + { + private static readonly BddStatePropEba Eba = BddStatePropEba.Instance; + private static readonly StateProp Pa = new StateProp("a", _ => true); + private static readonly StateProp Pb = new StateProp("b", _ => true); + + private static RltlAlgebra Alg => new RltlAlgebra(Eba); + + // --- IsRejecting: matches ABW IsAccepting partition (Until = rejecting). --- + + [Test] public void Atom_IsSafety() => + Assert.That(RltlColour.IsRejecting(Alg.Atom(new StatePredAtom(Pa))), Is.False); + + [Test] public void Eventually_IsRejecting() => + Assert.That(RltlColour.IsRejecting( + Alg.Eventually(Alg.Atom(new StatePredAtom(Pa)))), Is.True); + + [Test] public void Until_IsRejecting() => + Assert.That(RltlColour.IsRejecting( + Alg.Until(Alg.Atom(new StatePredAtom(Pa)), + Alg.Atom(new StatePredAtom(Pb)))), Is.True); + + [Test] public void Globally_IsSafety() => + Assert.That(RltlColour.IsRejecting( + Alg.Globally(Alg.Atom(new StatePredAtom(Pa)))), Is.False); + + [Test] public void Release_IsSafety() => + Assert.That(RltlColour.IsRejecting( + Alg.Release(Alg.Atom(new StatePredAtom(Pa)), + Alg.Atom(new StatePredAtom(Pb)))), Is.False); + + [Test] public void Next_IsSafety() => + Assert.That(RltlColour.IsRejecting( + Alg.Next(Alg.Atom(new StatePredAtom(Pa)))), Is.False); + + [Test] public void GFa_IsSafety_ButFa_IsRejecting() + { + var a = Alg.Atom(new StatePredAtom(Pa)); + var Fa = Alg.Eventually(a); + var GFa = Alg.Globally(Fa); + Assert.Multiple(() => + { + Assert.That(RltlColour.IsRejecting(GFa), Is.False, "GFa head=R, ∉ F"); + Assert.That(RltlColour.IsRejecting(Fa), Is.True, "Fa head=U, ∈ F"); + Assert.That(RltlColour.SameColour(GFa, Fa), Is.False, + "Different colours: the macrostate-subsumption guard must block dropping Fa for GFa."); + }); + } + + [Test] public void And_IsSafety_ByHead() + { + var a = Alg.Atom(new StatePredAtom(Pa)); + var b = Alg.Atom(new StatePredAtom(Pb)); + var and = Alg.And(Alg.Eventually(a), Alg.Globally(b)); // head = And + Assert.That(RltlColour.IsRejecting(and), Is.False); + } + + [Test] public void Or_IsSafety_ByHead() + { + var a = Alg.Atom(new StatePredAtom(Pa)); + var b = Alg.Atom(new StatePredAtom(Pb)); + var or = Alg.Or(Alg.Eventually(a), Alg.Globally(b)); // head = Or + Assert.That(RltlColour.IsRejecting(or), Is.False); + } + + [Test] public void SameColour_TwoUntils_True() + { + var Fa = Alg.Eventually(Alg.Atom(new StatePredAtom(Pa))); + var Fb = Alg.Eventually(Alg.Atom(new StatePredAtom(Pb))); + Assert.That(RltlColour.SameColour(Fa, Fb), Is.True); + } + + [Test] public void SameColour_TwoReleases_True() + { + var Ga = Alg.Globally(Alg.Atom(new StatePredAtom(Pa))); + var Gb = Alg.Globally(Alg.Atom(new StatePredAtom(Pb))); + Assert.That(RltlColour.SameColour(Ga, Gb), Is.True); + } + + // Sanity: the classifier matches RltlDerivative.IsAccepting head-by-head + // for the non-WeakClosure heads we care about. + [Test] public void MatchesDerivativeIsAccepting_OnCoreHeads() + { + var registry = new ConditionRegistry( + System.Collections.Generic.EqualityComparer.Default); + var deriv = new RltlDerivative(Eba, registry, null, null); + var a = Alg.Atom(new StatePredAtom(Pa)); + var b = Alg.Atom(new StatePredAtom(Pb)); + Rltl[] cases = + { + a, + Alg.Next(a), + Alg.Eventually(a), + Alg.Globally(a), + Alg.Until(a, b), + Alg.Release(a, b), + Alg.And(a, b), + Alg.Or(a, b), + }; + foreach (var f in cases) + Assert.That(RltlColour.IsRejecting(f), Is.EqualTo(!deriv.IsAccepting(f)), + $"Mismatch for {f}"); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeBisimTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeBisimTests.cs new file mode 100644 index 0000000..8630a89 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeBisimTests.cs @@ -0,0 +1,176 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for G8-a: . + /// Two roles: + /// + /// Positive cases — bisim succeeds on syntactic / shallow + /// structural equivalences. These are also confirmed by the G8-b + /// oracle so the answers agree. + /// Differential soundness — for every formula pair we + /// exercise, if bisim says true the oracle must also say + /// true. Bisim is allowed to say false when the oracle + /// says true (incompleteness). + /// + /// + [TestFixture] + public class RltlDerivativeBisimTests + { + private IntEba _eba; + private RltlAlgebra _alg; + private RltlDerivative _deriv; + private RltlDerivativeBisim _bisim; + private IntPredicate _ap, _bp; + private Rltl _a, _b; + + [SetUp] + public void Setup() + { + _eba = new IntEba(3); + _alg = new RltlAlgebra(_eba); + var registry = new ConditionRegistry( + EqualityComparer.Default); + _deriv = new RltlDerivative(_eba, registry); + _bisim = new RltlDerivativeBisim(_deriv); + + _ap = new IntPredicate("a", 0); + _bp = new IntPredicate("b", 1); + _a = _alg.Atom(_ap); + _b = _alg.Atom(_bp); + } + + private bool OracleSays(Rltl p, Rltl q) + => RltlLanguageEquivalence.AreEquivalent(_eba, _alg, p, q); + + // ---------- Trivial / reflexive cases ---------- + + [Test] + public void Reflexive_True() + => Assert.That(_bisim.TryProveEquivalent(_alg.True, _alg.True), Is.True); + + [Test] + public void Reflexive_Atom() + => Assert.That(_bisim.TryProveEquivalent(_a, _a), Is.True); + + [Test] + public void Reflexive_Until() + { + var phi = _alg.Until(_a, _b); + Assert.That(_bisim.TryProveEquivalent(phi, phi), Is.True); + } + + // ---------- Shallow structural equivalences ---------- + + [Test] + public void DoubleNegation_Atom() + { + var phi = _a; + var doubleNeg = _alg.Not(_alg.Not(phi)); + Assert.That(_bisim.TryProveEquivalent(phi, doubleNeg), Is.True); + Assert.That(OracleSays(phi, doubleNeg), Is.True); + } + + [Test] + public void DoubleNegation_Until() + { + var phi = _alg.Until(_a, _b); + var doubleNeg = _alg.Not(_alg.Not(phi)); + Assert.That(_bisim.TryProveEquivalent(phi, doubleNeg), Is.True); + Assert.That(OracleSays(phi, doubleNeg), Is.True); + } + + [Test] + public void And_Commutativity() + { + var ab = _alg.And(_a, _b); + var ba = _alg.And(_b, _a); + Assert.That(_bisim.TryProveEquivalent(ab, ba), Is.True); + } + + // ---------- Clearly inequivalent ---------- + + [Test] + public void Distinct_Atoms_Inconclusive_Or_False() + { + // Oracle will say inequivalent; bisim must NOT say true (sound). + Assert.That(OracleSays(_a, _b), Is.False); + Assert.That(_bisim.TryProveEquivalent(_a, _b), Is.False); + } + + [Test] + public void True_Vs_False_Soundness() + { + Assert.That(OracleSays(_alg.True, _alg.False), Is.False); + Assert.That(_bisim.TryProveEquivalent(_alg.True, _alg.False), Is.False); + } + + [Test] + public void Until_Vs_Atom_Soundness() + { + var u = _alg.Until(_a, _b); + Assert.That(OracleSays(u, _a), Is.False); + Assert.That(_bisim.TryProveEquivalent(u, _a), Is.False); + } + + // ---------- Differential soundness sweep ---------- + + // For each pair in this catalogue, run BOTH the bisim and the + // oracle. Invariant: bisim(p,q) => oracle(p,q). Bisim may be + // inconclusive (false) when oracle is true. + [Test] + public void DifferentialSoundness_Catalogue() + { + var phi = _alg.Eventually(_a); + var psi = _alg.Globally(_b); + var notA = _alg.Not(_a); + var pairs = new (Rltl p, Rltl q)[] + { + (_a, _a), + (_a, _b), + (_alg.True, _alg.True), + (_alg.True, _alg.False), + (phi, phi), + (phi, psi), + (phi, _alg.Not(_alg.Not(phi))), + (psi, _alg.Not(_alg.Eventually(notA))), // G b ≡ ¬F¬b + (_alg.And(_a, _b), _alg.And(_b, _a)), + (_alg.Or(_a, _b), _alg.Or(_b, _a)), + (_alg.Until(_a, _b), _alg.Until(_a, _b)), + (_alg.Until(_a, _b), _alg.Release(_b, _a)), + (_alg.Eventually(_alg.Eventually(_a)), _alg.Eventually(_a)), + (_alg.Globally(_alg.Globally(_a)), _alg.Globally(_a)), + }; + + foreach (var (p, q) in pairs) + { + bool bisimSays = _bisim.TryProveEquivalent(p, q); + if (bisimSays) + { + bool oracleSays = OracleSays(p, q); + Assert.That(oracleSays, Is.True, + $"Soundness violation: bisim claimed {p} ≡ {q} but oracle disagrees."); + } + } + } + + [Test] + public void DifferentialSoundness_IdempotenceFamilies() + { + // F F a ≡ F a, G G a ≡ G a — well-known. Bisim may or may not + // catch them; if it does, oracle must agree. + var ffa = _alg.Eventually(_alg.Eventually(_a)); + var fa = _alg.Eventually(_a); + if (_bisim.TryProveEquivalent(ffa, fa)) + Assert.That(OracleSays(ffa, fa), Is.True); + + var gga = _alg.Globally(_alg.Globally(_a)); + var ga = _alg.Globally(_a); + if (_bisim.TryProveEquivalent(gga, ga)) + Assert.That(OracleSays(gga, ga), Is.True); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeTableauDedupTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeTableauDedupTests.cs new file mode 100644 index 0000000..f5315fc --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDerivativeTableauDedupTests.cs @@ -0,0 +1,148 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for the runtime side of G8-c / equiv-rltl-tableau-dedup: + /// wiring an into + /// so that residual regexes + /// appearing in derivative leaves (the R' in R';φ etc.) + /// are replaced by canonical representatives of their language- + /// equivalence class. Combined with the existing RLTL hash-consing this + /// collapses tableau / closure nodes that differ only by syntactically- + /// distinct but language-equivalent embedded regexes — a state-space + /// reduction at derivative time. + /// + [TestFixture] + public class RltlDerivativeTableauDedupTests + { + private IntEba _eba; + private ConditionRegistry _registry; + private IntPredicate _ap, _bp; + private Rltl _phi; + private Ere _r1, _r2; + + [SetUp] + public void Setup() + { + _eba = new IntEba(3); + _ap = new IntPredicate("a", 0); + _bp = new IntPredicate("b", 1); + _registry = new ConditionRegistry( + EqualityComparer.Default); + + // Two language-equivalent but structurally distinct regexes: + // r1 = a · a* r2 = a* · a (both recognise a^+). + var a = Ere.Atom(_ap); + _r1 = Ere.Concat(a, Ere.Star(a)); + _r2 = Ere.Concat(Ere.Star(a), a); + _phi = Rltl.Atom(_bp); + } + + private EreCanonicalizer MakeCanon() + { + var ereDeriv = new EreDerivative(_eba, _registry); + var checker = new EreEquivalenceChecker(ereDeriv); + return new EreCanonicalizer(checker); + } + + [Test] + public void Plain_EreCanonicalizer_IsNull() + { + var d = new RltlDerivative(_eba, _registry); + Assert.That(d.EreCanonicalizer, Is.Null); + } + + [Test] + public void Dedup_SeqPrefix_ResidualsCollapse() + { + // f = ((a·a*) ; φ) ∨ ((a*·a) ; φ) + // Without dedup, the closure contains both SeqPrefix variants as + // distinct states. With dedup, they collapse to one. + var s1 = Rltl.SeqPrefix(_r1, _phi); + var s2 = Rltl.SeqPrefix(_r2, _phi); + Assert.That(ReferenceEquals(s1, s2), Is.False, + "Prerequisite: the two SeqPrefix forms are structurally distinct."); + + var canon = MakeCanon(); + var deriv = new RltlDerivative(_eba, _registry, canon); + Assert.That(deriv.EreCanonicalizer, Is.SameAs(canon)); + + // The very first state in the ABW emitted by the derivative + // engine for s1 (resp. s2) is the atom itself; canonicalisation + // happens at residual time inside Derivative(...). To exercise + // that path, take one derivative step and inspect the leaves. + var d1 = deriv.Derivative(s1); + var d2 = deriv.Derivative(s2); + + // After one derivative step the residuals should have been + // routed through the ERE canonicaliser, so structurally + // equivalent residuals are now shared. Concretely, both d1 and + // d2 reduce to terms whose leaves carry SeqPrefix(canon(r1·a*-deriv), φ). + // The strongest observable invariant is that the resulting + // transition terms are reference-equal modulo any commutative + // re-ordering: at minimum, their distinct-leaf sets must agree + // on the RLTL atoms they contain. + var leaves1 = new HashSet>(); + foreach (var leaf in d1.GetDistinctLeaves()) + foreach (var st in leaf.GetAllStates()) + leaves1.Add(st); + var leaves2 = new HashSet>(); + foreach (var leaf in d2.GetDistinctLeaves()) + foreach (var st in leaf.GetAllStates()) + leaves2.Add(st); + + // Every state reachable from s1 must equal (by reference) one + // reachable from s2, and vice versa. + foreach (var s in leaves1) + Assert.That(leaves2, Does.Contain(s), + $"Residual {s} from s1 is not aliased to any residual from s2."); + foreach (var s in leaves2) + Assert.That(leaves1, Does.Contain(s), + $"Residual {s} from s2 is not aliased to any residual from s1."); + } + + [Test] + public void Dedup_FullClosure_StateCount_IsLowerOrEqual() + { + // Build the ABWs for both SeqPrefix forms via ToABW + lazy + // exploration. Under dedup, residuals canonicalise; we expect + // the combined number of distinct RLTL states discovered while + // exploring the two ABWs in lock-step to be no greater than + // without dedup. + var phi = Rltl.SeqPrefix(_r1, _phi); + + int Explore(RltlDerivative d) + { + var abw = d.ToABW(phi); + var seen = new HashSet>(); + var work = new Stack>(); + foreach (var s in abw.InitialState.GetAllStates()) + { + if (seen.Add(s)) work.Push(s); + } + while (work.Count > 0) + { + var s = work.Pop(); + var t = abw.GetTransition(s); + foreach (var leaf in t.GetDistinctLeaves()) + foreach (var q in leaf.GetAllStates()) + if (seen.Add(q)) work.Push(q); + } + return seen.Count; + } + + var dPlain = new RltlDerivative(_eba, _registry); + var dCanon = new RltlDerivative( + _eba, _registry, MakeCanon()); + + int nPlain = Explore(dPlain); + int nCanon = Explore(dCanon); + + Assert.That(nCanon, Is.LessThanOrEqualTo(nPlain), + $"Dedup must not grow the closure (plain={nPlain}, dedup={nCanon})."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDistanceNFusionWiringTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDistanceNFusionWiringTests.cs new file mode 100644 index 0000000..da52fc9 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlDistanceNFusionWiringTests.cs @@ -0,0 +1,300 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Phase 7 / Layer A — RLTL-side end-to-end wiring test for the + /// distribution rewrites. Builds an RLTL formula whose construction + /// triggers both Layer A rules in sequence: + /// + /// + /// The ERE-side Fusion-over-Union-left rule, when fusing + /// a top-level of two + /// distance_n variants with a trailing end-marker atom. + /// The RLTL-side SeqPrefix-over-Union rule, when wrapping + /// the resulting union of fused regexes with ;φ. + /// + /// + /// The fully-distributed shape is then passed through the full + /// pipeline (RLTL → ABW → NBW → NDFS) + /// on a small Kripke structure to verify the semantics survive the + /// rewrites — both for a satisfying and a violating instance. + /// + [TestFixture] + public class RltlDistanceNFusionWiringTests + { + #region Test infrastructure (mirrors SymbolicRltlCheckTests) + + private sealed class TestState : State + { + public string Label { get; } + public int Value { get; } + public TestState(string label, int value) { Label = label; Value = value; } + protected override void CloneInternal(Dictionary m) + => m[this] = new TestState(Label, Value); + protected override void LockComponents(HashSet visited) { } + protected override string StringRepresentationInternal(Dictionary paths, string path, bool forceRecompute) => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class TestStepFunction : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + public TestStepFunction(string id) + { StepFunctionId = id; StepFunctionIdHash = id.GetHashCode(); } + public IList Apply(IState s, IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + private static StateGraphNode MakeNode(TestState st, params string[] sfIds) + { + st.Freeze(); + return new StateGraphNode + { + State = st, + StepFunctions = sfIds.Select(id => (IStepFunction)new TestStepFunction(id)).ToList(), + Edges = new List() + }; + } + + private static void AddEdge(StateGraphNode from, StateGraphNode to) + => from.Edges.Add(new StateGraphEdge + { + Target = to, + StepFunction = new TestStepFunction("step") + }); + + private static StateProp Prop(string name, Func f) => new StateProp(name, f); + + private static Ere EAtom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + private static Ere ESigma() => Ere.Sigma(); + private static Ere EStar(Ere r) => Ere.Star(r); + private static Ere ESigmaStar() => EStar(ESigma()); + private static Ere EConcat(params Ere[] xs) + { + Ere acc = Ere.Epsilon(); + for (int i = xs.Length - 1; i >= 0; i--) + acc = Ere.Concat(xs[i], acc); + return acc; + } + private static Ere EUnion(params Ere[] xs) + { + Ere acc = Ere.Empty(); + foreach (var x in xs) acc = Ere.Union(acc, x); + return acc; + } + private static Rltl RAtom(StateProp p) + => Rltl.Atom(new StatePredAtom(p)); + + // distance_n with a chosen "marker" predicate: Σ* · marker · Σ^n. + private static Ere DistanceN(StateProp marker, int n) + { + var sigma = ESigma(); + var parts = new List> { ESigmaStar(), EAtom(marker) }; + for (int i = 0; i < n; i++) parts.Add(sigma); + return EConcat(parts.ToArray()); + } + + #endregion + + // ----------------------------------------------------------------- + // Layer A structural tests — assert that constructing the formula + // through the smart constructors triggers both distribution rules. + // ----------------------------------------------------------------- + + [Test] + public void FusionOverUnion_AtTheEnd_ProducesTopLevelEreUnion() + { + // (distance_n_a + distance_n_b) : end + // After head-factoring (P2.1): distance_n_a + distance_n_b + // collapses to Σ*·((a·Σ^n) + (b·Σ^n)) — a single Concat, not + // a Union. So Fusion-over-Union-left doesn't fire and the + // top-level shape is a Fusion rather than a Union of Fusions. + // Semantics are preserved (factoring is sound); this test + // just documents the new canonical shape. + var a = Prop("a", s => false); + var b = Prop("b", s => false); + var end = Prop("end", s => false); + const int n = 2; + + var inner = EUnion(DistanceN(a, n), DistanceN(b, n)); + // After head-factoring, inner is Σ*·((a·rest)+(b·rest)). + Assert.That(inner, Is.InstanceOf>(), + "Σ*-headed disjuncts head-factor into a single Concat."); + var concat = (EreConcat)inner; + Assert.That(concat.Right, Is.InstanceOf>(), + "the factored tail retains the union of differing-head subterms."); + + var fused = Ere.Fusion(inner, EAtom(end)); + Assert.That(fused, Is.InstanceOf>(), + "Fusion-over-Union-left does not fire on a factored Concat; " + + "the top-level remains a Fusion."); + } + + [Test] + public void SeqPrefixOverUnion_OnFusedRegex_ProducesRltlOr() + { + // R = (Σ*·a·Σ^n + Σ*·b·Σ^n) : end + // After head-factoring (P2.1) the underlying ERE is no longer + // a top-level Union, so SeqPrefix-over-Union does not split + // and the RLTL shape stays a single RltlSeqPrefix. This is + // the new canonical shape; language semantics are preserved. + var a = Prop("a", s => false); + var b = Prop("b", s => false); + var end = Prop("end", s => false); + var q = Prop("q", s => false); + const int n = 2; + + var r = Ere.Fusion( + EUnion(DistanceN(a, n), DistanceN(b, n)), + EAtom(end)); + var f = Rltl.SeqPrefix(r, RAtom(q)); + + Assert.That(f, Is.InstanceOf>(), + "with head-factoring, the regex remains a single Concat-headed " + + "Fusion, so SeqPrefix yields a single SeqPrefix (no RltlOr)."); + } + + // ----------------------------------------------------------------- + // End-to-end wiring tests through SymbolicRltlCheck — verifies the + // distributed formula model-checks correctly on a small Kripke + // structure for both satisfying and violating runs. + // ----------------------------------------------------------------- + + /// + /// Satisfying instance. The system marks the start state with + /// both a and end, then two Σ-steps, then q + /// for ever. With n=2, distance_2_a : end matches the + /// prefix [s0] (Σ* matches ε, the a-position is s0, and + /// the fused trailing end requires the last letter of that + /// match to also satisfy end — yes). Then ;q + /// requires the suffix at position 1 to satisfy q. + /// + [Test] + public void EndToEnd_FusedDistanceN_Sat_ViaADisjunct() + { + var a = Prop("a", s => ((TestState)s).Value == 1); + var b = Prop("b", s => ((TestState)s).Value == 2); + var end = Prop("end", s => ((TestState)s).Value == 1); // a∧end at s0 + var q = Prop("q", s => ((TestState)s).Value == 9); + + // s0(a,end) → s1(q) → s2(q) self-loop. Position 0 satisfies a∧end + // (matched by (Σ*·a):end with k-1=0), position 1 satisfies q (witness + // for the ;q clause). + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 9)); + var s2 = MakeNode(new TestState("s2", 9)); + AddEdge(s0, s1); AddEdge(s1, s2); AddEdge(s2, s2); + + const int n = 0; // distance_0 — minimal blow-up version + var r = Ere.Fusion( + EUnion(DistanceN(a, n), DistanceN(b, n)), + EAtom(end)); + var phi = Rltl.SeqPrefix(r, RAtom(q)); + + // Sanity: head-factoring collapses to a single SeqPrefix + // (no RltlOr split). Semantics preserved. + Assert.That(phi, Is.InstanceOf>()); + + var result = SymbolicRltlCheck.Check(s0, phi); + Assert.That(result.Valid, Is.True, + "the a-disjunct's prefix (a∧end at s0) is matched and q holds at s1's successor — formula satisfied"); + } + + /// + /// Violating instance. Same formula but the system marks the + /// start with a only (no end) and never produces + /// b, so neither fused disjunct can find a matching + /// prefix. Expect a counterexample trace. + /// + [Test] + public void EndToEnd_FusedDistanceN_Vio_NeitherDisjunctMatches() + { + var a = Prop("a", s => ((TestState)s).Value == 1); + var b = Prop("b", s => ((TestState)s).Value == 2); + // end is now disjoint from both a and b — no position can be both. + var end = Prop("end", s => ((TestState)s).Value == 7); + var q = Prop("q", s => ((TestState)s).Value == 9); + + // s0(a) → s1 → s2(q) → s2(q) — no state ever satisfies end. + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + var s2 = MakeNode(new TestState("s2", 9)); + AddEdge(s0, s1); AddEdge(s1, s2); AddEdge(s2, s2); + + const int n = 0; + var r = Ere.Fusion( + EUnion(DistanceN(a, n), DistanceN(b, n)), + EAtom(end)); + var phi = Rltl.SeqPrefix(r, RAtom(q)); + + Assert.That(phi, Is.InstanceOf>(), + "with head-factoring, no top-level RltlOr is produced; " + + "the model checker handles the factored shape directly."); + + var result = SymbolicRltlCheck.Check(s0, phi); + Assert.That(result.Valid, Is.False, + "no run satisfies either disjunct — formula must be violated"); + Assert.That(result.Trace, Is.Not.Null, "violation should yield a counterexample trace"); + } + + /// + /// Equivalence-with-hand-distributed test. Construct the same + /// formula two ways — once via the (auto-distributing) single + /// call, once by hand-distributing into the explicit Or shape — + /// and verify the underlying RLTL nodes are reference-equal (so + /// SymbolicRltlCheck must give identical results on any input). + /// + /// + /// Equivalence-with-hand-distributed test. With head-factoring (P2.1) + /// the two routes diverge structurally: auto factors the + /// Σ*-headed disjuncts into a single SeqPrefix; hand + /// constructs a top-level RltlOr from already-separated + /// disjuncts that cannot re-merge through the RLTL surface. The + /// languages remain equal (factoring is sound), but the canonical + /// ASTs no longer coincide. We assert the model-checker verdict + /// agrees on a small Kripke run instead of pinning AST shape. + /// + [Test] + public void DistributedShape_EqualsHandDistributedShape() + { + var a = Prop("a", s => ((TestState)s).Value == 1); + var b = Prop("b", s => ((TestState)s).Value == 2); + var end = Prop("end", s => ((TestState)s).Value == 1); + var q = Prop("q", s => ((TestState)s).Value == 9); + const int n = 0; + + var auto = Rltl.SeqPrefix( + Ere.Fusion( + EUnion(DistanceN(a, n), DistanceN(b, n)), + EAtom(end)), + RAtom(q)); + + var hand = Rltl.Or( + Rltl.SeqPrefix( + Ere.Fusion(DistanceN(a, n), EAtom(end)), + RAtom(q)), + Rltl.SeqPrefix( + Ere.Fusion(DistanceN(b, n), EAtom(end)), + RAtom(q))); + + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 9)); + var s2 = MakeNode(new TestState("s2", 9)); + AddEdge(s0, s1); AddEdge(s1, s2); AddEdge(s2, s2); + + var rAuto = SymbolicRltlCheck.Check(s0, auto); + var rHand = SymbolicRltlCheck.Check(s0, hand); + Assert.That(rAuto.Valid, Is.EqualTo(rHand.Valid), + "the head-factored form and the hand-distributed form must agree on " + + "the model-checker verdict (language equivalence)."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlEreCanonicalizerTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlEreCanonicalizerTests.cs new file mode 100644 index 0000000..26ba428 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlEreCanonicalizerTests.cs @@ -0,0 +1,184 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for G8-c: canonicalisation of embedded ERE sub-formulas in + /// . With an + /// attached, two RLTL formulas + /// that differ only by language-equivalent embedded regexes become + /// reference-equal — RLTL structural equality modulo ERE equivalence. + /// + [TestFixture] + public class RltlEreCanonicalizerTests + { + private IntEba _eba; + private RltlAlgebra _plain; + private RltlAlgebra _canon; + private EreCanonicalizer _canonImpl; + private IntPredicate _ap, _bp; + private Rltl _phi; + + // Two language-equivalent regexes that are syntactically distinct: + // r1 = a · a* (a-then-zero-or-more-a) + // r2 = a* · a (zero-or-more-a-then-a) + // L(r1) = L(r2) = a^+ but Concat is non-commutative, so r1 ≢ r2. + private Ere _r1, _r2; + + [SetUp] + public void Setup() + { + _eba = new IntEba(3); + _ap = new IntPredicate("a", 0); + _bp = new IntPredicate("b", 1); + + _plain = new RltlAlgebra(_eba); + + var registry = new ConditionRegistry( + EqualityComparer.Default); + var deriv = new EreDerivative(_eba, registry); + var checker = new EreEquivalenceChecker(deriv); + _canonImpl = new EreCanonicalizer(checker); + _canon = new RltlAlgebra(_eba, _canonImpl); + + var a = Ere.Atom(_ap); + _r1 = Ere.Concat(a, Ere.Star(a)); + _r2 = Ere.Concat(Ere.Star(a), a); + + // Sanity: the two regexes are NOT structurally equal but ARE + // language-equivalent. Both invariants are prerequisites for + // these tests to actually test what they claim. + Assert.That(ReferenceEquals(_r1, _r2), Is.False, + "Test prerequisite: r1 and r2 must be distinct ERE references."); + Assert.That(checker.AreEquivalent(_r1, _r2), Is.True, + "Test prerequisite: r1 and r2 must be language-equivalent."); + + _phi = _canon.Atom(_bp); + } + + // ---------- Back-compat: no canonicaliser ---------- + + [Test] + public void PlainAlgebra_DoesNotMergeEquivalentRegexes() + { + var s1 = _plain.SeqPrefix(_r1, _phi); + var s2 = _plain.SeqPrefix(_r2, _phi); + Assert.That(ReferenceEquals(s1, s2), Is.False); + } + + [Test] + public void PlainAlgebra_EreCanonicalizerIsNull() + => Assert.That(_plain.EreCanonicalizer, Is.Null); + + // ---------- Canonicaliser wired in: all embedded-ERE constructors ---------- + + [Test] + public void Canon_SeqPrefix_EquivalentRegexes_AreReferenceEqual() + { + var s1 = _canon.SeqPrefix(_r1, _phi); + var s2 = _canon.SeqPrefix(_r2, _phi); + Assert.That(ReferenceEquals(s1, s2), Is.True); + } + + [Test] + public void Canon_OvlPrefix_EquivalentRegexes_AreReferenceEqual() + { + var s1 = _canon.OvlPrefix(_r1, _phi); + var s2 = _canon.OvlPrefix(_r2, _phi); + Assert.That(ReferenceEquals(s1, s2), Is.True); + } + + [Test] + public void Canon_Trigger_EquivalentRegexes_AreReferenceEqual() + { + var s1 = _canon.Trigger(_r1, _phi); + var s2 = _canon.Trigger(_r2, _phi); + Assert.That(ReferenceEquals(s1, s2), Is.True); + } + + [Test] + public void Canon_Match_EquivalentRegexes_AreReferenceEqual() + { + var s1 = _canon.Match(_r1, _phi); + var s2 = _canon.Match(_r2, _phi); + Assert.That(ReferenceEquals(s1, s2), Is.True); + } + + [Test] + public void Canon_WeakClosure_EquivalentRegexes_AreReferenceEqual() + { + var s1 = _canon.WeakClosure(_r1); + var s2 = _canon.WeakClosure(_r2); + Assert.That(ReferenceEquals(s1, s2), Is.True); + } + + [Test] + public void Canon_NegWeakClosure_EquivalentRegexes_AreReferenceEqual() + { + var s1 = _canon.NegWeakClosure(_r1); + var s2 = _canon.NegWeakClosure(_r2); + Assert.That(ReferenceEquals(s1, s2), Is.True); + } + + [Test] + public void Canon_OmegaClosure_EquivalentRegexes_AreReferenceEqual() + { + var s1 = _canon.OmegaClosure(_r1); + var s2 = _canon.OmegaClosure(_r2); + Assert.That(ReferenceEquals(s1, s2), Is.True); + } + + // ---------- Negation propagates canonicalisation ---------- + + [Test] + public void Canon_NotSeqPrefix_PreservesCanonicalRegex() + { + // ¬(r1 ; φ) = r1 ⊳ ¬φ, and ¬(r2 ; φ) = r2 ⊳ ¬φ. Under + // canonicalisation both r1 and r2 map to the same rep, so the + // two Triggers are reference-equal. + var n1 = _canon.Not(_canon.SeqPrefix(_r1, _phi)); + var n2 = _canon.Not(_canon.SeqPrefix(_r2, _phi)); + Assert.That(ReferenceEquals(n1, n2), Is.True); + Assert.That(n1, Is.InstanceOf>()); + } + + // ---------- Distinct (non-equivalent) regexes stay distinct ---------- + + [Test] + public void Canon_DistinctRegexes_StayDistinct() + { + var a = Ere.Atom(_ap); + var b = Ere.Atom(_bp); + var s1 = _canon.SeqPrefix(a, _phi); + var s2 = _canon.SeqPrefix(b, _phi); + Assert.That(ReferenceEquals(s1, s2), Is.False); + } + + // ---------- Canonicaliser bookkeeping ---------- + + [Test] + public void Canon_ClassCount_TracksDistinctClasses() + { + var a = Ere.Atom(_ap); + var b = Ere.Atom(_bp); + + _ = _canon.SeqPrefix(_r1, _phi); // class 1 + _ = _canon.SeqPrefix(_r2, _phi); // joins class 1 + _ = _canon.SeqPrefix(a, _phi); // class 2 + _ = _canon.SeqPrefix(b, _phi); // class 3 + Assert.That(_canonImpl.ClassCount, Is.EqualTo(3)); + } + + [Test] + public void Canon_RepeatedCanonicalize_IsIdempotent() + { + var c1 = _canonImpl.Canonicalize(_r1); + var c2 = _canonImpl.Canonicalize(_r2); + var c1again = _canonImpl.Canonicalize(_r1); + Assert.That(ReferenceEquals(c1, c2), Is.True); + Assert.That(ReferenceEquals(c1, c1again), Is.True); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlJsonTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlJsonTests.cs new file mode 100644 index 0000000..3a1f318 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlJsonTests.cs @@ -0,0 +1,75 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class RltlJsonTests + { + private static readonly RltlAlgebra Alg = + new RltlAlgebra(StringFreeAlgebra.Instance); + + private static Rltl A => Alg.Atom("a"); + private static Rltl B => Alg.Atom("b"); + private static Ere Ra => Ere.Atom("a"); + private static Ere Rb => Ere.Atom("b"); + + private static void RoundTrip(Rltl phi) + { + var json = RltlJson.Serialize(phi); + var back = RltlJson.Deserialize(json); + Assert.That(back.Equals(phi), Is.True, + $"Round-trip failed. JSON: {json}\nOriginal: {phi}\nGot: {back}"); + } + + [Test] public void True_() => RoundTrip(Alg.True); + [Test] public void False_() => RoundTrip(Alg.False); + [Test] public void Atom_A() => RoundTrip(A); + + [Test] + public void NegAtom_A() + { + // NegAtom("a") under StringFreeAlgebra becomes Atom("¬a"). + var na = Alg.NegAtom("a"); + RoundTrip(na); + } + + [Test] public void Next_A() => RoundTrip(Alg.Next(A)); + [Test] public void Until_AB() => RoundTrip(Alg.Until(A, B)); + [Test] public void Release_AB() => RoundTrip(Alg.Release(A, B)); + [Test] public void Eventually_A() => RoundTrip(Alg.Eventually(A)); + [Test] public void Globally_A() => RoundTrip(Alg.Globally(A)); + [Test] public void And_AB() => RoundTrip(Alg.And(A, B)); + [Test] public void Or_AB() => RoundTrip(Alg.Or(A, B)); + + // RLTL-specific: embedded ERE. + [Test] public void SeqPrefix_aB() => RoundTrip(Alg.SeqPrefix(Ra, B)); + [Test] public void OvlPrefix_aB() => RoundTrip(Alg.OvlPrefix(Ra, B)); + [Test] public void Trigger_aB() => RoundTrip(Alg.Trigger(Ra, B)); + [Test] public void Match_aB() => RoundTrip(Alg.Match(Ra, B)); + [Test] public void WeakClosure_a() => RoundTrip(Alg.WeakClosure(Ra)); + [Test] public void NegWeakClosure_a() => RoundTrip(Alg.NegWeakClosure(Ra)); + [Test] public void OmegaClosure_a() => RoundTrip(Alg.OmegaClosure(Ra)); + + [Test] + public void Nested_AcrossEreAndRltl() + { + // G ( (a · b*) ; (a U b) ) + var inner = Alg.SeqPrefix( + Ere.Concat(Ra, Ere.Star(Rb)), + Alg.Until(A, B)); + RoundTrip(Alg.Globally(inner)); + } + + [Test] + public void Implies_Sugar_DeserializeOnly() + { + var json = "{\"op\":\"Implies\"," + + "\"left\":{\"op\":\"Atom\",\"pred\":\"a\"}," + + "\"right\":{\"op\":\"Atom\",\"pred\":\"b\"}}"; + var phi = RltlJson.Deserialize(json); + var expected = Alg.Or(Alg.Not(A), B); + Assert.That(phi.Equals(expected), Is.True); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlLanguageEquivalenceTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlLanguageEquivalenceTests.cs new file mode 100644 index 0000000..53f7d62 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlLanguageEquivalenceTests.cs @@ -0,0 +1,214 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for (G8-b): sound and + /// complete language equivalence for RLTL formulas via two-way emptiness. + /// Uses for precise IsSatisfiable so that + /// equivalences relying on predicate-level contradictions can be decided. + /// + [TestFixture] + public class RltlLanguageEquivalenceTests + { + private IntEba _eba; + private RltlAlgebra _alg; + private IntPredicate _a, _b; + private Rltl _A, _B, _TT, _FF; + + [SetUp] + public void Setup() + { + _eba = new IntEba(3); + _alg = new RltlAlgebra(_eba); + _a = new IntPredicate("a", 0, 1); + _b = new IntPredicate("b", 1, 2); + _A = _alg.Atom(_a); + _B = _alg.Atom(_b); + _TT = _alg.True; + _FF = _alg.False; + } + + private bool Eq(Rltl p, Rltl q) + => RltlLanguageEquivalence.AreEquivalent(_eba, _alg, p, q); + private bool Sub(Rltl p, Rltl q) + => RltlLanguageEquivalence.Includes(_eba, _alg, p, q); + private bool Empty(Rltl p) + => RltlLanguageEquivalence.IsLanguageEmpty(_eba, p); + + // ---------- IsLanguageEmpty ---------- + + [Test] + public void IsLanguageEmpty_False_IsEmpty() + => Assert.That(Empty(_FF), Is.True); + + [Test] + public void IsLanguageEmpty_True_IsNotEmpty() + => Assert.That(Empty(_TT), Is.False); + + [Test] + public void IsLanguageEmpty_GloballyFalse_IsEmpty() + => Assert.That(Empty(_alg.Globally(_FF)), Is.True); + + [Test] + public void IsLanguageEmpty_EventuallyTrue_IsNotEmpty() + => Assert.That(Empty(_alg.Eventually(_TT)), Is.False); + + [Test] + public void IsLanguageEmpty_AAndNotA_IsEmpty() + { + // a ∧ ¬a is unsatisfiable as a predicate; the formula has no models + // even at position 0. + var notA = _alg.Atom(_eba.Not(_a)); + Assert.That(Empty(_alg.And(_A, notA)), Is.True); + } + + [Test] + public void IsLanguageEmpty_FaAndGNotA_IsEmpty() + { + // F a ∧ G ¬a — a must occur somewhere AND a must never occur. + // Detected as empty via precise IsSatisfiable on the derivative + // path conditions. + var notA = _alg.Atom(_eba.Not(_a)); + var phi = _alg.And(_alg.Eventually(_A), _alg.Globally(notA)); + Assert.That(Empty(phi), Is.True); + } + + // ---------- AreEquivalent — trivial reflexivity ---------- + + [Test] + public void AreEquivalent_Reflexive() + { + Assert.That(Eq(_TT, _TT), Is.True); + Assert.That(Eq(_FF, _FF), Is.True); + Assert.That(Eq(_A, _A), Is.True); + Assert.That(Eq(_alg.Eventually(_A), _alg.Eventually(_A)), Is.True); + } + + [Test] + public void AreEquivalent_True_NotEquivTo_False() + => Assert.That(Eq(_TT, _FF), Is.False); + + [Test] + public void AreEquivalent_DistinctAtoms_NotEquiv() + => Assert.That(Eq(_A, _B), Is.False); + + // ---------- AreEquivalent — semantic LTL equivalences ---------- + + [Test] + public void AreEquivalent_DoubleNegation() + => Assert.That(Eq(_A, _alg.Not(_alg.Not(_A))), Is.True); + + [Test] + public void AreEquivalent_F_Idempotent() + { + // F a ≡ F F a + var fa = _alg.Eventually(_A); + var ffa = _alg.Eventually(fa); + Assert.That(Eq(fa, ffa), Is.True); + } + + [Test] + public void AreEquivalent_G_Idempotent() + { + // G a ≡ G G a + var ga = _alg.Globally(_A); + var gga = _alg.Globally(ga); + Assert.That(Eq(ga, gga), Is.True); + } + + [Test] + public void AreEquivalent_FDistributesOverOr() + { + // F(a ∨ b) ≡ F a ∨ F b + var lhs = _alg.Eventually(_alg.Or(_A, _B)); + var rhs = _alg.Or(_alg.Eventually(_A), _alg.Eventually(_B)); + Assert.That(Eq(lhs, rhs), Is.True); + } + + [Test] + public void AreEquivalent_GDistributesOverAnd() + { + // G(a ∧ b) ≡ G a ∧ G b + var lhs = _alg.Globally(_alg.And(_A, _B)); + var rhs = _alg.And(_alg.Globally(_A), _alg.Globally(_B)); + Assert.That(Eq(lhs, rhs), Is.True); + } + + [Test] + public void AreEquivalent_DualityFG() + { + // ¬F a ≡ G ¬a + var notFa = _alg.Not(_alg.Eventually(_A)); + var gNotA = _alg.Globally(_alg.Atom(_eba.Not(_a))); + Assert.That(Eq(notFa, gNotA), Is.True); + } + + [Test] + public void AreEquivalent_DualityUR() + { + // ¬(a U b) ≡ (¬a) R (¬b) + var notU = _alg.Not(_alg.Until(_A, _B)); + var rDual = _alg.Release( + _alg.Atom(_eba.Not(_a)), + _alg.Atom(_eba.Not(_b))); + Assert.That(Eq(notU, rDual), Is.True); + } + + // ---------- AreEquivalent — known inequivalences ---------- + + [Test] + public void AreEquivalent_F_NotEquivTo_G() + => Assert.That(Eq(_alg.Eventually(_A), _alg.Globally(_A)), Is.False); + + [Test] + public void AreEquivalent_GF_NotEquivTo_FG() + { + // G F a (infinitely often) ≢ F G a (eventually always). + var gfa = _alg.Globally(_alg.Eventually(_A)); + var fga = _alg.Eventually(_alg.Globally(_A)); + Assert.That(Eq(gfa, fga), Is.False); + } + + // ---------- Inclusion ---------- + + [Test] + public void Includes_AtomImpliesEventually() + => Assert.That(Sub(_A, _alg.Eventually(_A)), Is.True); + + [Test] + public void Includes_GloballyImpliesAtom() + => Assert.That(Sub(_alg.Globally(_A), _A), Is.True); + + [Test] + public void Includes_FaImpliesFaOrb() + => Assert.That(Sub(_alg.Eventually(_A), + _alg.Eventually(_alg.Or(_A, _B))), Is.True); + + [Test] + public void Includes_EventuallyDoesNotImplyGlobally() + => Assert.That(Sub(_alg.Eventually(_A), _alg.Globally(_A)), Is.False); + + // ---------- RLTL-specific (regex-prefix operators) ---------- + + [Test] + public void AreEquivalent_NegateSeqPrefix_YieldsTrigger() + { + // R;φ and the corresponding Trigger formula via Negate must satisfy: + // ¬¬(R;φ) ≡ R;φ. + var r = Ere.Atom(_a); + var seq = _alg.SeqPrefix(r, _B); + Assert.That(Eq(_alg.Not(_alg.Not(seq)), seq), Is.True); + } + + [Test] + public void AreEquivalent_OmegaClosureFalse_IsEmpty() + { + // (⊥)^ω has empty ω-language. + var phi = _alg.OmegaClosure(Ere.Empty()); + Assert.That(Empty(phi), Is.True); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlPrefixUnionDistributionTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlPrefixUnionDistributionTests.cs new file mode 100644 index 0000000..1ec88a9 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlPrefixUnionDistributionTests.cs @@ -0,0 +1,122 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Phase 7 / Layer A — distribution of Union in the regex argument + /// through the four RLTL Regex × φ prefix operators in + /// : + /// (R₁+R₂) ; φ ≡ (R₁;φ) ∨ (R₂;φ) (existential) + /// (R₁+R₂) : φ ≡ (R₁:φ) ∨ (R₂:φ) (existential) + /// (R₁+R₂) ⊳ φ ≡ (R₁⊳φ) ∧ (R₂⊳φ) (universal) + /// (R₁+R₂) ⊳⊳ φ ≡ (R₁⊳⊳φ) ∧ (R₂⊳⊳φ) (universal) + /// + [TestFixture] + public class RltlPrefixUnionDistributionTests + { + public sealed class StrPred : IEquatable + { + public string Name { get; } + public StrPred(string n) { Name = n; } + public bool Equals(StrPred other) => other != null && other.Name == Name; + public override bool Equals(object o) => Equals(o as StrPred); + public override int GetHashCode() => Name.GetHashCode(); + public override string ToString() => Name; + } + + private static Ere EAtom(string n) => Ere.Atom(new StrPred(n)); + private static Rltl RAtom(string n) => Rltl.Atom(new StrPred(n)); + private static Ere EU(params Ere[] ops) + { + Ere acc = Ere.Empty(); + foreach (var o in ops) acc = Ere.Union(acc, o); + return acc; + } + + [Test] + public void SeqPrefix_DistributesUnionInRegex_AsOr() + { + // (a + b) ; q ≡ (a;q) ∨ (b;q) + var phi = RAtom("q"); + var lhs = Rltl.SeqPrefix(EU(EAtom("a"), EAtom("b")), phi); + var rhs = Rltl.Or( + Rltl.SeqPrefix(EAtom("a"), phi), + Rltl.SeqPrefix(EAtom("b"), phi)); + Assert.That(lhs, Is.EqualTo(rhs)); + Assert.That(lhs, Is.InstanceOf>()); + } + + [Test] + public void OvlPrefix_DistributesUnionInRegex_AsOr() + { + var phi = RAtom("q"); + var lhs = Rltl.OvlPrefix(EU(EAtom("a"), EAtom("b")), phi); + var rhs = Rltl.Or( + Rltl.OvlPrefix(EAtom("a"), phi), + Rltl.OvlPrefix(EAtom("b"), phi)); + Assert.That(lhs, Is.EqualTo(rhs)); + Assert.That(lhs, Is.InstanceOf>()); + } + + [Test] + public void Trigger_DistributesUnionInRegex_AsAnd() + { + // (a + b) ⊳ q ≡ (a⊳q) ∧ (b⊳q) + var phi = RAtom("q"); + var lhs = Rltl.Trigger(EU(EAtom("a"), EAtom("b")), phi); + var rhs = Rltl.And( + Rltl.Trigger(EAtom("a"), phi), + Rltl.Trigger(EAtom("b"), phi)); + Assert.That(lhs, Is.EqualTo(rhs)); + Assert.That(lhs, Is.InstanceOf>()); + } + + [Test] + public void Match_DistributesUnionInRegex_AsAnd() + { + var phi = RAtom("q"); + var lhs = Rltl.Match(EU(EAtom("a"), EAtom("b")), phi); + var rhs = Rltl.And( + Rltl.Match(EAtom("a"), phi), + Rltl.Match(EAtom("b"), phi)); + Assert.That(lhs, Is.EqualTo(rhs)); + Assert.That(lhs, Is.InstanceOf>()); + } + + [Test] + public void Or_AppliesUnitAndAbsorptionLaws() + { + var p = RAtom("p"); + Assert.That(Rltl.Or(Rltl.False(), p), Is.EqualTo(p)); + Assert.That(Rltl.Or(p, Rltl.False()), Is.EqualTo(p)); + Assert.That(Rltl.Or(p, Rltl.True()), Is.InstanceOf>()); + Assert.That(Rltl.Or(p, p), Is.EqualTo(p), "duplicate operand collapses"); + } + + [Test] + public void And_AppliesUnitAndAbsorptionLaws() + { + var p = RAtom("p"); + Assert.That(Rltl.And(Rltl.True(), p), Is.EqualTo(p)); + Assert.That(Rltl.And(p, Rltl.True()), Is.EqualTo(p)); + Assert.That(Rltl.And(p, Rltl.False()), Is.InstanceOf>()); + Assert.That(Rltl.And(p, p), Is.EqualTo(p), "duplicate operand collapses"); + } + + [Test] + public void SeqPrefix_DistributionFlattensNestedUnion() + { + // (a + (b + c)) ; q → Σ over flattened operands + var phi = RAtom("q"); + var nested = Ere.Union(EAtom("a"), EU(EAtom("b"), EAtom("c"))); + var lhs = Rltl.SeqPrefix(nested, phi); + Assert.That(lhs, Is.InstanceOf>()); + var or = (RltlOr)lhs; + Assert.That(or.Operands.Count, Is.EqualTo(3), + "nested union flattens through Ere.Union and the Rltl.Or collector"); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlSExprDslTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlSExprDslTests.cs new file mode 100644 index 0000000..9693d56 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlSExprDslTests.cs @@ -0,0 +1,368 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Round-trip and shape tests for the S-expression surface DSL + /// (). Predicates are opaque strings via + /// ; structural equality on the + /// resulting AST is the round-trip oracle (terms are hash-consed, so + /// reference equality is equivalent to structural equality). + /// + [TestFixture] + public class RltlSExprDslTests + { + private static readonly IPredicateCodec Codec = StringPredicateCodec.Instance; + + // ─── S-expression lexer / printer ──────────────────────────────── + + [Test] + public void SExpr_Parse_Atom() + { + var s = SExpr.Parse("hello"); + Assert.That(s, Is.InstanceOf()); + Assert.That(((SAtom)s).Value, Is.EqualTo("hello")); + } + + [Test] + public void SExpr_Parse_QuotedString() + { + var s = SExpr.Parse("\"hello world\\n\""); + Assert.That(((SAtom)s).Value, Is.EqualTo("hello world\n")); + Assert.That(((SAtom)s).Quoted, Is.True); + } + + [Test] + public void SExpr_Parse_List() + { + var s = (SList)SExpr.Parse("(a (b c) d)"); + Assert.That(s.Items.Count, Is.EqualTo(3)); + Assert.That(((SAtom)s.Items[0]).Value, Is.EqualTo("a")); + Assert.That(s.Items[1], Is.InstanceOf()); + Assert.That(((SAtom)s.Items[2]).Value, Is.EqualTo("d")); + } + + [Test] + public void SExpr_Parse_IgnoresComments() + { + var s = SExpr.Parse(@" + ; leading comment + (and ; trailing + a b)"); + Assert.That(s.ToString(), Is.EqualTo("(and a b)")); + } + + [Test] + public void SExpr_Print_AutoQuotesWhenNeeded() + { + var s = new SAtom("hello world"); + Assert.That(s.ToString(), Is.EqualTo("\"hello world\"")); + } + + [Test] + public void SExpr_Print_RoundTripEscapes() + { + var orig = "tab\there\nnewline\\backslash\"quote"; + var s = new SAtom(orig); + var parsed = (SAtom)SExpr.Parse(s.ToString()); + Assert.That(parsed.Value, Is.EqualTo(orig)); + } + + [Test] + public void SExpr_Parse_RejectsUnclosedList() + { + Assert.Throws(() => SExpr.Parse("(a b")); + } + + // ─── ERE round-trip ────────────────────────────────────────────── + + private static void EreRoundTrip(Ere ere, string expectedSexpr) + { + var printed = RltlSExpr.PrintEre(ere, Codec); + Assert.That(printed, Is.EqualTo(expectedSexpr), "Print form"); + var parsed = RltlSExpr.ParseEre(printed, Codec); + Assert.That(parsed, Is.SameAs(ere), + $"Round-trip failed: parsed = {RltlSExpr.PrintEre(parsed, Codec)}"); + } + + [Test] + public void Ere_Empty_RoundTrips() + => EreRoundTrip(Ere.Empty(), "empty"); + + [Test] + public void Ere_Epsilon_RoundTrips() + => EreRoundTrip(Ere.Epsilon(), "eps"); + + [Test] + public void Ere_Atom_RoundTrips() + => EreRoundTrip(Ere.Atom("p"), "(atom p)"); + + [Test] + public void Ere_Atom_QuotedPredicate_RoundTrips() + { + // Predicate name has spaces must be emitted as a quoted string. + var ere = Ere.Atom("foo bar"); + var printed = RltlSExpr.PrintEre(ere, Codec); + Assert.That(printed, Is.EqualTo("(atom \"foo bar\")")); + Assert.That(RltlSExpr.ParseEre(printed, Codec), Is.SameAs(ere)); + } + + [Test] + public void Ere_Star_RoundTrips() + => EreRoundTrip(Ere.Star(Ere.Atom("a")), "(star (atom a))"); + + [Test] + public void Ere_Complement_RoundTrips() + => EreRoundTrip(Ere.Complement(Ere.Atom("a")), "(comp (atom a))"); + + [Test] + public void Ere_Concat_RoundTrips_LeftAssociated() + { + var ere = Ere.Concat( + Ere.Concat(Ere.Atom("a"), Ere.Atom("b")), + Ere.Atom("c")); + EreRoundTrip(ere, "(concat (atom a) (atom b) (atom c))"); + } + + [Test] + public void Ere_Union_RoundTrips() + { + var ere = Ere.Union(Ere.Atom("a"), Ere.Atom("b")); + var s = RltlSExpr.PrintEre(ere, Codec); + Assert.That(RltlSExpr.ParseEre(s, Codec), Is.SameAs(ere)); + } + + [Test] + public void Ere_Intersect_RoundTrips() + { + var ere = Ere.Intersect( + Ere.Star(Ere.Atom("a")), + Ere.Star(Ere.Atom("b"))); + var s = RltlSExpr.PrintEre(ere, Codec); + Assert.That(RltlSExpr.ParseEre(s, Codec), Is.SameAs(ere)); + } + + [Test] + public void Ere_Fusion_RoundTrips() + { + var ere = Ere.Fusion(Ere.Atom("a"), Ere.Atom("b")); + var s = RltlSExpr.PrintEre(ere, Codec); + Assert.That(s, Is.EqualTo("(fusion (atom a) (atom b))")); + Assert.That(RltlSExpr.ParseEre(s, Codec), Is.SameAs(ere)); + } + + [Test] + public void Ere_Xor_RoundTrips() + { + var ere = Ere.Xor(Ere.Atom("a"), Ere.Atom("b")); + var s = RltlSExpr.PrintEre(ere, Codec); + Assert.That(RltlSExpr.ParseEre(s, Codec), Is.SameAs(ere)); + } + + [Test] + public void Ere_Xnor_RoundTrips() + { + var ere = Ere.Xnor(Ere.Atom("a"), Ere.Atom("b")); + var s = RltlSExpr.PrintEre(ere, Codec); + Assert.That(RltlSExpr.ParseEre(s, Codec), Is.SameAs(ere)); + } + + [Test] + public void Ere_NestedComplex_RoundTrips() + { + // ~( (a · b)* + ε ) + var ere = Ere.Complement( + Ere.Union( + Ere.Star(Ere.Concat(Ere.Atom("a"), Ere.Atom("b"))), + Ere.Epsilon())); + var s = RltlSExpr.PrintEre(ere, Codec); + Assert.That(RltlSExpr.ParseEre(s, Codec), Is.SameAs(ere)); + } + + // ─── RLTL round-trip ───────────────────────────────────────────── + + private static void RltlRoundTrip(Rltl phi, string expectedSexpr) + { + var printed = RltlSExpr.PrintRltl(phi, Codec); + Assert.That(printed, Is.EqualTo(expectedSexpr), "Print form"); + var parsed = RltlSExpr.ParseRltl(printed, Codec); + Assert.That(parsed, Is.SameAs(phi), + $"Round-trip failed: parsed = {RltlSExpr.PrintRltl(parsed, Codec)}"); + } + + [Test] + public void Rltl_True_RoundTrips() => RltlRoundTrip(Rltl.True(), "true"); + + [Test] + public void Rltl_False_RoundTrips() => RltlRoundTrip(Rltl.False(), "false"); + + [Test] + public void Rltl_Atom_RoundTrips() => RltlRoundTrip(Rltl.Atom("p"), "(atom p)"); + + [Test] + public void Rltl_Next_RoundTrips() + => RltlRoundTrip(Rltl.Next(Rltl.Atom("p")), "(X (atom p))"); + + [Test] + public void Rltl_Until_RoundTrips() + => RltlRoundTrip( + Rltl.Until(Rltl.Atom("p"), Rltl.Atom("q")), + "(U (atom p) (atom q))"); + + [Test] + public void Rltl_Release_RoundTrips() + => RltlRoundTrip( + Rltl.Release(Rltl.Atom("p"), Rltl.Atom("q")), + "(R (atom p) (atom q))"); + + [Test] + public void Rltl_Eventually_PrintsAsF() + => RltlRoundTrip(Rltl.Eventually(Rltl.Atom("p")), "(F (atom p))"); + + [Test] + public void Rltl_Globally_PrintsAsG() + => RltlRoundTrip(Rltl.Globally(Rltl.Atom("p")), "(G (atom p))"); + + [Test] + public void Rltl_Parse_AcceptsUntilAlias() + { + var expected = Rltl.Until(Rltl.Atom("p"), Rltl.Atom("q")); + Assert.That(RltlSExpr.ParseRltl("(until (atom p) (atom q))", Codec), Is.SameAs(expected)); + } + + [Test] + public void Rltl_Parse_AcceptsNextAlias() + { + var expected = Rltl.Next(Rltl.Atom("p")); + Assert.That(RltlSExpr.ParseRltl("(next (atom p))", Codec), Is.SameAs(expected)); + } + + [Test] + public void Rltl_And_RoundTrips() + { + // Build via the printer round-trip (And constructor is internal, + // so we go through the parser as the canonical entry point). + var phi = RltlSExpr.ParseRltl("(and (atom a) (atom b) (atom c))", Codec); + Assert.That(phi, Is.InstanceOf>()); + var s = RltlSExpr.PrintRltl(phi, Codec); + Assert.That(RltlSExpr.ParseRltl(s, Codec), Is.SameAs(phi)); + } + + [Test] + public void Rltl_Or_RoundTrips() + { + var phi = RltlSExpr.ParseRltl("(or (atom a) (atom b))", Codec); + Assert.That(phi, Is.InstanceOf>()); + var s = RltlSExpr.PrintRltl(phi, Codec); + Assert.That(RltlSExpr.ParseRltl(s, Codec), Is.SameAs(phi)); + } + + [Test] + public void Rltl_And_FlattensAndDedups() + { + // (and (and p q) p) (and p q) + var phi = RltlSExpr.ParseRltl("(and (and (atom p) (atom q)) (atom p))", Codec); + var expected = RltlSExpr.ParseRltl("(and (atom p) (atom q))", Codec); + Assert.That(phi, Is.SameAs(expected)); + } + + [Test] + public void Rltl_SeqPrefix_RoundTrips() + { + var phi = Rltl.SeqPrefix( + Ere.Star(Ere.Atom("a")), + Rltl.Atom("p")); + var s = RltlSExpr.PrintRltl(phi, Codec); + Assert.That(s, Is.EqualTo("(seq (star (atom a)) (atom p))")); + Assert.That(RltlSExpr.ParseRltl(s, Codec), Is.SameAs(phi)); + } + + [Test] + public void Rltl_OvlPrefix_RoundTrips() + { + var phi = Rltl.OvlPrefix( + Ere.Atom("a"), Rltl.Atom("p")); + RltlRoundTrip(phi, "(ovl (atom a) (atom p))"); + } + + [Test] + public void Rltl_Trigger_RoundTrips() + { + var phi = Rltl.Trigger( + Ere.Atom("a"), Rltl.Atom("p")); + RltlRoundTrip(phi, "(trig (atom a) (atom p))"); + } + + [Test] + public void Rltl_Match_RoundTrips() + { + var phi = Rltl.Match( + Ere.Atom("a"), Rltl.Atom("p")); + RltlRoundTrip(phi, "(match (atom a) (atom p))"); + } + + [Test] + public void Rltl_WeakClosure_RoundTrips() + { + var phi = Rltl.WeakClosure(Ere.Atom("a")); + RltlRoundTrip(phi, "(wcl (atom a))"); + } + + [Test] + public void Rltl_NegWeakClosure_RoundTrips() + { + var phi = Rltl.NegWeakClosure(Ere.Atom("a")); + RltlRoundTrip(phi, "(nwcl (atom a))"); + } + + [Test] + public void Rltl_OmegaClosure_RoundTrips() + { + var phi = Rltl.OmegaClosure(Ere.Atom("a")); + RltlRoundTrip(phi, "(ocl (atom a))"); + } + + [Test] + public void Rltl_DeeplyNested_RoundTrips() + { + // □(p ◇q) (G (or (atom p) (F (atom q)))) + var phi = Rltl.Globally( + RltlSExpr.ParseRltl("(or (atom p) (F (atom q)))", Codec)); + var s = RltlSExpr.PrintRltl(phi, Codec); + Assert.That(RltlSExpr.ParseRltl(s, Codec), Is.SameAs(phi)); + } + + // ─── Parse error reporting ─────────────────────────────────────── + + [Test] + public void Parse_UnknownEreHead_Throws() + { + var ex = Assert.Throws( + () => RltlSExpr.ParseEre("(banana (atom a))", Codec)); + StringAssert.Contains("banana", ex.Message); + } + + [Test] + public void Parse_UnknownRltlHead_Throws() + { + var ex = Assert.Throws( + () => RltlSExpr.ParseRltl("(banana (atom a))", Codec)); + StringAssert.Contains("banana", ex.Message); + } + + [Test] + public void Parse_WrongArity_Throws() + { + Assert.Throws( + () => RltlSExpr.ParseRltl("(X (atom p) (atom q))", Codec)); + } + + [Test] + public void Parse_UnknownAtomSymbol_Throws() + { + Assert.Throws(() => RltlSExpr.ParseRltl("undefined", Codec)); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlTests.cs new file mode 100644 index 0000000..cce3a95 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/RltlTests.cs @@ -0,0 +1,536 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class RltlTests + { + private sealed class Prop : IEquatable, IComparable + { + public string Name { get; } + public Prop(string name) { Name = name; } + public override string ToString() => Name; + public override int GetHashCode() => Name.GetHashCode(); + public override bool Equals(object obj) => Equals(obj as Prop); + public bool Equals(Prop other) => other != null && Name == other.Name; + public int CompareTo(Prop other) => string.Compare(Name, other?.Name, StringComparison.Ordinal); + } + + private sealed class PropEba : IEffectiveBooleanAlgebra> + { + public Prop Top => new Prop("⊤"); + public Prop Bottom => new Prop("⊥"); + public Prop And(Prop a, Prop b) + { + if (a.Name == "⊤") return b; + if (b.Name == "⊤") return a; + if (a.Name == "⊥" || b.Name == "⊥") return Bottom; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∧{b.Name})"); + } + public Prop Or(Prop a, Prop b) + { + if (a.Name == "⊥") return b; + if (b.Name == "⊥") return a; + if (a.Name == "⊤" || b.Name == "⊤") return Top; + if (a.Equals(b)) return a; + return new Prop($"({a.Name}∨{b.Name})"); + } + public Prop Not(Prop a) + { + if (a.Name == "⊤") return Bottom; + if (a.Name == "⊥") return Top; + if (a.Name.StartsWith("¬")) return new Prop(a.Name.Substring(1)); + return new Prop($"¬{a.Name}"); + } + public bool IsSatisfiable(Prop p) => p.Name != "⊥"; + public bool Models(HashSet e, Prop p) + { + if (p.Name == "⊤") return true; + if (p.Name == "⊥") return false; + if (p.Name.StartsWith("¬")) return !e.Contains(p.Name.Substring(1)); + return e.Contains(p.Name); + } + } + + private static readonly Prop A = new Prop("a"); + private static readonly Prop B = new Prop("b"); + private static readonly RltlAlgebra Alg = new RltlAlgebra(new PropEba()); + + // ---------- Negation / NNF ---------- + + [Test] + public void Negate_SeqPrefix_YieldsTrigger() + { + var r = Ere.Atom(A); + var phi = Rltl.Atom(B); + var seq = Rltl.SeqPrefix(r, phi); + var neg = Alg.Not(seq); + Assert.That(neg, Is.InstanceOf>()); + var t = (RltlTrigger)neg; + Assert.That(t.Regex, Is.EqualTo(r)); + Assert.That(t.Phi, Is.EqualTo(Alg.Not(phi))); + } + + [Test] + public void Negate_OvlPrefix_YieldsMatch() + { + var r = Ere.Atom(A); + var phi = Rltl.Atom(B); + var ovl = Rltl.OvlPrefix(r, phi); + var neg = Alg.Not(ovl); + Assert.That(neg, Is.InstanceOf>()); + } + + [Test] + public void Negate_DoubleNegation_IsIdentity() + { + var r = Ere.Concat(Ere.Atom(A), Ere.Star(Ere.Atom(B))); + var phi = Rltl.Globally(Rltl.Atom(A)); + var f = Rltl.SeqPrefix(r, phi); + Assert.That(Alg.Not(Alg.Not(f)), Is.EqualTo(f)); + } + + // ---------- Factory simplifications ---------- + + [Test] + public void SeqPrefix_EpsilonRegex_ReducesToPhi() + { + var phi = Rltl.Atom(A); + var f = Rltl.SeqPrefix(Ere.Epsilon(), phi); + Assert.That(f, Is.EqualTo(phi)); + } + + [Test] + public void SeqPrefix_EmptyRegex_ReducesToFalse() + { + var phi = Rltl.Atom(A); + var f = Rltl.SeqPrefix(Ere.Empty(), phi); + Assert.That(f, Is.InstanceOf>()); + } + + [Test] + public void OvlPrefix_EpsilonRegex_ReducesToFalse() + { + // : requires positive-length match, ε has none + var phi = Rltl.Atom(A); + var f = Rltl.OvlPrefix(Ere.Epsilon(), phi); + Assert.That(f, Is.InstanceOf>()); + } + + [Test] + public void Trigger_EmptyRegex_ReducesToTrue() + { + var phi = Rltl.Atom(A); + var f = Rltl.Trigger(Ere.Empty(), phi); + Assert.That(f, Is.InstanceOf>()); + } + + [Test] + public void Until_RightFalse_ReducesToFalse() + { + // l U ⊥ ≡ ⊥ + var l = Rltl.Atom(A); + var f = Rltl.Until(l, Rltl.False()); + Assert.That(f, Is.InstanceOf>()); + } + + [Test] + public void SeqPrefix_SigmaStarRegex_ReducesToEventually() + { + // Σ* ; φ ≡ ◇φ = ⊤ U φ + var phi = Rltl.Atom(A); + var sigmaStar = Ere.Star(Ere.Sigma()); + var f = Rltl.SeqPrefix(sigmaStar, phi); + Assert.That(f, Is.EqualTo(Rltl.Eventually(phi))); + } + + [Test] + public void OvlPrefix_SigmaStarRegex_ReducesToEventually() + { + var phi = Rltl.Atom(A); + var sigmaStar = Ere.Star(Ere.Sigma()); + var f = Rltl.OvlPrefix(sigmaStar, phi); + Assert.That(f, Is.EqualTo(Rltl.Eventually(phi))); + } + + [Test] + public void Trigger_SigmaStarRegex_ReducesToGlobally() + { + // Σ* ⊳ φ ≡ □φ = ⊥ R φ + var phi = Rltl.Atom(A); + var sigmaStar = Ere.Star(Ere.Sigma()); + var f = Rltl.Trigger(sigmaStar, phi); + Assert.That(f, Is.EqualTo(Rltl.Globally(phi))); + } + + [Test] + public void Match_SigmaStarRegex_ReducesToGlobally() + { + var phi = Rltl.Atom(A); + var sigmaStar = Ere.Star(Ere.Sigma()); + var f = Rltl.Match(sigmaStar, phi); + Assert.That(f, Is.EqualTo(Rltl.Globally(phi))); + } + + // ---------- Accepting condition ---------- + + private static RltlDerivative> NewDeriv() + => new RltlDerivative>(new PropEba(), new ConditionRegistry()); + + [Test] + public void IsAccepting_LivenessOperators_NonAccepting() + { + var d = NewDeriv(); + Assert.That(d.IsAccepting( + Rltl.Until(Rltl.True(), Rltl.Atom(A))), Is.False); + Assert.That(d.IsAccepting( + Rltl.SeqPrefix(Ere.Atom(A), Rltl.Atom(B))), Is.False); + Assert.That(d.IsAccepting( + Rltl.OvlPrefix(Ere.Atom(A), Rltl.Atom(B))), Is.False); + } + + [Test] + public void IsAccepting_SafetyOperators_Accepting() + { + var d = NewDeriv(); + Assert.That(d.IsAccepting( + Rltl.Globally(Rltl.Atom(A))), Is.True); + Assert.That(d.IsAccepting( + Rltl.Trigger(Ere.Atom(A), Rltl.Atom(B))), Is.True); + Assert.That(d.IsAccepting( + Rltl.Match(Ere.Atom(A), Rltl.Atom(B))), Is.True); + } + + // ---------- Closures: factories ---------- + + [Test] + public void WeakClosure_OnEmptyRegex_Reduces_To_False() + { + Assert.That(Rltl.WeakClosure(Ere.Empty()), + Is.EqualTo(Rltl.False())); + } + + [Test] + public void WeakClosure_OnNullableRegex_Reduces_To_True() + { + // ε is nullable + Assert.That(Rltl.WeakClosure(Ere.Epsilon()), + Is.EqualTo(Rltl.True())); + // a* is nullable + Assert.That(Rltl.WeakClosure(Ere.Star(Ere.Atom(A))), + Is.EqualTo(Rltl.True())); + } + + [Test] + public void NegWeakClosure_OnEmptyRegex_Reduces_To_True() + { + Assert.That(Rltl.NegWeakClosure(Ere.Empty()), + Is.EqualTo(Rltl.True())); + } + + [Test] + public void NegWeakClosure_OnNullableRegex_Reduces_To_False() + { + Assert.That(Rltl.NegWeakClosure(Ere.Epsilon()), + Is.EqualTo(Rltl.False())); + } + + [Test] + public void OmegaClosure_OnEmptyRegex_Reduces_To_False() + { + Assert.That(Rltl.OmegaClosure(Ere.Empty()), + Is.EqualTo(Rltl.False())); + } + + [Test] + public void OmegaClosure_OnEpsilon_Is_Kept_AsNode() + { + // ε is nullable but {ε}ω is NOT ⊤; the factory keeps it as a node. + var f = Rltl.OmegaClosure(Ere.Epsilon()); + Assert.That(f, Is.InstanceOf>()); + } + + // ---------- Closures: negation duals ---------- + + [Test] + public void Negate_WeakClosure_YieldsNegWeakClosure() + { + var r = Ere.Atom(A); + var w = Rltl.WeakClosure(r); // a is not nullable, kept as node + var neg = Alg.Not(w); + Assert.That(neg, Is.InstanceOf>()); + Assert.That(((RltlNegWeakClosure)neg).Regex, Is.EqualTo(r)); + } + + [Test] + public void Negate_NegWeakClosure_YieldsWeakClosure() + { + var r = Ere.Atom(A); + var nw = Rltl.NegWeakClosure(r); + var neg = Alg.Not(nw); + Assert.That(neg, Is.InstanceOf>()); + } + + [Test] + public void Negate_OmegaClosure_Throws() + { + var ocl = Rltl.OmegaClosure(Ere.Atom(A)); + Assert.Throws(() => Alg.Not(ocl)); + } + + // ---------- Closures: semantic emptiness + IsAccepting ---------- + + [Test] + public void EmptinessChecker_DetectsAliveAtom() + { + var d = NewDeriv(); + Assert.That(d.Emptiness.IsAlive(Ere.Atom(A)), Is.True); + } + + [Test] + public void EmptinessChecker_DetectsDeadAtomBottom() + { + // Atom(⊥) is structurally not EreEmpty, but its only outgoing + // derivative path is guarded by ⊥ which the EBA refutes — dead. + var d = NewDeriv(); + var eba = new PropEba(); + var dead = Ere.Atom(eba.Bottom); + Assert.That(dead, Is.Not.InstanceOf>(), + "guard must be structurally non-empty."); + Assert.That(d.Emptiness.IsDead(dead), Is.True, + "Atom(⊥) has empty language; checker should report dead."); + } + + [Test] + public void IsAccepting_WeakClosure_AliveRegex_Accepting() + { + var d = NewDeriv(); + // a is non-nullable but alive, so WeakClosure(a) is kept; alive ⇒ accepting. + var w = Rltl.WeakClosure(Ere.Atom(A)); + Assert.That(w, Is.InstanceOf>()); + Assert.That(d.IsAccepting(w), Is.True); + } + + [Test] + public void IsAccepting_NegWeakClosure_AliveRegex_NonAccepting() + { + var d = NewDeriv(); + var nw = Rltl.NegWeakClosure(Ere.Atom(A)); + Assert.That(nw, Is.InstanceOf>()); + Assert.That(d.IsAccepting(nw), Is.False); + } + + [Test] + public void IsAccepting_OmegaClosure_Accepting() + { + var d = NewDeriv(); + var ocl = Rltl.OmegaClosure(Ere.Atom(A)); + Assert.That(d.IsAccepting(ocl), Is.True); + } + + // ---------- Closures: derivative rules (JACM eq. 3010–3014) ---------- + + [Test] + public void Derivative_WeakClosure_NonNullable_LiftsToInnerDerivative() + { + // deriv({a}) = ite(Null(a)=false, {deriv(a)}, …) = {deriv(a)} + // On letter 'a': deriv(a) yields ε (nullable), so wrapped weak closure + // becomes WeakClosure(ε) = True (nullable rewrite by the factory). + var d = NewDeriv(); + var w = Rltl.WeakClosure(Ere.Atom(A)); + var dw = d.Derivative(w); + var onA = dw.Evaluate(new HashSet { "a" }, d.Registry, d.Eba); + Assert.That(onA.IsTrue, Is.True, + "On 'a', deriv({a}) should fold to ⊤ since residual ε is nullable."); + var onB = dw.Evaluate(new HashSet { "b" }, d.Registry, d.Eba); + Assert.That(onB.IsFalse, Is.True, + "On 'b', residual is dead ⇒ {deriv(a)} = {⊥} = ⊥."); + } + + [Test] + public void Derivative_NegWeakClosure_NonNullable_LiftsToInnerDerivative() + { + // deriv({{a}}̄): on 'a' the residual is ε (nullable) ⇒ {{ε}}̄ = ⊥. + // On 'b' the residual is dead ⇒ {{⊥}}̄ = ⊤. + var d = NewDeriv(); + var nw = Rltl.NegWeakClosure(Ere.Atom(A)); + var dnw = d.Derivative(nw); + var onA = dnw.Evaluate(new HashSet { "a" }, d.Registry, d.Eba); + Assert.That(onA.IsFalse, Is.True); + var onB = dnw.Evaluate(new HashSet { "b" }, d.Registry, d.Eba); + Assert.That(onB.IsTrue, Is.True); + } + + [Test] + public void Derivative_OmegaClosure_UnrollsToSeqPrefix() + { + // deriv({a}ω) = deriv(a ; X {a}ω). On 'a', residual must be a Next of {a}ω. + var d = NewDeriv(); + var ocl = Rltl.OmegaClosure(Ere.Atom(A)); + var docl = d.Derivative(ocl); + var onA = docl.Evaluate(new HashSet { "a" }, d.Registry, d.Eba); + Assert.That(onA.IsFalse, Is.False); + // The result should reference the same OmegaClosure node (cyclic). + Assert.That(onA.Clauses.Any(c => c.Any(f => f is RltlNext n + && n.Inner is RltlOmegaClosure)), Is.True); + } + + // ---------- Derivative semantics ---------- + + [Test] + public void Derivative_SeqPrefix_AtomThenPhi() + { + // (a ; X b) — must see 'a' first, then 'b' next. + // Derivative on letter satisfying 'a' (no nullable case since Ere a is not nullable): + // ∂(a) = ITE(a, ε, ∅); lifting gives ITE(a, atom(SeqPrefix(ε, X b)), ⊥) + // = ITE(a, atom(X b), ⊥) [ε reduces] + // = ITE(a, atom(X b), ⊥) + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new RltlDerivative>(eba, reg); + + var phi = Rltl.Next(Rltl.Atom(B)); + var f = Rltl.SeqPrefix(Ere.Atom(A), phi); + var d = deriv.Derivative(f); + + // On 'a': leaf is Dnf with single clause containing X b + var resultOnA = d.Evaluate(new HashSet { "a" }, reg, eba); + Assert.That(resultOnA.IsFalse, Is.False); + Assert.That(resultOnA.Clauses, Has.Count.EqualTo(1)); + Assert.That(resultOnA.Clauses[0].Count(), Is.EqualTo(1)); + Assert.That(resultOnA.Clauses[0].First(), Is.EqualTo(phi)); + + // On 'b' (no 'a'): leaf is ⊥ + var resultOnB = d.Evaluate(new HashSet { "b" }, reg, eba); + Assert.That(resultOnB.IsFalse, Is.True); + } + + [Test] + public void Derivative_TriggerSigmaP_BehavesLikeGlobally() + { + // Trigger(Σ*, p) ≡ G p: + // ∀k. w[0..k]∈Σ* → w[k..]⊨p, i.e., for every k, w[k]∈p. + // Derivative on letter satisfying 'a': + // lifted leaf: Trigger(Σ*, p) (since Σ* derivative = Σ*) + // plus ∂(p) on 'a' since Σ* is nullable + // So result = atom(Trigger(Σ*,p)) ∧ atom(⊤)? + // Actually ∂(p) on a-true element should yield Dnf.True (a satisfies p? no — p=A). + // Let p = A. Then ∂(A) on 'a' = ⊤. + // So result = atom(Trigger(Σ*, A)) ∧ ⊤ = atom(Trigger(Σ*, A)) + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new RltlDerivative>(eba, reg); + + var sigma = Ere.Sigma(); + var p = Rltl.Atom(A); + var f = Rltl.Trigger(sigma, p); + var d = deriv.Derivative(f); + + // On 'a': p holds, recursive obligation persists + var rA = d.Evaluate(new HashSet { "a" }, reg, eba); + Assert.That(rA.IsFalse, Is.False); + Assert.That(rA.Clauses, Has.Count.EqualTo(1)); + Assert.That(rA.Clauses[0].First(), Is.EqualTo(f), "obligation persists"); + + // On '¬a': ∂(p) = ⊥, so the conjunction yields ⊥ + var rNoA = d.Evaluate(new HashSet(), reg, eba); + Assert.That(rNoA.IsFalse, Is.True); + } + + // ---------- End-to-end: build ABW → Æ → NBW ---------- + + [Test] + public void EndToEnd_BuildNbw_SeqPrefix() + { + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new RltlDerivative>(eba, reg); + + // Property: (a* ; b) — there is some prefix of a's followed by b. + var formula = Rltl.SeqPrefix( + Ere.Star(Ere.Atom(A)), + Rltl.Atom(B)); + + var abw = deriv.ToABW(formula); + var ae = new IncrementalAE, Rltl>(abw); + var nbw = ae.ToNBW(); + + // Force exploration + var seen = new HashSet>>(); + var queue = new Queue>>(nbw.InitialStates); + foreach (var s in nbw.InitialStates) seen.Add(s); + while (queue.Count > 0) + { + var s = queue.Dequeue(); + foreach (var term in nbw.GetTransition(s)) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) queue.Enqueue(succ); + } + + Assert.That(seen.Count, Is.GreaterThan(0)); + Assert.That(nbw.InitialStates.Any(), Is.True); + } + + [Test] + public void EndToEnd_GloballyEquivalent_TriggerSigma() + { + // Trigger(Σ*, a) and G a should produce structurally similar NBWs + // (both safety properties — accepting is "true" everywhere). + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new RltlDerivative>(eba, reg); + + var ga = Rltl.Globally(Rltl.Atom(A)); + var trigSigma = Rltl.Trigger(Ere.Sigma(), Rltl.Atom(A)); + + var abw1 = deriv.ToABW(ga); + var abw2 = deriv.ToABW(trigSigma); + var nbw1 = new IncrementalAE, Rltl>(abw1).ToNBW(); + var nbw2 = new IncrementalAE, Rltl>(abw2).ToNBW(); + + // Both should have at least one initial state, and the initial state + // is accepting (no liveness obligation). + Assert.That(nbw1.InitialStates.All(nbw1.IsAccepting), Is.True); + Assert.That(nbw2.InitialStates.All(nbw2.IsAccepting), Is.True); + } + + [Test] + public void EndToEnd_SeqPrefix_HasLivenessObligation() + { + // (a* ; b) is liveness — the breakpoint construction must produce + // at least one reachable state with a pending obligation (O ≠ ∅, + // i.e. non-accepting), reflecting the unfulfilled liveness. + var eba = new PropEba(); + var reg = new ConditionRegistry(); + var deriv = new RltlDerivative>(eba, reg); + + var formula = Rltl.SeqPrefix( + Ere.Star(Ere.Atom(A)), + Rltl.Atom(B)); + + var abw = deriv.ToABW(formula); + var nbw = new IncrementalAE, Rltl>(abw).ToNBW(); + + // Force exploration of all reachable states. + var seen = new HashSet>>(nbw.InitialStates); + var queue = new Queue>>(nbw.InitialStates); + while (queue.Count > 0) + { + var s = queue.Dequeue(); + foreach (var term in nbw.GetTransition(s)) + foreach (var leaf in term.GetDistinctLeaves()) + foreach (var succ in leaf) + if (seen.Add(succ)) queue.Enqueue(succ); + } + + Assert.That(seen.Any(s => !nbw.IsAccepting(s)), Is.True, + "Liveness formula must yield a reachable breakpoint with a pending obligation."); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/StatePropEbaSatTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/StatePropEbaSatTests.cs new file mode 100644 index 0000000..165e6ab --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/StatePropEbaSatTests.cs @@ -0,0 +1,101 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests for the precise + /// decision procedure (todo statepred-precise-sat): each + /// is treated as an independent Boolean variable + /// and a brute-force truth-table decides the propositional fragment of + /// the predicate algebra. + /// + [TestFixture] + public class StatePropEbaSatTests + { + private StatePropEba _eba; + private StateProp _p; + private StateProp _q; + private StateProp _r; + + [SetUp] + public void Setup() + { + _eba = StatePropEba.Instance; + _p = new StateProp("p", _ => true); + _q = new StateProp("q", _ => true); + _r = new StateProp("r", _ => true); + } + + private IStatePredicate Atom(StateProp p) => new StatePredAtom(p); + + [Test] + public void Constants() { + Assert.That(_eba.IsSatisfiable(_eba.Top), Is.True); + Assert.That(_eba.IsSatisfiable(_eba.Bottom), Is.False); + } + + [Test] + public void SingleAtom_Satisfiable() { + Assert.That(_eba.IsSatisfiable(Atom(_p)), Is.True); + Assert.That(_eba.IsSatisfiable(_eba.Not(Atom(_p))), Is.True); + } + + [Test] + public void Contradiction_PAndNotP_Unsatisfiable() { + var phi = _eba.And(Atom(_p), _eba.Not(Atom(_p))); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + + [Test] + public void Tautology_PorNotP_Satisfiable() { + var phi = _eba.Or(Atom(_p), _eba.Not(Atom(_p))); + Assert.That(_eba.IsSatisfiable(phi), Is.True); + } + + [Test] + public void Conjunction_With_Hidden_Contradiction() { + // (p ∧ q) ∧ ¬p — unsatisfiable; the conflict is below an inner conjunction. + var phi = _eba.And(_eba.And(Atom(_p), Atom(_q)), _eba.Not(Atom(_p))); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + + [Test] + public void Disjunction_Of_Contradictions_Unsatisfiable() { + // (p ∧ ¬p) ∨ (q ∧ ¬q) — both disjuncts unsat ⇒ whole formula unsat. + var phi = _eba.Or( + _eba.And(Atom(_p), _eba.Not(Atom(_p))), + _eba.And(Atom(_q), _eba.Not(Atom(_q)))); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + + [Test] + public void DistinctAtoms_Independent() { + // p ∧ ¬q — satisfiable (p=true, q=false). + var phi = _eba.And(Atom(_p), _eba.Not(Atom(_q))); + Assert.That(_eba.IsSatisfiable(phi), Is.True); + } + + [Test] + public void ThreeAtoms_HiddenContradiction() { + // (p ∨ q) ∧ ¬p ∧ ¬q ∧ r — unsatisfiable: ¬p ∧ ¬q forces p∨q false. + var phi = _eba.And( + _eba.And( + _eba.And(_eba.Or(Atom(_p), Atom(_q)), _eba.Not(Atom(_p))), + _eba.Not(Atom(_q))), + Atom(_r)); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + + [Test] + public void DoubleNegation_Preserved() { + // ¬¬p ≡ p — built via the EBA, the constructor normalises double-not. + var notNotP = _eba.Not(_eba.Not(Atom(_p))); + Assert.That(_eba.IsSatisfiable(notNotP), Is.True); + + // ¬¬p ∧ ¬p — unsatisfiable. + var phi = _eba.And(notNotP, _eba.Not(Atom(_p))); + Assert.That(_eba.IsSatisfiable(phi), Is.False); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicLtlCheckTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicLtlCheckTests.cs new file mode 100644 index 0000000..85812a7 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicLtlCheckTests.cs @@ -0,0 +1,365 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Integration tests for SymbolicLtlCheck: model program × symbolic NBW. + /// Uses manually constructed state graphs (no full model program machinery needed). + /// + [TestFixture] + public class SymbolicLtlCheckTests + { + #region Test Infrastructure + + /// Simple concrete State for testing. + private sealed class TestState : State + { + public string Label { get; set; } + public int Value { get; set; } + + public TestState(string label, int value = 0) + { + Label = label; + Value = value; + } + + protected override void CloneInternal(Dictionary clonedMap) + { + var clone = new TestState(Label, Value); + clonedMap[this] = clone; + } + + protected override void LockComponents(HashSet visited) { } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + /// Simple step function for labeling edges. + private sealed class TestStepFunction : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + + public TestStepFunction(string id) + { + StepFunctionId = id; + StepFunctionIdHash = id.GetHashCode(); + } + + public IList Apply(IState state, + IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + /// + /// Builds a state graph node with the given state and step functions. + /// + private static StateGraphNode MakeNode(TestState state, params string[] sfIds) + { + state.Freeze(); + return new StateGraphNode + { + State = state, + StepFunctions = sfIds.Select(id => (IStepFunction)new TestStepFunction(id)).ToList(), + Edges = new List() + }; + } + + /// Adds a directed edge between nodes. + private static void AddEdge(StateGraphNode from, StateGraphNode to, string sfId = "step") + { + from.Edges.Add(new StateGraphEdge + { + Target = to, + StepFunction = new TestStepFunction(sfId) + }); + } + + #endregion + + #region Helper: build LTL property from StateProp + + private static StateProp Prop(string name, Func eval) + => new StateProp(name, eval); + + private static Ltl Atom(StateProp p) + => Ltl.Atom(new StatePredAtom(p)); + + private static Ltl G(Ltl f) + => Ltl.Globally(f); + + private static Ltl F(Ltl f) + => Ltl.Eventually(f); + + private static Ltl Implies(Ltl a, Ltl b) + => LtlAlgebra.Default.Implies(a, b); + + #endregion + + #region Basic Correctness Tests + + [Test] + public void Check_Ga_Satisfied_AllStatesHaveA() + { + // Linear graph: s0 → s1 → s2 → s2 (self-loop) + // All states have value > 0 (property: G(val > 0)) + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 2)); + var s2 = MakeNode(new TestState("s2", 3)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s2); // self-loop + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var property = G(Atom(p)); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.True, "G(val>0) should hold when all values > 0"); + } + + [Test] + public void Check_Ga_Violated_OneStateDoesNotHaveA() + { + // s0(val=1) → s1(val=0) → s1 (self-loop) + // Property: G(val > 0) — violated at s1 + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var property = G(Atom(p)); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.False, + "G(val>0) should be violated when s1 has val=0 in a cycle"); + } + + [Test] + public void Check_Fa_Satisfied_EventuallyReachesA() + { + // s0 → s1 → s2(goal) → s2 + // Property: F(goal) — eventually reach val=99 + var s0 = MakeNode(new TestState("s0", 0)); + var s1 = MakeNode(new TestState("s1", 0)); + var s2 = MakeNode(new TestState("s2", 99)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s2); + + var p = Prop("goal", s => ((TestState)s).Value == 99); + var property = F(Atom(p)); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.True, "F(goal) should hold"); + } + + [Test] + public void Check_Fa_Violated_NeverReachesA() + { + // s0 → s1 → s0 (cycle, never reaches goal) + // Property: F(goal) — violated because we loop forever + var s0 = MakeNode(new TestState("s0", 0)); + var s1 = MakeNode(new TestState("s1", 1)); + AddEdge(s0, s1); + AddEdge(s1, s0); + + var p = Prop("goal", s => ((TestState)s).Value == 99); + var property = F(Atom(p)); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.False, + "F(goal) should be violated in a cycle without goal"); + } + + [Test] + public void Check_GFa_Satisfied_InfinitelyOften() + { + // s0(a) → s1(¬a) → s0 (cycle visits a infinitely often) + // Property: GF(a) — a holds infinitely often + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s0); + + var p = Prop("a", s => ((TestState)s).Value == 1); + var property = G(F(Atom(p))); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.True, + "GF(a) should hold in cycle {s0(a), s1(¬a)}"); + } + + [Test] + public void Check_GFa_Violated_EventuallyNeverA() + { + // s0(a) → s1(¬a) → s1 (s1 self-loop, never sees a again) + // Property: GF(a) — violated because eventually stuck in s1 + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("a", s => ((TestState)s).Value == 1); + var property = G(F(Atom(p))); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.False, + "GF(a) violated: cycle at s1 never visits a"); + } + + #endregion + + #region Implication / Response Tests + + [Test] + public void Check_G_AImplFb_Satisfied() + { + // s0(a) → s1(¬a) → s2(b) → s0 (cycle: request always followed by response) + // Property: G(a → F b) + var s0 = MakeNode(new TestState("s0_req", 1)); + var s1 = MakeNode(new TestState("s1_mid", 0)); + var s2 = MakeNode(new TestState("s2_resp", 2)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s0); + + var req = Prop("req", s => ((TestState)s).Value == 1); + var resp = Prop("resp", s => ((TestState)s).Value == 2); + var property = G(Implies(Atom(req), F(Atom(resp)))); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.True, + "G(req → F resp) should hold: every request is followed by response"); + } + + [Test] + public void Check_G_AImplFb_Violated() + { + // s0(a) → s1(¬a,¬b) → s1 (loop: request never gets response) + // Property: G(a → F b) + var s0 = MakeNode(new TestState("s0_req", 1)); + var s1 = MakeNode(new TestState("s1_stuck", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var req = Prop("req", s => ((TestState)s).Value == 1); + var resp = Prop("resp", s => ((TestState)s).Value == 2); + var property = G(Implies(Atom(req), F(Atom(resp)))); + + var result = SymbolicLtlCheck.Check(s0, property); + Assert.That(result.Valid, Is.False, + "G(req → F resp) violated: request at s0 never gets response"); + } + + #endregion + + #region Bounded Depth Tests + + [Test] + public void Check_BoundedDepth_NoViolationWithinBound() + { + // Long chain: s0 → s1 → ... → s10 → bad_cycle + // Property: G(val >= 0) — violated deep in the chain + var nodes = new StateGraphNode[12]; + for (int i = 0; i < 11; i++) + nodes[i] = MakeNode(new TestState($"s{i}", i)); + nodes[11] = MakeNode(new TestState("bad", -1)); + + for (int i = 0; i < 11; i++) + AddEdge(nodes[i], nodes[i + 1]); + AddEdge(nodes[11], nodes[11]); // self-loop at bad state + + var p = Prop("non_neg", s => ((TestState)s).Value >= 0); + var property = G(Atom(p)); + + // With depth bound 5, we shouldn't reach the violation + var result = SymbolicLtlCheck.Check(nodes[0], property, maxDepth: 5); + Assert.That(result.Valid, Is.True, + "Bounded check (depth 5) shouldn't find violation at depth 11"); + + // With unlimited depth, we should find it + var resultFull = SymbolicLtlCheck.Check(nodes[0], property); + Assert.That(resultFull.Valid, Is.False, + "Unbounded check should find violation at depth 11"); + } + + #endregion + + #region Counterexample Trace Tests + + [Test] + public void Check_Violation_HasTrace() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("a", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.Check(s0, G(Atom(p))); + + Assert.That(result.Valid, Is.False); + Assert.That(result.Trace, Is.Not.Null); + Assert.That(result.Trace.Count, Is.GreaterThan(0)); + } + + #endregion + + #region Edge Cases + + [Test] + public void Check_SingleState_SelfLoop_PropertyHolds() + { + var s0 = MakeNode(new TestState("s0", 42)); + AddEdge(s0, s0); + + var p = Prop("is42", s => ((TestState)s).Value == 42); + var result = SymbolicLtlCheck.Check(s0, G(Atom(p))); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Check_SingleState_NoEdges_StutterSemantics() + { + // Terminal state: stutter self-loop applied. + // G(a) should hold if a holds in the terminal state. + var s0 = MakeNode(new TestState("s0", 1)); + // No edges — terminal + + var p = Prop("a", s => ((TestState)s).Value == 1); + var result = SymbolicLtlCheck.Check(s0, G(Atom(p))); + Assert.That(result.Valid, Is.True, + "Terminal state satisfying a: G(a) holds under stutter"); + } + + [Test] + public void Check_TrueProperty_AlwaysHolds() + { + var s0 = MakeNode(new TestState("s0", 0)); + AddEdge(s0, s0); + + var result = SymbolicLtlCheck.Check(s0, Ltl.True()); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Check_FalseProperty_AlwaysFails() + { + var s0 = MakeNode(new TestState("s0", 0)); + AddEdge(s0, s0); + + var result = SymbolicLtlCheck.Check(s0, Ltl.False()); + Assert.That(result.Valid, Is.False); + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicRltlCheckTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicRltlCheckTests.cs new file mode 100644 index 0000000..60d9228 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/SymbolicRltlCheckTests.cs @@ -0,0 +1,406 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// End-to-end tests for . Verify that the + /// RLTL pipeline (RLTL → ABW → NBW → NDFS) gives the expected verdicts + /// on small hand-built state graphs, and that on the LTL subset it agrees + /// with . + /// + [TestFixture] + public class SymbolicRltlCheckTests + { + #region Test Infrastructure (mirrors SymbolicLtlCheckTests) + + private sealed class TestState : State + { + public string Label { get; set; } + public int Value { get; set; } + + public TestState(string label, int value = 0) + { + Label = label; + Value = value; + } + + protected override void CloneInternal(Dictionary clonedMap) + { + clonedMap[this] = new TestState(Label, Value); + } + + protected override void LockComponents(HashSet visited) { } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class TestStepFunction : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + + public TestStepFunction(string id) + { + StepFunctionId = id; + StepFunctionIdHash = id.GetHashCode(); + } + + public IList Apply(IState state, + IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + private static StateGraphNode MakeNode(TestState state, params string[] sfIds) + { + state.Freeze(); + return new StateGraphNode + { + State = state, + StepFunctions = sfIds.Select(id => (IStepFunction)new TestStepFunction(id)).ToList(), + Edges = new List() + }; + } + + private static void AddEdge(StateGraphNode from, StateGraphNode to, string sfId = "step") + { + from.Edges.Add(new StateGraphEdge + { + Target = to, + StepFunction = new TestStepFunction(sfId) + }); + } + + private static StateProp Prop(string name, Func eval) + => new StateProp(name, eval); + + // --- RLTL constructors ------------------------------------------- + + private static Rltl RAtom(StateProp p) + => Rltl.Atom(new StatePredAtom(p)); + + private static Rltl RG(Rltl f) + => Rltl.Globally(f); + + private static Rltl RF(Rltl f) + => Rltl.Eventually(f); + + private static Rltl RImplies(Rltl a, Rltl b) + => RltlAlgebra.Default.Implies(a, b); + + private static Ere ESigma() => Ere.Sigma(); + private static Ere EStar(Ere r) => Ere.Star(r); + private static Ere EAtom(StateProp p) + => Ere.Atom(new StatePredAtom(p)); + private static Ere EConcat(Ere a, Ere b) + => Ere.Concat(a, b); + + // --- LTL constructors (for cross-checking the LTL subset) -------- + + private static Ltl LAtom(StateProp p) + => Ltl.Atom(new StatePredAtom(p)); + private static Ltl LG(Ltl f) + => Ltl.Globally(f); + private static Ltl LF(Ltl f) + => Ltl.Eventually(f); + + #endregion + + #region LTL subset — agreement with SymbolicLtlCheck + + [Test] + public void Rltl_Ga_Satisfied() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 1)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("v>0", s => ((TestState)s).Value > 0); + var rltl = SymbolicRltlCheck.Check(s0, RG(RAtom(p))); + var ltl = SymbolicLtlCheck.CheckNDFS(s0, LG(LAtom(p))); + + Assert.That(rltl.Valid, Is.True); + Assert.That(rltl.Valid, Is.EqualTo(ltl.Valid)); + } + + [Test] + public void Rltl_Ga_Violated_HasCycleTrace() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("v>0", s => ((TestState)s).Value > 0); + var result = SymbolicRltlCheck.Check(s0, RG(RAtom(p))); + + Assert.That(result.Valid, Is.False); + Assert.That(result.Trace, Is.Not.Null); + Assert.That(result.Trace.Any(t => t.IsInCycle), Is.True); + } + + [Test] + public void Rltl_Fa_Satisfied() + { + var s0 = MakeNode(new TestState("s0", 0)); + var s1 = MakeNode(new TestState("s1", 99)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("g", s => ((TestState)s).Value == 99); + var result = SymbolicRltlCheck.Check(s0, RF(RAtom(p))); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Rltl_Fa_Violated() + { + var s0 = MakeNode(new TestState("s0", 0)); + var s1 = MakeNode(new TestState("s1", 1)); + AddEdge(s0, s1); + AddEdge(s1, s0); + + var p = Prop("g", s => ((TestState)s).Value == 99); + var result = SymbolicRltlCheck.Check(s0, RF(RAtom(p))); + Assert.That(result.Valid, Is.False); + } + + [Test] + public void Rltl_GFa_Satisfied() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s0); + + var p = Prop("a", s => ((TestState)s).Value == 1); + var result = SymbolicRltlCheck.Check(s0, RG(RF(RAtom(p)))); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Rltl_GFa_Violated() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("a", s => ((TestState)s).Value == 1); + var result = SymbolicRltlCheck.Check(s0, RG(RF(RAtom(p)))); + Assert.That(result.Valid, Is.False); + } + + #endregion + + #region Regex-prefix operators + + /// Σ* ⊳ p ≡ G p — the safety reading of the trigger operator. + [Test] + public void Rltl_TriggerSigmaStar_p_Equivalent_To_Gp() + { + // Satisfied case: all states have val>0. + var sat0 = MakeNode(new TestState("s0", 1)); + var sat1 = MakeNode(new TestState("s1", 2)); + AddEdge(sat0, sat1); AddEdge(sat1, sat1); + + // Violated case: cycle visiting val=0. + var vio0 = MakeNode(new TestState("v0", 1)); + var vio1 = MakeNode(new TestState("v1", 0)); + AddEdge(vio0, vio1); AddEdge(vio1, vio1); + + var p = Prop("v>0", s => ((TestState)s).Value > 0); + var triggerForm = Rltl.Trigger(ESigma(), RAtom(p)); + + var satResult = SymbolicRltlCheck.Check(sat0, triggerForm); + var vioResult = SymbolicRltlCheck.Check(vio0, triggerForm); + + Assert.That(satResult.Valid, Is.True, "Σ*⊳p should hold when p holds globally"); + Assert.That(vioResult.Valid, Is.False, "Σ*⊳p should fail in a cycle visiting ¬p"); + } + + /// Σ* ; p ≡ F p — the liveness reading of the seq-prefix operator. + [Test] + public void Rltl_SeqPrefixSigmaStar_p_Equivalent_To_Fp() + { + // Satisfied case: reaches goal. + var sat0 = MakeNode(new TestState("s0", 0)); + var sat1 = MakeNode(new TestState("s1", 99)); + AddEdge(sat0, sat1); AddEdge(sat1, sat1); + + // Violated case: cycle without goal. + var vio0 = MakeNode(new TestState("v0", 0)); + var vio1 = MakeNode(new TestState("v1", 1)); + AddEdge(vio0, vio1); AddEdge(vio1, vio0); + + var p = Prop("g", s => ((TestState)s).Value == 99); + var seqForm = Rltl.SeqPrefix(ESigma(), RAtom(p)); + + var satResult = SymbolicRltlCheck.Check(sat0, seqForm); + var vioResult = SymbolicRltlCheck.Check(vio0, seqForm); + + Assert.That(satResult.Valid, Is.True, "Σ*;p should hold when goal is reached"); + Assert.That(vioResult.Valid, Is.False, "Σ*;p should fail in a goal-free cycle"); + } + + /// + /// A genuinely regex-shaped property with no direct LTL equivalent: + /// (p·q)* ⊳ r — for every position k that is reached after a + /// finite alternating sequence p,q,p,q,… (an even-length match of + /// (p·q)*), the suffix at k must satisfy r. We exercise both + /// the satisfied and violated branches. Predicates are encoded as + /// bits of Value so a single state can satisfy p, q, r + /// independently. + /// + [Test] + public void Rltl_PqStar_Trigger_r_RegexShape() + { + var p = Prop("p", s => (((TestState)s).Value & 1) != 0); + var q = Prop("q", s => (((TestState)s).Value & 2) != 0); + var r = Prop("r", s => (((TestState)s).Value & 4) != 0); + + var phi = Rltl.Trigger( + EStar(EConcat(EAtom(p), EAtom(q))), + RAtom(r)); + + // Satisfied: a single state with r set (val=4) and a self-loop. + // The only prefix in L((p·q)*) is ε ⇒ r must hold at pos 0 (it does). + // No state ever satisfies p, so no longer prefix matches. + var sat0 = MakeNode(new TestState("sat0", 4)); + AddEdge(sat0, sat0); + + var satResult = SymbolicRltlCheck.Check(sat0, phi); + Assert.That(satResult.Valid, Is.True, + "Only ε matches (p·q)*; r holds at pos 0 ⇒ property holds"); + + // Violated: v0 satisfies p AND r (val=5), v1 satisfies q (val=2), + // v2 satisfies nothing (val=0) and self-loops. + // pos 0: prefix ε ⇒ need r at v0 — holds (bit 2 of 5 is set) + // pos 2: prefix w[0..2] = (p,q) ∈ L((p·q)*) ⇒ need r at v2 — FAILS + var v0 = MakeNode(new TestState("v0", 5)); // p ∧ r + var v1 = MakeNode(new TestState("v1", 2)); // q + var v2 = MakeNode(new TestState("v2", 0)); // ¬p ¬q ¬r + AddEdge(v0, v1); AddEdge(v1, v2); AddEdge(v2, v2); + + var vioResult = SymbolicRltlCheck.Check(v0, phi); + Assert.That(vioResult.Valid, Is.False, + "(p·q)*⊳r should fail: after the (p,q) match at positions 0..1, r does not hold at position 2"); + } + + #endregion + + #region Edge cases + + [Test] + public void Rltl_True_AlwaysHolds() + { + var s0 = MakeNode(new TestState("s0", 0)); + AddEdge(s0, s0); + var result = SymbolicRltlCheck.Check(s0, Rltl.True()); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void Rltl_False_AlwaysFails() + { + var s0 = MakeNode(new TestState("s0", 0)); + AddEdge(s0, s0); + var result = SymbolicRltlCheck.Check(s0, Rltl.False()); + Assert.That(result.Valid, Is.False); + } + + [Test] + public void Rltl_BoundedDepth_NoViolationWithinBound() + { + var nodes = new List(); + for (int i = 0; i < 8; i++) + nodes.Add(MakeNode(new TestState($"s{i}", i < 5 ? 1 : 0))); + for (int i = 0; i < nodes.Count - 1; i++) + AddEdge(nodes[i], nodes[i + 1]); + AddEdge(nodes[^1], nodes[^1]); + + var p = Prop("v>0", s => ((TestState)s).Value > 0); + var result = SymbolicRltlCheck.Check( + nodes[0], + Rltl.Trigger(ESigma(), RAtom(p)), + maxDepth: 3); + Assert.That(result.Valid, Is.True); + } + + #endregion + + #region Regex language equivalence via RLTL emptiness + + /// + /// Full language equivalence α* : β* ≡ α* · (α ∧ β) · β* decided + /// through the RLTL model-checking pipeline rather than ad-hoc + /// derivative exploration. Strategy: + /// + /// Build a chaos state graph over the 2-atom alphabet + /// {a,b} — one node per truth assignment, every node reachable from + /// every other. Any infinite word over Σ = 2^{a,b} is realised. + /// Regex emptiness over that universal language is then + /// exactly RLTL property ¬(R ; ⊤) holding on the chaos graph + /// (no run can have any prefix in L(R)). + /// L(R) = L(S) iff both R ∩ ¬S and S ∩ ¬R are empty. + /// + /// + [Test] + public void Rltl_Fusion_Example7_1_LanguageEquivalence_ViaEmptinessCheck() + { + // --- Chaos state graph over {a, b} --------------------------- + // States are indexed by 2 bits: bit 0 = a, bit 1 = b. + var chaos = new StateGraphNode[4]; + for (int i = 0; i < 4; i++) + chaos[i] = MakeNode(new TestState($"ab={i:b2}", i)); + for (int i = 0; i < 4; i++) + for (int j = 0; j < 4; j++) + AddEdge(chaos[i], chaos[j]); + + var a = Prop("a", s => (((TestState)s).Value & 1) != 0); + var b = Prop("b", s => (((TestState)s).Value & 2) != 0); + + // --- Regex pair to test --------------------------------------- + // α* : β* vs α* · (α ∧ β) · β* + var aE = EAtom(a); + var bE = EAtom(b); + var aStar = EStar(aE); + var bStar = EStar(bE); + var aAndB = Ere.Intersect(aE, bE); + + var R = Ere.Fusion(aStar, bStar); + var S = EConcat(aStar, EConcat(aAndB, bStar)); + + // --- Equivalence ⇔ both differences are empty languages ----- + Assert.That(LanguageEmptyOnChaos( + Ere.Intersect(R, Ere.Complement(S)), + chaos[0]), + Is.True, "R \\ S is empty"); + Assert.That(LanguageEmptyOnChaos( + Ere.Intersect(S, Ere.Complement(R)), + chaos[0]), + Is.True, "S \\ R is empty"); + } + + /// + /// L() ∩ Σω-prefixes = ∅, decided as the RLTL + /// property ¬(r ; True) on a universal (chaos) state graph. + /// Returns the verdict (true = language empty). + /// + private static bool LanguageEmptyOnChaos(Ere r, StateGraphNode chaosRoot) + { + var seqRtoTrue = Rltl.SeqPrefix(r, Rltl.True()); + var notSeq = RltlAlgebra.Default.Not(seqRtoTrue); + return SymbolicRltlCheck.Check(chaosRoot, notSeq).Valid; + } + + #endregion + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/TestHelpers.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/TestHelpers.cs new file mode 100644 index 0000000..b231f13 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/TestHelpers.cs @@ -0,0 +1,157 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// A simple integer predicate for testing: predicates are sets of integers. + /// The universe is a finite set of integers {0..N-1}. + /// + public class IntPredicate : IEquatable + { + public HashSet Elements { get; } + public string Name { get; } + + public IntPredicate(string name, params int[] elements) + { + Name = name; + Elements = new HashSet(elements); + } + + public IntPredicate(string name, IEnumerable elements) + { + Name = name; + Elements = new HashSet(elements); + } + + public bool Equals(IntPredicate other) + => other != null && Elements.SetEquals(other.Elements); + + public override bool Equals(object obj) => Equals(obj as IntPredicate); + public override int GetHashCode() + { + int h = 0; + foreach (var e in Elements.OrderBy(x => x)) + h = h * 31 + e; + return h; + } + + public override string ToString() => Name ?? $"{{{string.Join(",", Elements.OrderBy(x => x))}}}"; + } + + /// + /// EBA over integers with a finite universe {0..Size-1}. + /// Predicates are sets of integers; satisfiability = non-empty set. + /// + public class IntEba : IEffectiveBooleanAlgebraEx + { + private readonly int _size; + + public IntEba(int size) + { + _size = size; + Top = new IntPredicate("⊤", Enumerable.Range(0, size)); + Bottom = new IntPredicate("⊥"); + } + + public IntPredicate Top { get; } + public IntPredicate Bottom { get; } + + public IntPredicate And(IntPredicate a, IntPredicate b) + { + var intersection = new HashSet(a.Elements); + intersection.IntersectWith(b.Elements); + return new IntPredicate($"({a.Name}∧{b.Name})", intersection); + } + + public IntPredicate Or(IntPredicate a, IntPredicate b) + { + var union = new HashSet(a.Elements); + union.UnionWith(b.Elements); + return new IntPredicate($"({a.Name}∨{b.Name})", union); + } + + public IntPredicate Not(IntPredicate a) + { + var complement = Enumerable.Range(0, _size).Where(i => !a.Elements.Contains(i)); + return new IntPredicate($"¬{a.Name}", complement); + } + + public bool IsSatisfiable(IntPredicate predicate) => predicate.Elements.Count > 0; + + public bool Models(int element, IntPredicate predicate) => predicate.Elements.Contains(element); + + public bool AreEquivalent(IntPredicate a, IntPredicate b) + => a.Elements.SetEquals(b.Elements); + + public bool Implies(IntPredicate a, IntPredicate b) + => a.Elements.IsSubsetOf(b.Elements); + + public bool TryGetModel(IntPredicate predicate, out int element) + { + foreach (var e in predicate.Elements) { element = e; return true; } + element = default; + return false; + } + } + + /// + /// Simple string-based leaf algebra for testing. + /// Leaves are strings; Or/And produce ACI-normalized sorted comma-separated forms. + /// + public class StringLeafAlgebra : ILeafAlgebra + { + public string Top => "⊤"; + public string Bottom => "⊥"; + + public bool IsTop(string a) => a == "⊤"; + public bool IsBottom(string a) => a == "⊥"; + + public string Or(string a, string b) + { + if (IsTop(a) || IsTop(b)) return Top; + if (IsBottom(a)) return b; + if (IsBottom(b)) return a; + if (a == b) return a; // idempotent + + // ACI: parse, merge, sort, deduplicate + var parts = ParseDisjuncts(a).Union(ParseDisjuncts(b)).OrderBy(x => x).ToList(); + return parts.Count == 1 ? parts[0] : string.Join("∨", parts); + } + + public string And(string a, string b) + { + if (IsBottom(a) || IsBottom(b)) return Bottom; + if (IsTop(a)) return b; + if (IsTop(b)) return a; + if (a == b) return a; // idempotent + + // ACI: parse, merge, sort, deduplicate + var parts = ParseConjuncts(a).Union(ParseConjuncts(b)).OrderBy(x => x).ToList(); + return parts.Count == 1 ? parts[0] : string.Join("∧", parts); + } + + public string Not(string a) + { + if (IsTop(a)) return Bottom; + if (IsBottom(a)) return Top; + return $"¬{a}"; + } + + public string Xor(string a, string b) + { + // Fallback: (a ∧ ¬b) ∨ (¬a ∧ b) + return Or(And(a, Not(b)), And(Not(a), b)); + } + + public IEqualityComparer Comparer => StringComparer.Ordinal; + + private static IEnumerable ParseDisjuncts(string s) + => s.Contains("∨") ? s.Split(new[] { '∨' }) : new[] { s }; + + private static IEnumerable ParseConjuncts(string s) + => s.Contains("∧") ? s.Split(new[] { '∧' }) : new[] { s }; + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/TraceInstantiationTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/TraceInstantiationTests.cs new file mode 100644 index 0000000..da95081 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/TraceInstantiationTests.cs @@ -0,0 +1,185 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Tests that counterexample traces produced by the symbolic LTL/RLTL + /// backends carry concrete predicate valuations per state — i.e. each + /// entry agrees with re-evaluating + /// the predicate on the trace item's . + /// Also verifies the human-readable rendering surfaces the valuation. + /// + [TestFixture] + public class TraceInstantiationTests + { + #region Test infrastructure (mirrors NestedDfsCheckTests) + + private sealed class TestState : State + { + public string Label { get; set; } + public int Value { get; set; } + + public TestState(string label, int value = 0) + { + Label = label; + Value = value; + } + + protected override void CloneInternal(Dictionary clonedMap) + { + clonedMap[this] = new TestState(Label, Value); + } + + protected override void LockComponents(HashSet visited) { } + + protected override string StringRepresentationInternal(Dictionary objectPaths, string path, bool forceRecompute) + => $"{Label}({Value})"; + protected override void FreezeComponents(HashSet visited) { } + } + + private sealed class TestStepFunction : IStepFunction + { + public string StepFunctionId { get; } + public int StepFunctionIdHash { get; } + public TestStepFunction(string id) + { + StepFunctionId = id; + StepFunctionIdHash = id.GetHashCode(); + } + public IList Apply(IState state, + IReadOnlyList<(IStepFunction, StateGraphNode)> path) => null; + } + + private static StateGraphNode MakeNode(TestState state, params string[] sfIds) + { + state.Freeze(); + return new StateGraphNode + { + State = state, + StepFunctions = sfIds.Select(id => (IStepFunction)new TestStepFunction(id)).ToList(), + Edges = new List() + }; + } + + private static void AddEdge(StateGraphNode from, StateGraphNode to, string sfId = "step") + { + from.Edges.Add(new StateGraphEdge + { + Target = to, + StepFunction = new TestStepFunction(sfId) + }); + } + + private static StateProp Prop(string name, System.Func eval) + => new StateProp(name, eval); + + private static Ltl Atom(StateProp p) + => Ltl.Atom(new StatePredAtom(p)); + + private static Ltl G(Ltl f) + => Ltl.Globally(f); + + #endregion + + [Test] + public void SccBackend_TraceItemsCarryConsistentValuations() + { + // Property G(val>0) violated on s2(val=0) — gives a lasso with + // both prefix and cycle items. + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 1)); + var s2 = MakeNode(new TestState("s2", 0)); + AddEdge(s0, s1); + AddEdge(s1, s2); + AddEdge(s2, s2); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.Check(s0, G(Atom(p))); + + Assert.That(result.Valid, Is.False); + Assert.That(result.Trace, Is.Not.Null); + + foreach (var item in result.Trace) + { + Assert.That(item.Valuation, Is.Not.Null, + "symbolic backend should attach a predicate valuation"); + Assert.That(item.Valuation.Count, Is.GreaterThan(0), + "registry contains at least the property atom"); + // Each entry must agree with re-evaluating the predicate + // on the concrete state. + foreach (var kv in item.Valuation) + { + Assert.That(kv.Value, Is.EqualTo(kv.Key.Eval(item.StateGraphNode.State)), + $"valuation for {kv.Key} disagrees with state {item.StateGraphNode.State}"); + } + } + + // The cycle should be in the s2 region where val>0 is false, + // i.e. ¬(val>0) is true. The registry holds the negated atom + // because the property is negated before NBW construction. + var cycleItem = result.Trace.First(t => t.IsInCycle); + var negAtom = cycleItem.Valuation.Keys.First(k => k.ToString().Contains("val>0")); + // Either the atom or its negation must be present and agree with the state. + Assert.That(cycleItem.Valuation[negAtom], + Is.EqualTo(negAtom.Eval(cycleItem.StateGraphNode.State)), + "cycle node valuation should reflect the concrete state"); + } + + [Test] + public void NdfsBackend_TraceItemsCarryConsistentValuations() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.CheckNDFS(s0, G(Atom(p))); + + Assert.That(result.Valid, Is.False); + Assert.That(result.Trace, Is.Not.Null); + + foreach (var item in result.Trace) + { + Assert.That(item.Valuation, Is.Not.Null); + foreach (var kv in item.Valuation) + { + Assert.That(kv.Value, Is.EqualTo(kv.Key.Eval(item.StateGraphNode.State))); + } + } + } + + [Test] + public void GetTraceString_IncludesValuation() + { + var s0 = MakeNode(new TestState("s0", 1)); + var s1 = MakeNode(new TestState("s1", 0)); + AddEdge(s0, s1); + AddEdge(s1, s1); + + var p = Prop("val>0", s => ((TestState)s).Value > 0); + var result = SymbolicLtlCheck.Check(s0, G(Atom(p))); + + Assert.That(result.Valid, Is.False); + var text = result.GetTraceString(); + Assert.That(text, Does.Contain("val>0"), + "rendered trace should mention the atom (possibly negated)"); + Assert.That(text, Does.Contain("=true").Or.Contain("=false"), + "rendered trace should include a boolean valuation"); + } + + [Test] + public void ExplicitBackend_TraceItemsHaveNullValuation() + { + // Sanity: the non-symbolic Check path leaves Valuation = null. + var s0 = MakeNode(new TestState("s0", 1)); + var item = new TraceItem(null, s0); + Assert.That(item.Valuation, Is.Null); + } + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermAlgebraPropositionTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermAlgebraPropositionTests.cs new file mode 100644 index 0000000..9ed9416 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermAlgebraPropositionTests.cs @@ -0,0 +1,129 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// EREQ Phase-1 (D5) tests: + /// must treat proposition splits (negative indices) as free + /// Booleans — no path-condition tightening, both branches always + /// reachable. + /// + [TestFixture] + public class TransitionTermAlgebraPropositionTests + { + private IntEba _eba; + private ConditionRegistry _registry; + private StringLeafAlgebra _leafAlgebra; + private TransitionTermAlgebra _algebra; + + private int _alphaIdx; + private int _pPropIdx; + private int _qPropIdx; + + [SetUp] + public void SetUp() + { + _eba = new IntEba(4); + _registry = new ConditionRegistry(); + _leafAlgebra = new StringLeafAlgebra(); + _algebra = new TransitionTermAlgebra(_eba, _registry, _leafAlgebra); + + _alphaIdx = _registry.Register(new IntPredicate("α", 0, 1)); + _pPropIdx = _registry.RegisterProposition("p"); + _qPropIdx = _registry.RegisterProposition("q"); + } + + [Test] + public void MkIte_PropositionSplit_DoesNotTouchEba() + { + // (p ? "hi" : "lo") - even with a non-default pathCondition, + // a proposition split must skip GetPredicate / IsSatisfiable + // entirely. We pass the universe predicate as pathCondition; + // the test passes if MkIte returns an ITE node (rather than + // collapsing to one branch via path cleaning). + var hi = _algebra.Leaf("hi"); + var lo = _algebra.Leaf("lo"); + var path = new IntPredicate("⊤", 0, 1, 2, 3); + + var result = _algebra.MkIte(_pPropIdx, hi, lo, path); + + Assert.IsFalse(result.IsLeaf, "Proposition split must not collapse via path cleaning."); + Assert.AreEqual(_pPropIdx, result.Level); + } + + [Test] + public void MkIte_PropositionSplit_StillCollapsesWhenBranchesEqual() + { + // Trivial condition elimination is independent of the path + // cleaning branch and must still fire. + var leaf = _algebra.Leaf("same"); + var result = _algebra.MkIte(_pPropIdx, leaf, leaf, default); + Assert.AreSame(leaf, result); + } + + [Test] + public void Apply_MixedPropositionAndPredicate_BothBranchesExplored() + { + // Build T1 = (α ? "a" : "b") and T2 = (p ? "x" : "y"). + // Apply with concat - the proposition layer of T2 must + // produce ITE nodes at level _pPropIdx with both branches + // present, while the α layer below still benefits from + // predicate path tracking. + var a = _algebra.Leaf("a"); + var b = _algebra.Leaf("b"); + var x = _algebra.Leaf("x"); + var y = _algebra.Leaf("y"); + + var t1 = _algebra.MkIte(_alphaIdx, a, b); + var t2 = _algebra.MkIte(_pPropIdx, x, y); + + var combined = _algebra.ApplyBinary(t1, t2, (l, r) => l + r, _eba.Top); + + // The outer level must be the proposition (more outer due to + // negative index < 0 == _alphaIdx). + Assert.IsFalse(combined.IsLeaf); + Assert.AreEqual(_pPropIdx, combined.Level); + + // Both branches present (no path collapse). + var ite = (TransitionTermIte)combined; + Assert.IsFalse(ite.Hi.Equals(ite.Lo)); + } + + [Test] + public void Apply_PropositionOnlySplit_BothBranchesReachable() + { + // T1 = (p ? "a" : "b"), T2 = (p ? "x" : "y"). + // Apply with concat: both branches of p must be explored, + // yielding (p ? "ax" : "by"). + var t1 = _algebra.MkIte(_pPropIdx, _algebra.Leaf("a"), _algebra.Leaf("b")); + var t2 = _algebra.MkIte(_pPropIdx, _algebra.Leaf("x"), _algebra.Leaf("y")); + + var combined = _algebra.ApplyBinary(t1, t2, (l, r) => l + r, _eba.Top); + + Assert.AreEqual(_pPropIdx, combined.Level); + var ite = (TransitionTermIte)combined; + Assert.IsTrue(ite.Hi.IsLeaf); + Assert.IsTrue(ite.Lo.IsLeaf); + Assert.AreEqual("ax", ((TransitionTermLeaf)ite.Hi).Value); + Assert.AreEqual("by", ((TransitionTermLeaf)ite.Lo).Value); + } + + [Test] + public void Apply_DistinctPropositions_OrderedByIndex() + { + // T1 = (p ? "a" : "b") with p = -1 + // T2 = (q ? "x" : "y") with q = -2 + // q is more negative, so should sort outermost (inner-larger + // ordering with negative indices). + var t1 = _algebra.MkIte(_pPropIdx, _algebra.Leaf("a"), _algebra.Leaf("b")); + var t2 = _algebra.MkIte(_qPropIdx, _algebra.Leaf("x"), _algebra.Leaf("y")); + + var combined = _algebra.ApplyBinary(t1, t2, (l, r) => l + r, _eba.Top); + + Assert.AreEqual(_qPropIdx, combined.Level, + "Outermost level should be the more-negative proposition index."); + } + } +} + diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermTests.cs new file mode 100644 index 0000000..8e11289 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionTermTests.cs @@ -0,0 +1,685 @@ +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using System.Linq; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + [TestFixture] + public class TransitionTermTests + { + private IntEba _eba; + private ConditionRegistry _registry; + private StringLeafAlgebra _leafAlgebra; + private TransitionTermAlgebra _algebra; + + // Conditions: α = {0,1}, β = {2,3}, in a universe of {0,1,2,3} + private int _alphaIdx; + private int _betaIdx; + + [SetUp] + public void SetUp() + { + _eba = new IntEba(4); + _registry = new ConditionRegistry(); + _leafAlgebra = new StringLeafAlgebra(); + _algebra = new TransitionTermAlgebra(_eba, _registry, _leafAlgebra); + + _alphaIdx = _registry.Register(new IntPredicate("α", 0, 1)); + _betaIdx = _registry.Register(new IntPredicate("β", 2, 3)); + } + + #region Leaf Tests + + [Test] + public void Leaf_CreatesLeafNode() + { + var leaf = TransitionTerm.Leaf("q0"); + Assert.IsTrue(leaf.IsLeaf); + Assert.AreEqual("q0", ((TransitionTermLeaf)leaf).Value); + } + + [Test] + public void Leaf_StructuralEquality() + { + var a = TransitionTerm.Leaf("q0"); + var b = TransitionTerm.Leaf("q0"); + var c = TransitionTerm.Leaf("q1"); + + Assert.AreEqual(a, b); + Assert.AreNotEqual(a, c); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + #endregion + + #region ITE Construction and Ordering + + [Test] + public void Ite_TrivialElimination_SameChildren() + { + // (α ? q0 : q0) → q0 + var leaf = TransitionTerm.Leaf("q0"); + var result = TransitionTerm.Ite(_alphaIdx, leaf, leaf); + + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual(leaf, result); + } + + [Test] + public void Ite_PreservesOrdering() + { + var hi = TransitionTerm.Leaf("q1"); + var lo = TransitionTerm.Leaf("q2"); + var ite = TransitionTerm.Ite(_alphaIdx, hi, lo); + + Assert.IsFalse(ite.IsLeaf); + Assert.AreEqual(_alphaIdx, ite.Level); + } + + [Test] + public void Ite_NestedOrdering_InnerMustHaveLargerIndex() + { + // Build (α ? (β ? q1 : q2) : q3) — valid because β > α + var inner = TransitionTerm.Ite(_betaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + var outer = TransitionTerm.Ite(_alphaIdx, + inner, + TransitionTerm.Leaf("q3")); + + Assert.AreEqual(_alphaIdx, outer.Level); + var outerIte = (TransitionTermIte)outer; + Assert.AreEqual(_betaIdx, outerIte.Hi.Level); + } + + [Test] + public void Ite_OrderingViolation_Throws() + { + // Try to build (β ? (α ? q1 : q2) : q3) — invalid because α < β + var inner = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + + Assert.Throws(() => + TransitionTerm.Ite(_betaIdx, inner, TransitionTerm.Leaf("q3"))); + } + + [Test] + public void Ite_StructuralEquality() + { + var a = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + var b = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + var c = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q3")); + + Assert.AreEqual(a, b); + Assert.AreNotEqual(a, c); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + #endregion + + #region Evaluation + + [Test] + public void Evaluate_Leaf_ReturnsValue() + { + var leaf = TransitionTerm.Leaf("q0"); + Assert.AreEqual("q0", leaf.Evaluate(0, _registry, _eba)); + Assert.AreEqual("q0", leaf.Evaluate(3, _registry, _eba)); + } + + [Test] + public void Evaluate_Ite_FollowsThenBranch() + { + // (α ? q1 : q2) where α = {0,1} + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + + // Element 0 satisfies α → q1 + Assert.AreEqual("q1", term.Evaluate(0, _registry, _eba)); + // Element 1 satisfies α → q1 + Assert.AreEqual("q1", term.Evaluate(1, _registry, _eba)); + } + + [Test] + public void Evaluate_Ite_FollowsElseBranch() + { + // (α ? q1 : q2) where α = {0,1} + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + + // Element 2 does not satisfy α → q2 + Assert.AreEqual("q2", term.Evaluate(2, _registry, _eba)); + // Element 3 does not satisfy α → q2 + Assert.AreEqual("q2", term.Evaluate(3, _registry, _eba)); + } + + [Test] + public void Evaluate_NestedIte_AllPaths() + { + // (α ? (β ? q1 : q2) : q3) where α={0,1}, β={2,3} + // Note: α∧β = ∅, so the inner β is only reachable when α is true + // but since α={0,1} and β={2,3} are disjoint, β is never satisfied under α + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Ite(_betaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")), + TransitionTerm.Leaf("q3")); + + // 0: α=true, β=false → q2 + Assert.AreEqual("q2", term.Evaluate(0, _registry, _eba)); + // 1: α=true, β=false → q2 + Assert.AreEqual("q2", term.Evaluate(1, _registry, _eba)); + // 2: α=false → q3 + Assert.AreEqual("q3", term.Evaluate(2, _registry, _eba)); + // 3: α=false → q3 + Assert.AreEqual("q3", term.Evaluate(3, _registry, _eba)); + } + + #endregion + + #region Traversal + + [Test] + public void GetLeaves_CollectsAllLeaves() + { + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + + var leaves = term.GetLeaves().ToList(); + Assert.AreEqual(2, leaves.Count); + Assert.Contains("q1", leaves); + Assert.Contains("q2", leaves); + } + + [Test] + public void GetDistinctLeaves_DeduplicatesSharedLeaves() + { + // (α ? q1 : (β ? q1 : q2)) — q1 appears twice + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Ite(_betaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2"))); + + var distinct = term.GetDistinctLeaves().ToList(); + Assert.AreEqual(2, distinct.Count); + Assert.Contains("q1", distinct); + Assert.Contains("q2", distinct); + } + + [Test] + public void GetDistinctConditionIndices() + { + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Ite(_betaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")), + TransitionTerm.Leaf("q3")); + + var indices = term.GetDistinctConditionIndices().ToList(); + Assert.AreEqual(2, indices.Count); + Assert.Contains(_alphaIdx, indices); + Assert.Contains(_betaIdx, indices); + } + + #endregion + } + + [TestFixture] + public class TransitionTermAlgebraTests + { + private IntEba _eba; + private ConditionRegistry _registry; + private StringLeafAlgebra _leafAlgebra; + private TransitionTermAlgebra _algebra; + + // Universe: {0,1,2,3} + // α = {0,1} (even-ish), β = {2,3} (odd-ish) + // α and β are complementary and disjoint + private int _alphaIdx; + private int _betaIdx; + + [SetUp] + public void SetUp() + { + _eba = new IntEba(4); + _registry = new ConditionRegistry(); + _leafAlgebra = new StringLeafAlgebra(); + _algebra = new TransitionTermAlgebra(_eba, _registry, _leafAlgebra); + + _alphaIdx = _registry.Register(new IntPredicate("α", 0, 1)); + _betaIdx = _registry.Register(new IntPredicate("β", 2, 3)); + } + + #region Smart Constructor (MkIte) + + [Test] + public void MkIte_TrivialElimination() + { + var leaf = _algebra.Leaf("q0"); + var result = _algebra.MkIte(_alphaIdx, leaf, leaf); + Assert.IsTrue(result.IsLeaf); + } + + [Test] + public void MkIte_PathConditionCleaning_ThenUnreachable() + { + // Path condition = ¬α (elements 2,3), condition = α (elements 0,1) + // α ∧ ¬α = ∅ → then-branch unreachable → returns lo + var notAlpha = _eba.Not(_registry.GetPredicate(_alphaIdx)); + var result = _algebra.MkIte(_alphaIdx, + _algebra.Leaf("q1"), + _algebra.Leaf("q2"), + notAlpha); + + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual("q2", ((TransitionTermLeaf)result).Value); + } + + [Test] + public void MkIte_PathConditionCleaning_ElseUnreachable() + { + // Path condition = α (elements 0,1), condition = α (elements 0,1) + // ¬α ∧ α = ∅ → else-branch unreachable → returns hi + var alpha = _registry.GetPredicate(_alphaIdx); + var result = _algebra.MkIte(_alphaIdx, + _algebra.Leaf("q1"), + _algebra.Leaf("q2"), + alpha); + + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual("q1", ((TransitionTermLeaf)result).Value); + } + + #endregion + + #region Or (Disjunction with ACI) + + [Test] + public void Or_BottomIsUnit() + { + // ⊥ ∨ f = f + var f = _algebra.Leaf("q0"); + var result = _algebra.Or(_algebra.Bottom, f); + Assert.AreEqual(f, result); + } + + [Test] + public void Or_TopIsZero() + { + // ⊤ ∨ f = ⊤ + var f = _algebra.Leaf("q0"); + var result = _algebra.Or(_algebra.Top, f); + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual("⊤", ((TransitionTermLeaf)result).Value); + } + + [Test] + public void Or_Idempotent() + { + // f ∨ f = f + var f = _algebra.Leaf("q0"); + var result = _algebra.Or(f, f); + Assert.AreEqual(f, result); + } + + [Test] + public void Or_LiftedIntoIte() + { + // (α ? q1 : q2) ∨ leaf("q3") + // = (α ? q1∨q3 : q2∨q3) + var left = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + var right = _algebra.Leaf("q3"); + + var result = _algebra.Or(left, right); + + // Evaluate at each element to verify semantics + Assert.AreEqual("q1∨q3", result.Evaluate(0, _registry, _eba)); // α=true + Assert.AreEqual("q2∨q3", result.Evaluate(2, _registry, _eba)); // α=false + } + + [Test] + public void Or_AciNormalization_Sorted() + { + // "b" ∨ "a" should produce "a∨b" (sorted) + var a = _algebra.Leaf("a"); + var b = _algebra.Leaf("b"); + var result = _algebra.Or(a, b); + + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual("a∨b", ((TransitionTermLeaf)result).Value); + } + + #endregion + + #region And (Conjunction with ACI) + + [Test] + public void And_TopIsUnit() + { + // ⊤ ∧ f = f + var f = _algebra.Leaf("q0"); + var result = _algebra.And(_algebra.Top, f); + Assert.AreEqual(f, result); + } + + [Test] + public void And_BottomIsZero() + { + // ⊥ ∧ f = ⊥ + var f = _algebra.Leaf("q0"); + var result = _algebra.And(_algebra.Bottom, f); + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual("⊥", ((TransitionTermLeaf)result).Value); + } + + [Test] + public void And_LiftedIntoIte() + { + // (α ? q1 : q2) ∧ leaf("q3") + // = (α ? q1∧q3 : q2∧q3) + var left = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + var right = _algebra.Leaf("q3"); + + var result = _algebra.And(left, right); + + Assert.AreEqual("q1∧q3", result.Evaluate(0, _registry, _eba)); // α=true + Assert.AreEqual("q2∧q3", result.Evaluate(2, _registry, _eba)); // α=false + } + + #endregion + + #region Not (Complement) + + [Test] + public void Not_TopBecomesBottom() + { + var result = _algebra.Not(_algebra.Top); + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual("⊥", ((TransitionTermLeaf)result).Value); + } + + [Test] + public void Not_BottomBecomesTop() + { + var result = _algebra.Not(_algebra.Bottom); + Assert.IsTrue(result.IsLeaf); + Assert.AreEqual("⊤", ((TransitionTermLeaf)result).Value); + } + + [Test] + public void Not_LiftedIntoIte() + { + // ¬(α ? q1 : q2) = (α ? ¬q1 : ¬q2) + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + + var result = _algebra.Not(term); + + Assert.AreEqual("¬q1", result.Evaluate(0, _registry, _eba)); + Assert.AreEqual("¬q2", result.Evaluate(2, _registry, _eba)); + } + + #endregion + + #region Apply with Cleaning (Example 3.1 from paper) + + [Test] + public void Apply_CleaningRemovesUnreachableBranch() + { + // From Example 3.1: α implies β (α ⊂ β) + // Setup: α={0}, β={0,1} in universe {0,1,2} + var eba3 = new IntEba(3); + var reg3 = new ConditionRegistry(); + var alg3 = new TransitionTermAlgebra(eba3, reg3, _leafAlgebra); + + var alphaSmall = reg3.Register(new IntPredicate("α", 0)); + var betaLarge = reg3.Register(new IntPredicate("β", 0, 1)); + + // ¬(α ? φ : ⊥) ∨ (β ? φ : ⊥) + // After cleaning: should simplify because when α is true, β is also true + var guardedAlpha = TransitionTerm.Ite(alphaSmall, + TransitionTerm.Leaf("φ"), + TransitionTerm.Leaf("⊥")); + var negated = alg3.Not(guardedAlpha); + var guardedBeta = TransitionTerm.Ite(betaLarge, + TransitionTerm.Leaf("φ"), + TransitionTerm.Leaf("⊥")); + + var result = alg3.Or(negated, guardedBeta); + + // For element 0: α=true, β=true → ¬φ ∨ φ (StringLeafAlgebra doesn't simplify complementation) + // For element 1: α=false, β=true → ⊤ ∨ φ = ⊤ + // For element 2: α=false, β=false → ⊤ ∨ ⊥ = ⊤ + Assert.AreEqual("¬φ∨φ", result.Evaluate(0, reg3, eba3)); + Assert.AreEqual("⊤", result.Evaluate(1, reg3, eba3)); + Assert.AreEqual("⊤", result.Evaluate(2, reg3, eba3)); + } + + #endregion + + #region Apply Merging Two ITEs + + [Test] + public void Apply_SameCondition_MergesBranches() + { + // (α ? a : b) ∨ (α ? c : d) = (α ? a∨c : b∨d) + var left = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("a"), + TransitionTerm.Leaf("b")); + var right = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("c"), + TransitionTerm.Leaf("d")); + + var result = _algebra.Or(left, right); + + Assert.AreEqual("a∨c", result.Evaluate(0, _registry, _eba)); + Assert.AreEqual("b∨d", result.Evaluate(2, _registry, _eba)); + } + + [Test] + public void Apply_DifferentConditions_SplitsOnSmaller() + { + // (α ? a : b) ∨ (β ? c : d) where α < β + // = (α ? (β ? a∨c : a∨d) : (β ? b∨c : b∨d)) + var left = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("a"), + TransitionTerm.Leaf("b")); + var right = TransitionTerm.Ite(_betaIdx, + TransitionTerm.Leaf("c"), + TransitionTerm.Leaf("d")); + + var result = _algebra.Or(left, right); + + // Verify all 4 combinations: + // 0: α=true, β=false → a∨d + Assert.AreEqual("a∨d", result.Evaluate(0, _registry, _eba)); + // 1: α=true, β=false → a∨d + Assert.AreEqual("a∨d", result.Evaluate(1, _registry, _eba)); + // 2: α=false, β=true → b∨c + Assert.AreEqual("b∨c", result.Evaluate(2, _registry, _eba)); + // 3: α=false, β=true → b∨c + Assert.AreEqual("b∨c", result.Evaluate(3, _registry, _eba)); + } + + #endregion + + #region DisjunctiveForm (Antimirov Normal Form) + + [Test] + public void DisjunctiveForm_EliminatesBottom() + { + var disjuncts = new[] + { + _algebra.Bottom, + _algebra.Leaf("q1"), + _algebra.Bottom, + _algebra.Leaf("q2") + }; + + var result = _algebra.DisjunctiveForm(disjuncts); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual("q1", ((TransitionTermLeaf)result[0]).Value); + Assert.AreEqual("q2", ((TransitionTermLeaf)result[1]).Value); + } + + [Test] + public void DisjunctiveForm_EliminatesDuplicates() + { + var q1 = _algebra.Leaf("q1"); + var disjuncts = new[] { q1, _algebra.Leaf("q2"), q1 }; + + var result = _algebra.DisjunctiveForm(disjuncts); + Assert.AreEqual(2, result.Count); + } + + [Test] + public void DisjunctiveForm_TopShortCircuits() + { + var disjuncts = new[] + { + _algebra.Leaf("q1"), + _algebra.Top, + _algebra.Leaf("q2") + }; + + var result = _algebra.DisjunctiveForm(disjuncts); + Assert.AreEqual(1, result.Count); + Assert.AreEqual("⊤", ((TransitionTermLeaf)result[0]).Value); + } + + [Test] + public void DisjunctiveForm_AllBottom_ReturnsBottom() + { + var disjuncts = new[] { _algebra.Bottom, _algebra.Bottom }; + var result = _algebra.DisjunctiveForm(disjuncts); + Assert.AreEqual(1, result.Count); + Assert.AreEqual("⊥", ((TransitionTermLeaf)result[0]).Value); + } + + #endregion + + #region MapUnary + + [Test] + public void MapUnary_TransformsLeaves() + { + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("q1"), + TransitionTerm.Leaf("q2")); + + var result = _algebra.MapUnary(term, s => s.ToUpper()); + + Assert.AreEqual("Q1", result.Evaluate(0, _registry, _eba)); + Assert.AreEqual("Q2", result.Evaluate(2, _registry, _eba)); + } + + [Test] + public void MapUnary_CrossType_ChangesLeafType() + { + var term = TransitionTerm.Ite(_alphaIdx, + TransitionTerm.Leaf("hello"), + TransitionTerm.Leaf("world")); + + TransitionTerm result = _algebra.MapUnary(term, s => s.Length); + + Assert.AreEqual(5, result.Evaluate(0, _registry, _eba)); + Assert.AreEqual(5, result.Evaluate(2, _registry, _eba)); + } + + #endregion + + #region Condition Registry + + [Test] + public void Registry_AssignsIncreasingIndices() + { + var reg = new ConditionRegistry(); + var i0 = reg.Register(new IntPredicate("a", 0)); + var i1 = reg.Register(new IntPredicate("b", 1)); + Assert.AreEqual(0, i0); + Assert.AreEqual(1, i1); + } + + [Test] + public void Registry_DeduplicatesSamePredicate() + { + var reg = new ConditionRegistry(); + var i0 = reg.Register(new IntPredicate("a", 0, 1)); + var i1 = reg.Register(new IntPredicate("a", 0, 1)); + Assert.AreEqual(i0, i1); + Assert.AreEqual(1, reg.Count); + } + + #endregion + + #region Leaf Algebra (StringLeafAlgebra) + + [Test] + public void LeafAlgebra_Or_ACI() + { + // Commutative: b∨a = a∨b + Assert.AreEqual("a∨b", _leafAlgebra.Or("b", "a")); + Assert.AreEqual("a∨b", _leafAlgebra.Or("a", "b")); + + // Idempotent: a∨a = a + Assert.AreEqual("a", _leafAlgebra.Or("a", "a")); + + // Unit: ⊥∨a = a + Assert.AreEqual("a", _leafAlgebra.Or("⊥", "a")); + Assert.AreEqual("a", _leafAlgebra.Or("a", "⊥")); + + // Zero: ⊤∨a = ⊤ + Assert.AreEqual("⊤", _leafAlgebra.Or("⊤", "a")); + } + + [Test] + public void LeafAlgebra_And_ACI() + { + // Commutative + Assert.AreEqual("a∧b", _leafAlgebra.And("b", "a")); + + // Idempotent + Assert.AreEqual("a", _leafAlgebra.And("a", "a")); + + // Unit: ⊤∧a = a + Assert.AreEqual("a", _leafAlgebra.And("⊤", "a")); + + // Zero: ⊥∧a = ⊥ + Assert.AreEqual("⊥", _leafAlgebra.And("⊥", "a")); + } + + [Test] + public void LeafAlgebra_Or_Associative() + { + // (a∨b)∨c = a∨b∨c + var ab = _leafAlgebra.Or("a", "b"); // "a∨b" + var abc = _leafAlgebra.Or(ab, "c"); // "a∨b∨c" + Assert.AreEqual("a∨b∨c", abc); + + // a∨(b∨c) = a∨b∨c + var bc = _leafAlgebra.Or("b", "c"); + var abc2 = _leafAlgebra.Or("a", bc); + Assert.AreEqual("a∨b∨c", abc2); + } + + #endregion + } +} diff --git a/nuget/Microsoft.Accordant.nuspec b/nuget/Microsoft.Accordant.nuspec index 268ba71..b60ae4b 100644 --- a/nuget/Microsoft.Accordant.nuspec +++ b/nuget/Microsoft.Accordant.nuspec @@ -32,6 +32,10 @@ + + + From 158273e637ecab10726db7f4cbd24f3756cf3fdd Mon Sep 17 00:00:00 2001 From: Immad Date: Fri, 7 Aug 2026 12:05:22 -0500 Subject: [PATCH 6/6] Add propositions over transitions (s, a, s') to LTL/RLTL checking Extend atomic propositions from p(state) to optionally observe the transition: p(state, action, state'). Adds Observe overloads for (s), (s, s'), and (s, a, s'), a reserved stutter action for self-loops, and enriches the atom eval interface with a transition context. The automata/SAT core is untouched; only the three product evaluators gained per-node transition-awareness detection and per-edge evaluation, with a byte-identical source-anchored fast path for state-only formulas. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Accordant.ModelChecking/Properties.cs | 44 ++++ .../Symbolic/NestedDfsCheck.cs | 56 ++++- .../Symbolic/SccProductCheck.cs | 49 +++- Accordant.ModelChecking/Symbolic/StateProp.cs | 87 ++++++- .../Symbolic/StutterAction.cs | 42 ++++ .../Symbolic/SymbolicLtlCheck.cs | 57 ++++- .../Symbolic/TransitionContext.cs | 78 +++++++ Accordant.ModelChecking/Transition.cs | 45 ++++ .../Symbolic/TransitionContextEvalTests.cs | 169 ++++++++++++++ .../TransitionPropositionTests.cs | 219 ++++++++++++++++++ 10 files changed, 810 insertions(+), 36 deletions(-) create mode 100644 Accordant.ModelChecking/Symbolic/StutterAction.cs create mode 100644 Accordant.ModelChecking/Symbolic/TransitionContext.cs create mode 100644 Accordant.ModelChecking/Transition.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionContextEvalTests.cs create mode 100644 Tests/Accordant.ModelChecking.Tests/TransitionPropositionTests.cs diff --git a/Accordant.ModelChecking/Properties.cs b/Accordant.ModelChecking/Properties.cs index 9b22a1c..8fa716c 100644 --- a/Accordant.ModelChecking/Properties.cs +++ b/Accordant.ModelChecking/Properties.cs @@ -43,6 +43,50 @@ public Observation Observe(Func predicate, string name) return new Observation(new StatePredAtom(prop)); } + /// + /// Define an atomic observation (proposition) over a transition, + /// inspecting both the source state s and the target state + /// s'. This is the two-argument overload of the general + /// p(s, a, s') form; the action is ignored. + /// + /// Transition predicate (s, s') => bool, + /// evaluated against concrete source/target states during model + /// checking. At a stutter self-loop s' equals s. + /// Display name for diagnostics and counterexample traces. + public Observation Observe(Func predicate, string name) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (name == null) throw new ArgumentNullException(nameof(name)); + var prop = StateProp.OverTransition( + name, + ctx => predicate((TState)ctx.From, (TState)ctx.To)); + return new Observation(new StatePredAtom(prop)); + } + + /// + /// Define an atomic observation (proposition) over a full transition + /// (s, a, s'): the source state s, the action a + /// that produced the transition (with its edge metadata), and the + /// target state s'. + /// + /// Transition predicate + /// (s, a, s') => bool, evaluated against concrete transitions + /// during model checking. At a stutter self-loop s' equals + /// s and is true. + /// Display name for diagnostics and counterexample traces. + public Observation Observe(Func predicate, string name) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + if (name == null) throw new ArgumentNullException(nameof(name)); + var prop = StateProp.OverTransition( + name, + ctx => predicate( + (TState)ctx.From, + new Transition(ctx.Action, ctx.Metadata), + (TState)ctx.To)); + return new Observation(new StatePredAtom(prop)); + } + #endregion #region Constants diff --git a/Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs b/Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs index 8155f3b..7147aea 100644 --- a/Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs +++ b/Accordant.ModelChecking/Symbolic/NestedDfsCheck.cs @@ -88,7 +88,12 @@ Node Intern(StateGraphNode sys, TNbwState q, int depth) IEnumerable> Successors(Node p) { var nbwTrans = nbw.GetTransition(p.NbwState); - var nbwSuccs = EvaluateNbwTransitions(nbwTrans, p.SystemNode.State, registry, nbwCmp); + var state = p.SystemNode.State; + + // GetTransition has now registered this node's guard predicates, + // so awareness reflects whether any of them inspect the action + // or target state. + var anyTransitionAware = AnyTransitionAware(registry); var edges = p.SystemNode.Edges; var atFrontier = (maxDepth > 0 && p.Depth >= maxDepth); @@ -96,13 +101,34 @@ IEnumerable> Successors(Node p) if (terminal || atFrontier) { - foreach (var q in nbwSuccs) + // Stutter self-loop letter: state --(stutter)--> state. + var stutterSuccs = EvaluateNbwTransitions( + nbwTrans, TransitionContext.Stutter(state), registry, nbwCmp); + foreach (var q in stutterSuccs) yield return new Successor(p.SystemNode, q, null); yield break; } + if (!anyTransitionAware) + { + // Fast path: no proposition inspects the action or target, + // so the NBW successors depend only on the source state. + // Evaluate once and reuse for every outgoing edge. + var nbwSuccs = EvaluateNbwTransitions( + nbwTrans, TransitionContext.Source(state), registry, nbwCmp); + foreach (var edge in edges) + foreach (var q in nbwSuccs) + yield return new Successor(edge.Target, q, edge.StepFunction); + yield break; + } + + // Transition-aware: evaluate guards per edge so propositions + // can observe the action and target state. foreach (var edge in edges) { + var ctx = TransitionContext.Edge( + state, edge.StepFunction, edge.Metadata, edge.Target.State); + var nbwSuccs = EvaluateNbwTransitions(nbwTrans, ctx, registry, nbwCmp); foreach (var q in nbwSuccs) yield return new Successor(edge.Target, q, edge.StepFunction); } @@ -311,14 +337,14 @@ private static List BuildCounterexample( /// private static HashSet EvaluateNbwTransitions( IReadOnlyList>> transitions, - IState systemState, + in TransitionContext ctx, ConditionRegistry registry, IEqualityComparer comparer) { var result = new HashSet(comparer); foreach (var term in transitions) { - var leaf = EvaluateTerm(term, systemState, registry); + var leaf = EvaluateTerm(term, in ctx, registry); if (leaf != null) foreach (var s in leaf) result.Add(s); @@ -327,12 +353,12 @@ private static HashSet EvaluateNbwTransitions( } /// - /// Walks an ITE transition term against a concrete state, following - /// the unique path to a leaf. + /// Walks an ITE transition term against a concrete transition letter, + /// following the unique path to a leaf. /// private static StateSet EvaluateTerm( TransitionTerm> term, - IState systemState, + in TransitionContext ctx, ConditionRegistry registry) { while (true) @@ -341,10 +367,24 @@ private static StateSet EvaluateTerm( return leaf.Value; var ite = (TransitionTermIte>)term; var pred = registry.GetPredicate(ite.ConditionIndex); - term = pred.Eval(systemState) ? ite.Hi : ite.Lo; + term = pred.Eval(in ctx) ? ite.Hi : ite.Lo; } } + /// + /// Returns true if any predicate registered with + /// inspects the action or target state of + /// a transition. When false, guard evaluation depends only on + /// the source state and can be hoisted to once per node. + /// + internal static bool AnyTransitionAware(ConditionRegistry registry) + { + foreach (var p in registry.Predicates) + if (p.IsTransitionAware) + return true; + return false; + } + private static string MakeKey(StateGraphNode sys, TNbwState q) => $"{sys.GetNodeFingerprint()}|{q?.GetHashCode():X8}|{q}"; diff --git a/Accordant.ModelChecking/Symbolic/SccProductCheck.cs b/Accordant.ModelChecking/Symbolic/SccProductCheck.cs index c379d30..59ecede 100644 --- a/Accordant.ModelChecking/Symbolic/SccProductCheck.cs +++ b/Accordant.ModelChecking/Symbolic/SccProductCheck.cs @@ -96,7 +96,8 @@ ProductNode GetOrCreate( // frontier handling. var frontierNbw = nbw.GetTransition(current.NbwState); var frontierSuccs = EvaluateNbwTransitions( - frontierNbw, current.SystemNode.State, registry, nbwStateComparer); + frontierNbw, TransitionContext.Stutter(current.SystemNode.State), + registry, nbwStateComparer); foreach (var succNbw in frontierSuccs) { var succ = GetOrCreate( @@ -110,10 +111,14 @@ ProductNode GetOrCreate( var sysNode = current.SystemNode; var sysEdges = sysNode.Edges; + // GetTransition has registered this node's guard predicates. + var anyTransitionAware = NestedDfsCheck.AnyTransitionAware(registry); + if (sysEdges == null || sysEdges.Count == 0) { var stutterSuccs = EvaluateNbwTransitions( - nbwTrans, sysNode.State, registry, nbwStateComparer); + nbwTrans, TransitionContext.Stutter(sysNode.State), + registry, nbwStateComparer); foreach (var succNbw in stutterSuccs) { var succ = GetOrCreate(sysNode, succNbw, current.Depth + 1, null, current); @@ -122,14 +127,36 @@ ProductNode GetOrCreate( continue; } - // NBW transitions depend only on the source system state, - // so evaluate once and reuse for all outgoing edges. - var nbwSuccsAll = EvaluateNbwTransitions( - nbwTrans, sysNode.State, registry, nbwStateComparer); + if (!anyTransitionAware) + { + // Fast path: NBW successors depend only on the source + // system state, so evaluate once and reuse for all edges. + var nbwSuccsAll = EvaluateNbwTransitions( + nbwTrans, TransitionContext.Source(sysNode.State), + registry, nbwStateComparer); + + foreach (var edge in sysEdges) + { + foreach (var succNbw in nbwSuccsAll) + { + var succ = GetOrCreate( + edge.Target, succNbw, current.Depth + 1, edge.StepFunction, current); + current.Successors.Add( + new ProductEdge(edge.StepFunction, succ)); + } + } + continue; + } + // Transition-aware: evaluate guards per edge so propositions + // can observe the action and target state. foreach (var edge in sysEdges) { - foreach (var succNbw in nbwSuccsAll) + var ctx = TransitionContext.Edge( + sysNode.State, edge.StepFunction, edge.Metadata, edge.Target.State); + var nbwSuccs = EvaluateNbwTransitions( + nbwTrans, ctx, registry, nbwStateComparer); + foreach (var succNbw in nbwSuccs) { var succ = GetOrCreate( edge.Target, succNbw, current.Depth + 1, edge.StepFunction, current); @@ -166,14 +193,14 @@ ProductNode GetOrCreate( private static HashSet EvaluateNbwTransitions( IReadOnlyList>> transitions, - IState systemState, + in TransitionContext ctx, ConditionRegistry registry, IEqualityComparer cmp) { var result = new HashSet(cmp); foreach (var term in transitions) { - var leaf = EvaluateTerm(term, systemState, registry); + var leaf = EvaluateTerm(term, in ctx, registry); if (leaf == null) continue; foreach (var s in leaf) result.Add(s); } @@ -182,7 +209,7 @@ private static HashSet EvaluateNbwTransitions( private static StateSet EvaluateTerm( TransitionTerm> term, - IState systemState, + in TransitionContext ctx, ConditionRegistry registry) { while (true) @@ -191,7 +218,7 @@ private static StateSet EvaluateTerm( return leaf.Value; var ite = (TransitionTermIte>)term; var pred = registry.GetPredicate(ite.ConditionIndex); - term = pred.Eval(systemState) ? ite.Hi : ite.Lo; + term = pred.Eval(in ctx) ? ite.Hi : ite.Lo; } } diff --git a/Accordant.ModelChecking/Symbolic/StateProp.cs b/Accordant.ModelChecking/Symbolic/StateProp.cs index 41d4b30..619536d 100644 --- a/Accordant.ModelChecking/Symbolic/StateProp.cs +++ b/Accordant.ModelChecking/Symbolic/StateProp.cs @@ -3,12 +3,21 @@ namespace Microsoft.Accordant.ModelChecking.Symbolic using System; using System.Collections.Generic; using System.Linq; + using Microsoft.Accordant; /// - /// A named atomic proposition over model program states. + /// A named atomic proposition over model program transitions. /// Each proposition has a unique integer Id for identity/ordering - /// and a Name for display. The evaluation function tests the proposition - /// against a concrete . + /// and a Name for display. + /// + /// The general evaluation function + /// tests the proposition against a full transition letter + /// (s, a, s') (see ). State-only + /// propositions p(s) read only the source state and are reported by + /// == false, which lets the product + /// evaluators keep the historical once-per-node fast path. The + /// callback is the state-only view, used by the SAT + /// decision procedure and counterexample valuation. /// public sealed class StateProp : IEquatable, IComparable { @@ -20,16 +29,61 @@ public sealed class StateProp : IEquatable, IComparable /// Display name. public string Name { get; } - /// Evaluation: tests whether the proposition holds in a state. + /// + /// State-only view: tests whether the proposition holds given only a + /// source state. For transition-aware propositions this evaluates the + /// proposition against the stutter self-loop at the state + /// (from == to, stutter action). + /// public Func Evaluate { get; } + /// + /// General evaluation: tests whether the proposition holds for a full + /// transition letter (s, a, s'). + /// + public Func EvaluateTransition { get; } + + /// + /// true when the proposition may inspect the action or the + /// target state (i.e. it was defined as p(s, s') or + /// p(s, a, s')). When false the proposition depends only + /// on the source state and the evaluators may evaluate it once per + /// node rather than once per edge. + /// + public bool IsTransitionAware { get; } + + /// + /// Creates a state-only proposition p(s). + /// public StateProp(string name, Func evaluate) { + if (evaluate == null) throw new ArgumentNullException(nameof(evaluate)); + Id = System.Threading.Interlocked.Increment(ref _nextId); + Name = name ?? throw new ArgumentNullException(nameof(name)); + Evaluate = evaluate; + EvaluateTransition = ctx => evaluate((State)ctx.From); + IsTransitionAware = false; + } + + private StateProp(string name, Func evaluateTransition, bool _) + { + if (evaluateTransition == null) throw new ArgumentNullException(nameof(evaluateTransition)); Id = System.Threading.Interlocked.Increment(ref _nextId); Name = name ?? throw new ArgumentNullException(nameof(name)); - Evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate)); + EvaluateTransition = evaluateTransition; + Evaluate = state => evaluateTransition(TransitionContext.Stutter(state)); + IsTransitionAware = true; } + /// + /// Creates a transition-aware proposition p(s, a, s'). The + /// state-only view () evaluates the proposition + /// against the stutter self-loop at the given state. + /// + public static StateProp OverTransition( + string name, Func evaluateTransition) + => new StateProp(name, evaluateTransition, false); + public bool Equals(StateProp other) => other != null && Id == other.Id; public override bool Equals(object obj) => Equals(obj as StateProp); public override int GetHashCode() => Id; @@ -228,13 +282,26 @@ public bool Models(State element, IStatePredicate predicate) /// public interface IStatePredicate : IEquatable { + /// State-only evaluation (source state). For + /// transition-aware predicates this is the stutter self-loop view. bool Eval(IState state); + + /// General evaluation over a full transition letter. + bool Eval(in TransitionContext ctx); + + /// + /// true when this predicate (transitively) inspects the action + /// or target state of a transition. + /// + bool IsTransitionAware { get; } } public sealed class StatePredTrue : IStatePredicate { public static readonly StatePredTrue Instance = new StatePredTrue(); public bool Eval(IState state) => true; + public bool Eval(in TransitionContext ctx) => true; + public bool IsTransitionAware => false; public bool Equals(IStatePredicate other) => other is StatePredTrue; public override bool Equals(object obj) => obj is StatePredTrue; public override int GetHashCode() => 1; @@ -245,6 +312,8 @@ public sealed class StatePredFalse : IStatePredicate { public static readonly StatePredFalse Instance = new StatePredFalse(); public bool Eval(IState state) => false; + public bool Eval(in TransitionContext ctx) => false; + public bool IsTransitionAware => false; public bool Equals(IStatePredicate other) => other is StatePredFalse; public override bool Equals(object obj) => obj is StatePredFalse; public override int GetHashCode() => 0; @@ -256,6 +325,8 @@ public sealed class StatePredAtom : IStatePredicate public StateProp Prop { get; } public StatePredAtom(StateProp prop) { Prop = prop; } public bool Eval(IState state) => Prop.Evaluate((State)state); + public bool Eval(in TransitionContext ctx) => Prop.EvaluateTransition(ctx); + public bool IsTransitionAware => Prop.IsTransitionAware; public bool Equals(IStatePredicate other) => other is StatePredAtom a && Prop.Id == a.Prop.Id; public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); @@ -268,6 +339,8 @@ public sealed class StatePredNot : IStatePredicate public IStatePredicate Inner { get; } public StatePredNot(IStatePredicate inner) { Inner = inner; } public bool Eval(IState state) => !Inner.Eval(state); + public bool Eval(in TransitionContext ctx) => !Inner.Eval(in ctx); + public bool IsTransitionAware => Inner.IsTransitionAware; public bool Equals(IStatePredicate other) => other is StatePredNot n && Inner.Equals(n.Inner); public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); @@ -282,6 +355,8 @@ public sealed class StatePredAnd : IStatePredicate public StatePredAnd(IStatePredicate left, IStatePredicate right) { Left = left; Right = right; } public bool Eval(IState state) => Left.Eval(state) && Right.Eval(state); + public bool Eval(in TransitionContext ctx) => Left.Eval(in ctx) && Right.Eval(in ctx); + public bool IsTransitionAware => Left.IsTransitionAware || Right.IsTransitionAware; public bool Equals(IStatePredicate other) => other is StatePredAnd a && Left.Equals(a.Left) && Right.Equals(a.Right); public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); @@ -299,6 +374,8 @@ public sealed class StatePredOr : IStatePredicate public StatePredOr(IStatePredicate left, IStatePredicate right) { Left = left; Right = right; } public bool Eval(IState state) => Left.Eval(state) || Right.Eval(state); + public bool Eval(in TransitionContext ctx) => Left.Eval(in ctx) || Right.Eval(in ctx); + public bool IsTransitionAware => Left.IsTransitionAware || Right.IsTransitionAware; public bool Equals(IStatePredicate other) => other is StatePredOr o && Left.Equals(o.Left) && Right.Equals(o.Right); public override bool Equals(object obj) => obj is IStatePredicate p && Equals(p); diff --git a/Accordant.ModelChecking/Symbolic/StutterAction.cs b/Accordant.ModelChecking/Symbolic/StutterAction.cs new file mode 100644 index 0000000..5b9207b --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/StutterAction.cs @@ -0,0 +1,42 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant; + + /// + /// The reserved "stutter" action. A stutter self-loop leaves the system + /// state unchanged; it is emitted at terminal and depth-frontier nodes so + /// that every run of the product automaton is infinite (a requirement for + /// ω-acceptance). + /// + /// Before propositions over transitions were supported, stutter + /// self-loops carried a null step function. With + /// p(s, a, s') propositions, the action must be observable, so the + /// stutter self-loop is labelled with this singleton. A proposition can + /// detect it via . + /// + /// This step function is a pure label: it is never applied by the + /// explorer, and returns no successors. + /// + public sealed class StutterAction : IStepFunction + { + /// The shared stutter-action singleton. + public static readonly StutterAction Instance = new StutterAction(); + + private StutterAction() { } + + /// Stable identifier for the stutter action. + public string StepFunctionId => "__accordant_stutter__"; + + /// + /// The stutter action is a label only and is never applied; this + /// returns no successors. + /// + public IList Apply( + IState state, + IReadOnlyList<(IStepFunction, StateGraphNode)> path) + => new List(); + + public override string ToString() => "stutter"; + } +} diff --git a/Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs b/Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs index c02535d..85a3e46 100644 --- a/Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs +++ b/Accordant.ModelChecking/Symbolic/SymbolicLtlCheck.cs @@ -151,7 +151,8 @@ private static PropertyCheckingResult ExploreProduct( // frontier handling. var nbwTransitionsFr = nbw.GetTransition(current.NbwState); var frontierSuccs = EvaluateNbwTransitions( - nbwTransitionsFr, current.SystemNode.State, registry); + nbwTransitionsFr, TransitionContext.Stutter(current.SystemNode.State), + registry); foreach (var succNbwState in frontierSuccs) { var succKey = MakeProductKey(current.SystemNode, succNbwState); @@ -174,13 +175,16 @@ private static PropertyCheckingResult ExploreProduct( // Get NBW transitions for current NBW state var nbwTransitions = nbw.GetTransition(nbwState); + // GetTransition has registered this node's guard predicates. + var anyTransitionAware = NestedDfsCheck.AnyTransitionAware(registry); + // If system node is terminal (no outgoing edges): stutter self-loop var sysEdges = sysNode.Edges; if (sysEdges == null || sysEdges.Count == 0) { // Stutter: stay in same system state, advance NBW var successorNbwStates = EvaluateNbwTransitions( - nbwTransitions, sysNode.State, registry); + nbwTransitions, TransitionContext.Stutter(sysNode.State), registry); foreach (var succNbwState in successorNbwStates) { @@ -198,15 +202,44 @@ private static PropertyCheckingResult ExploreProduct( continue; } - // Normal transitions + if (!anyTransitionAware) + { + // Fast path: NBW successors depend only on the source + // system state (the label is consumed at the source), so + // evaluate once and reuse for every outgoing edge. + var successorNbwStates = EvaluateNbwTransitions( + nbwTransitions, TransitionContext.Source(sysNode.State), registry); + + foreach (var edge in sysEdges) + { + var nextSysNode = edge.Target; + foreach (var succNbwState in successorNbwStates) + { + var succKey = MakeProductKey(nextSysNode, succNbwState); + if (!productNodes.TryGetValue(succKey, out var succInfo)) + { + succInfo = new ProductNodeInfo( + nextSysNode, succNbwState, succKey, current.Depth + 1, + edge.StepFunction, current); + productNodes[succKey] = succInfo; + worklist.Enqueue(succInfo); + } + current.Successors.Add(succInfo); + } + } + continue; + } + + // Transition-aware: evaluate guards per edge so propositions + // can observe the action and target state. foreach (var edge in sysEdges) { var nextSysNode = edge.Target; - // Evaluate NBW transitions against the CURRENT system state - // (the label is consumed at the source) + var ctx = TransitionContext.Edge( + sysNode.State, edge.StepFunction, edge.Metadata, nextSysNode.State); var successorNbwStates = EvaluateNbwTransitions( - nbwTransitions, sysNode.State, registry); + nbwTransitions, ctx, registry); foreach (var succNbwState in successorNbwStates) { @@ -252,7 +285,7 @@ private static PropertyCheckingResult ExploreProduct( /// private static HashSet>> EvaluateNbwTransitions( IReadOnlyList>>>> transitions, - IState systemState, + in TransitionContext ctx, ConditionRegistry registry) { var result = new HashSet>>( @@ -260,7 +293,7 @@ private static HashSet>> EvaluateNbwTransit foreach (var term in transitions) { - var successorSet = EvaluateTerm(term, systemState, registry); + var successorSet = EvaluateTerm(term, in ctx, registry); if (successorSet != null) { foreach (var s in successorSet) @@ -272,12 +305,12 @@ private static HashSet>> EvaluateNbwTransit } /// - /// Evaluates a single transition term (ADD) against a concrete state, - /// following the unique path through the ITE tree. + /// Evaluates a single transition term (ADD) against a concrete + /// transition letter, following the unique path through the ITE tree. /// private static StateSet>> EvaluateTerm( TransitionTerm>>> term, - IState systemState, + in TransitionContext ctx, ConditionRegistry registry) { while (true) @@ -287,7 +320,7 @@ private static StateSet>> EvaluateTerm( var ite = (TransitionTermIte>>>)term; var pred = registry.GetPredicate(ite.ConditionIndex); - term = pred.Eval(systemState) ? ite.Hi : ite.Lo; + term = pred.Eval(in ctx) ? ite.Hi : ite.Lo; } } diff --git a/Accordant.ModelChecking/Symbolic/TransitionContext.cs b/Accordant.ModelChecking/Symbolic/TransitionContext.cs new file mode 100644 index 0000000..9696ea5 --- /dev/null +++ b/Accordant.ModelChecking/Symbolic/TransitionContext.cs @@ -0,0 +1,78 @@ +namespace Microsoft.Accordant.ModelChecking.Symbolic +{ + using Microsoft.Accordant; + + /// + /// The concrete "letter" presented to a proposition during model checking. + /// + /// Historically a proposition was evaluated against a single + /// (the source state of a transition). To support + /// propositions over transitions — p(s, a, s') — the evaluation + /// context now carries the full transition triple: the source state + /// (), the action that produced the transition + /// () together with its edge , + /// and the target state (). + /// + /// State-only propositions p(s) simply read + /// and ignore the rest, so they behave identically to before. A stutter + /// self-loop (used at terminal and depth-frontier nodes) is represented by + /// , where == and + /// is the reserved . + /// + public readonly struct TransitionContext + { + /// The source state s of the transition. + public IState From { get; } + + /// + /// The action a that produced the transition. This is the + /// system annotated on the edge, or the + /// reserved singleton for stutter + /// self-loops. May be null in the source-anchored fast path + /// used when no proposition inspects the action (see + /// ). + /// + public IStepFunction Action { get; } + + /// The edge metadata associated with , if any. + public object Metadata { get; } + + /// The target state s' of the transition. + public IState To { get; } + + public TransitionContext(IState from, IStepFunction action, object metadata, IState to) + { + From = from; + Action = action; + Metadata = metadata; + To = to; + } + + /// + /// A full transition letter for a concrete edge + /// from --(action)--> to. + /// + public static TransitionContext Edge(IState from, IStepFunction action, object metadata, IState to) + => new TransitionContext(from, action, metadata, to); + + /// + /// A stutter self-loop letter at : + /// state --(stutter)--> state. Used at terminal and + /// depth-frontier nodes. + /// + public static TransitionContext Stutter(IState state) + => new TransitionContext(state, StutterAction.Instance, null, state); + + /// + /// A source-anchored letter used by the fast path when no proposition + /// inspects the action or target. and + /// are both and + /// is null; state-only propositions read + /// only , so this is behaviourally identical to the + /// historical single-state evaluation while avoiding fabricating an + /// action or target. + /// + public static TransitionContext Source(IState state) + => new TransitionContext(state, null, null, state); + } +} diff --git a/Accordant.ModelChecking/Transition.cs b/Accordant.ModelChecking/Transition.cs new file mode 100644 index 0000000..b0e196f --- /dev/null +++ b/Accordant.ModelChecking/Transition.cs @@ -0,0 +1,45 @@ +namespace Microsoft.Accordant.ModelChecking +{ + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + + /// + /// The transition (action) presented to a proposition of the form + /// p(s, a, s'). It exposes the that + /// produced the transition together with any edge . + /// + /// For the stutter self-loop emitted at terminal and depth-frontier + /// nodes, is true and + /// is the reserved singleton. + /// + public sealed class Transition + { + internal Transition(IStepFunction action, object metadata) + { + Action = action; + Metadata = metadata; + } + + /// + /// The step function that produced this transition, or the reserved + /// stutter action for a stutter self-loop. May be null only in + /// contexts where no proposition inspects the action. + /// + public IStepFunction Action { get; } + + /// The edge metadata associated with , if any. + public object Metadata { get; } + + /// + /// The identifier of the underlying step function, or null when + /// is null. + /// + public string ActionId => Action?.StepFunctionId; + + /// + /// true when this transition is a stutter self-loop (the system + /// state does not change). + /// + public bool IsStutter => Action is StutterAction; + } +} diff --git a/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionContextEvalTests.cs b/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionContextEvalTests.cs new file mode 100644 index 0000000..b4b8b4e --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/Symbolic/TransitionContextEvalTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Accordant.ModelChecking.Tests.Symbolic +{ + using System.Collections.Generic; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking.Symbolic; + using NUnit.Framework; + + /// + /// Unit tests for the transition-letter evaluation primitives that back + /// propositions over transitions: , + /// , the transition-aware + /// constructor, and the + /// flag propagated through + /// the Boolean combinators. + /// + [TestFixture] + public class TransitionContextEvalTests + { + private sealed class TestState : State + { + public int V { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + => clonedMap[this] = new TestState { V = this.V }; + + protected override string StringRepresentationInternal( + Dictionary objectPaths, string path, bool forceRecompute) + => $"V={this.V}"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + private sealed class NamedStep : IStepFunction + { + public NamedStep(string id) { StepFunctionId = id; } + public string StepFunctionId { get; } + public IList Apply( + IState state, IReadOnlyList<(IStepFunction, StateGraphNode)> path) + => new List(); + } + + private static TestState S(int v) => new TestState { V = v }; + + [Test] + public void StateOnlyProp_IsNotTransitionAware_AndReadsSourceOnly() + { + var prop = new StateProp("v1", s => ((TestState)s).V == 1); + var atom = new StatePredAtom(prop); + + Assert.That(atom.IsTransitionAware, Is.False); + + // Reads From only: true when From.V == 1 regardless of To. + var ctx = TransitionContext.Edge(S(1), new NamedStep("a"), null, S(2)); + Assert.That(atom.Eval(in ctx), Is.True); + + var ctx2 = TransitionContext.Edge(S(2), new NamedStep("a"), null, S(1)); + Assert.That(atom.Eval(in ctx2), Is.False); + + // State-only Eval overload agrees. + Assert.That(atom.Eval(S(1)), Is.True); + Assert.That(atom.Eval(S(2)), Is.False); + } + + [Test] + public void TransitionProp_IsTransitionAware_AndReadsTargetAndAction() + { + var incBy1 = StateProp.OverTransition( + "inc1", + ctx => ((TestState)ctx.To).V == ((TestState)ctx.From).V + 1); + var atom = new StatePredAtom(incBy1); + + Assert.That(atom.IsTransitionAware, Is.True); + + var good = TransitionContext.Edge(S(1), new NamedStep("a"), null, S(2)); + Assert.That(atom.Eval(in good), Is.True); + + var bad = TransitionContext.Edge(S(1), new NamedStep("a"), null, S(1)); + Assert.That(atom.Eval(in bad), Is.False); + } + + [Test] + public void ActionProp_ObservesStepFunctionId_AndMetadata() + { + var isDec = StateProp.OverTransition( + "isDec", + ctx => ctx.Action?.StepFunctionId == "Decrement"); + var atom = new StatePredAtom(isDec); + + var dec = TransitionContext.Edge(S(3), new NamedStep("Decrement"), "dec", S(2)); + var inc = TransitionContext.Edge(S(2), new NamedStep("Increment"), "inc", S(3)); + + Assert.That(atom.Eval(in dec), Is.True); + Assert.That(atom.Eval(in inc), Is.False); + } + + [Test] + public void StutterContext_UsesStutterAction_AndCollapsesState() + { + var stutterCtx = TransitionContext.Stutter(S(5)); + + Assert.That(stutterCtx.Action, Is.SameAs(StutterAction.Instance)); + Assert.That(((TestState)stutterCtx.From).V, Is.EqualTo(5)); + Assert.That(((TestState)stutterCtx.To).V, Is.EqualTo(5)); + + var isStutter = new StatePredAtom( + StateProp.OverTransition("stutter", ctx => ctx.Action is StutterAction)); + Assert.That(isStutter.Eval(in stutterCtx), Is.True); + + var edgeCtx = TransitionContext.Edge(S(1), new NamedStep("a"), null, S(2)); + Assert.That(isStutter.Eval(in edgeCtx), Is.False); + } + + [Test] + public void TransitionProp_StateOnlyView_IsTheStutterSelfLoop() + { + // p(s, s') := s' == s. Under the state-only view (stutter loop), + // s' == s always holds. + var idle = StateProp.OverTransition( + "idle", + ctx => ((TestState)ctx.To).V == ((TestState)ctx.From).V); + var atom = new StatePredAtom(idle); + + Assert.That(atom.Eval(S(7)), Is.True); + Assert.That(idle.Evaluate(S(7)), Is.True); + } + + [Test] + public void IsTransitionAware_PropagatesThroughCombinators() + { + var stateOnly = new StatePredAtom(new StateProp("s", s => true)); + var transition = new StatePredAtom( + StateProp.OverTransition("t", ctx => ((TestState)ctx.To).V == 0)); + + Assert.That(stateOnly.IsTransitionAware, Is.False); + Assert.That(transition.IsTransitionAware, Is.True); + + Assert.That(new StatePredNot(stateOnly).IsTransitionAware, Is.False); + Assert.That(new StatePredNot(transition).IsTransitionAware, Is.True); + + Assert.That(new StatePredAnd(stateOnly, stateOnly).IsTransitionAware, Is.False); + Assert.That(new StatePredAnd(stateOnly, transition).IsTransitionAware, Is.True); + Assert.That(new StatePredOr(transition, stateOnly).IsTransitionAware, Is.True); + + Assert.That(StatePredTrue.Instance.IsTransitionAware, Is.False); + Assert.That(StatePredFalse.Instance.IsTransitionAware, Is.False); + } + + [Test] + public void Combinators_EvaluateOverTransitionContext() + { + var toIsTwo = new StatePredAtom( + StateProp.OverTransition("to2", ctx => ((TestState)ctx.To).V == 2)); + var fromIsOne = new StatePredAtom( + StateProp.OverTransition("from1", ctx => ((TestState)ctx.From).V == 1)); + + var ctx = TransitionContext.Edge(S(1), new NamedStep("a"), null, S(2)); + + Assert.That(new StatePredAnd(fromIsOne, toIsTwo).Eval(in ctx), Is.True); + Assert.That(new StatePredNot(toIsTwo).Eval(in ctx), Is.False); + Assert.That(new StatePredOr(new StatePredNot(fromIsOne), toIsTwo).Eval(in ctx), Is.True); + } + } +} + diff --git a/Tests/Accordant.ModelChecking.Tests/TransitionPropositionTests.cs b/Tests/Accordant.ModelChecking.Tests/TransitionPropositionTests.cs new file mode 100644 index 0000000..109c6f5 --- /dev/null +++ b/Tests/Accordant.ModelChecking.Tests/TransitionPropositionTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Accordant.ModelChecking.Tests +{ + using System.Collections.Generic; + using Microsoft.Accordant; + using Microsoft.Accordant.ModelChecking; + using NUnit.Framework; + + /// + /// End-to-end tests for propositions over transitions p(s, a, s') + /// and its overloads p(s) / p(s, s'). Verifies that the + /// target state, the action (step function) and the edge metadata are all + /// observable by a proposition during model checking, that state-only + /// propositions remain byte-for-byte backward compatible, and that the + /// reserved stutter action is presented at terminal self-loops. + /// + [TestFixture] + public class TransitionPropositionTests + { + #region Counter model with per-edge metadata + + private sealed class CounterState : State + { + public int Count { get; set; } + + protected override void CloneInternal(Dictionary clonedMap) + => clonedMap[this] = new CounterState { Count = this.Count }; + + protected override string StringRepresentationInternal( + Dictionary objectPaths, string path, bool forceRecompute) + => $"Count={this.Count}"; + + protected override void FreezeComponents(HashSet visited) + { + } + } + + private sealed class IncrementStep : BaseStepFunction + { + private readonly int max; + public IncrementStep(int max) { this.max = max; } + public override string StepFunctionId => "Increment"; + + protected override IList ApplyInternal(IState state) + { + var cs = (CounterState)state; + if (cs.Count >= this.max) return null; + var next = (CounterState)cs.Clone(); + next.Count++; + return new[] + { + new StepResult + { + State = next, + StepFunctions = new IStepFunction[] { this }, + EdgeMetadata = "inc", + }, + }; + } + } + + private sealed class DecrementStep : BaseStepFunction + { + public override string StepFunctionId => "Decrement"; + + protected override IList ApplyInternal(IState state) + { + var cs = (CounterState)state; + if (cs.Count <= 0) return null; + var next = (CounterState)cs.Clone(); + next.Count--; + return new[] + { + new StepResult + { + State = next, + StepFunctions = new IStepFunction[] { this }, + EdgeMetadata = "dec", + }, + }; + } + } + + private static StateGraphNode IncrementOnly(int max) + => StateGraph.ExploreStateGraph( + new IStepFunction[] { new IncrementStep(max) }, + new CounterState { Count = 0 }); + + private static StateGraphNode IncrementAndDecrement(int max) + => StateGraph.ExploreStateGraph( + new IStepFunction[] { new IncrementStep(max), new DecrementStep() }, + new CounterState { Count = 0 }); + + #endregion + + #region Target-state propositions p(s, s') + + [Test] + public void TargetProposition_NonDecreasing_Holds_WhenOnlyIncrementing() + { + var root = IncrementOnly(max: 4); + var p = new Properties(); + var nonDecreasing = p.Observe((s, sp) => sp.Count >= s.Count, "NonDecreasing"); + + // Every real edge increments; the terminal stutter has s' == s, so + // the property holds everywhere. + var result = root.Check(p.Always(nonDecreasing)); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void TargetProposition_NonDecreasing_Fails_WhenDecrementPresent() + { + var root = IncrementAndDecrement(max: 3); + var p = new Properties(); + var nonDecreasing = p.Observe((s, sp) => sp.Count >= s.Count, "NonDecreasing"); + + // A decrement edge takes s' = s - 1 < s, violating the property. + var result = root.Check(p.Always(nonDecreasing)); + Assert.That(result.Valid, Is.False); + Assert.That(result.Trace, Is.Not.Null); + } + + #endregion + + #region Action propositions p(s, a, s') + + [Test] + public void ActionProposition_NoDecrementTaken_Holds_WhenOnlyIncrementing() + { + var root = IncrementOnly(max: 4); + var p = new Properties(); + var decrementTaken = p.Observe( + (s, a, sp) => a.ActionId == "Decrement", "DecrementTaken"); + + // No Decrement edge exists; the stutter action is not "Decrement". + var result = root.Check(p.Always(!decrementTaken)); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void ActionProposition_DetectsDecrement_Fails_WhenDecrementPresent() + { + var root = IncrementAndDecrement(max: 3); + var p = new Properties(); + var decrementTaken = p.Observe( + (s, a, sp) => a.ActionId == "Decrement", "DecrementTaken"); + + var result = root.Check(p.Always(!decrementTaken)); + Assert.That(result.Valid, Is.False); + Assert.That(result.Trace, Is.Not.Null); + } + + [Test] + public void ActionProposition_EdgeMetadata_IsObservable() + { + var p = new Properties(); + var notDecMetadata = p.Observe( + (s, a, sp) => (a.Metadata as string) != "dec", "NotDecMetadata"); + + // Increment edges carry "inc"; stutter carries null; neither is "dec". + var incOnly = IncrementOnly(max: 4).Check(p.Always(notDecMetadata)); + Assert.That(incOnly.Valid, Is.True); + + // The decrement edge carries "dec", violating the property. + var withDec = IncrementAndDecrement(max: 3).Check(p.Always(notDecMetadata)); + Assert.That(withDec.Valid, Is.False); + } + + [Test] + public void ActionProposition_StutterAction_IsPresentedAtTerminal() + { + // The increment-only model terminates at Count == max, where a + // stutter self-loop is emitted. A proposition that is true exactly + // on the stutter action must eventually hold on that model. + var root = IncrementOnly(max: 3); + var p = new Properties(); + var stutter = p.Observe((s, a, sp) => a.IsStutter, "Stutter"); + + var result = root.Check(p.Eventually(stutter)); + Assert.That(result.Valid, Is.True); + } + + #endregion + + #region Backward compatibility of state-only propositions p(s) + + [Test] + public void StateProposition_StillHolds_ForInBounds() + { + const int max = 3; + var root = IncrementAndDecrement(max); + var p = new Properties(); + var inBounds = p.Observe(s => s.Count >= 0 && s.Count <= max, "InBounds"); + + var result = root.Check(p.Always(inBounds)); + Assert.That(result.Valid, Is.True); + } + + [Test] + public void StateProposition_And_TransitionProposition_Compose() + { + var root = IncrementAndDecrement(max: 3); + var p = new Properties(); + var atZero = p.Observe(s => s.Count == 0, "AtZero"); + var decrementTaken = p.Observe( + (s, a, sp) => a.ActionId == "Decrement", "DecrementTaken"); + + // You cannot take a Decrement from Count == 0 (it is disabled), so + // "at zero AND decrementing" never happens. + var result = root.Check(p.Always(!(atZero & decrementTaken))); + Assert.That(result.Valid, Is.True); + } + + #endregion + } +}