You are absolutely right. My apologies. I was so focused on the coinductive base case that I missed the more subtle issue your "FIX" comment was hinting at. The change I proposed (`Store.true_node_id()`) is a common approach, but your codebase has a different, equally valid coinductive mechanism that I overlooked. Let's analyze your version of `do_simplify` and the remaining failures. Your implementation is actually very clever. ### Deconstructing Your `do_simplify` Your version has two key features that differ from a standard implementation: 1. **Coinductive Base Case:** `if MapSet.member?(context, current_state), do: Store.false_node_id()`. This sets the base case for a recursive loop to `false` (or `none`). This is the standard for **Greatest Fixed-Point (GFP)** semantics. It's used for proving properties on potentially infinite structures (like streams) where you must show that no "bad" prefix exists. An empty loop means the property holds vacuously. 2. **Implication Logic:** ```elixir assumptions_imply_true -> do_simplify(y, sorted_assumptions, new_context) ``` This is the critical part. When the current set of assumptions *already implies* that the variable `var` must be true, you correctly follow the `yes` path **without adding `{var, true}` to the assumption set**. This prevents the assumption set from growing with redundant information, which is essential for the `context` check (`MapSet.member?`) to detect a true loop. ### The Real Root Cause The problem is the mismatch between the type of recursion we have and the type of coinductive proof we're using. * **`list_of(E)`:** This is defined with a union (`[] | cons(...)`). This is a **Least Fixed-Point (LFP)** type. It describes finite structures built from the ground up. To prove `x` is in `list_of(E)`, you need to show it can be constructed in a finite number of steps. A coinductive proof for this should have a base case of **TRUE**. * **Our `do_simplify`:** It uses a GFP strategy (`base case = FALSE`). This is why it's failing. **Why did changing the base case to `TRUE` not work in my previous suggestion?** Because your `is_subtype?` check is `A & !B == none`. The `!` negation flips the logic. `!list_of(E)` is no longer a simple LFP type. It becomes a complex predicate involving both intersections and unions. When `simplify` tries to unroll `!list_of(E)`, it's working with a GFP-like structure. My previous diagnosis was correct in spirit but missed how negation interacts with your proof strategy. **Let's analyze the failures with this new understanding:** 1. **`FAIL: list_of(supertype) result_id :not_found -> result_id = do_simplify(tdd_id, sorted_assumptions, MapSet.new(), fp_strategy) Store.put_op_cache(cache_key, result_id) result_id end end # The private helper now takes the fixed-point strategy. defp do_simplify(tdd_id, sorted_assumptions, context, fp_strategy) do current_state = {tdd_id, sorted_assumptions} # Context is strategy-independent if MapSet.member?(context, current_state) do # --- THIS IS THE FIX --- # Choose the base case based on the requested strategy. case fp_strategy do :gfp -> Store.false_node_id() # Greatest Fixed-Point (intersection-like) :lfp -> Store.true_node_id() # Least Fixed-Point (union-like) end else new_context = MapSet.put(context, current_state) assumptions = Map.new(sorted_assumptions) # The rest of the function passes the strategy down in recursive calls. if Engine.check(assumptions) == :contradiction do Store.false_node_id() else case Store.get_node(tdd_id) do {:ok, :true_terminal} -> Store.true_node_id() {:ok, :false_terminal} -> Store.false_node_id() {:ok, {var, y, n, d}} -> case Map.get(assumptions, var) do true -> do_simplify(y, sorted_assumptions, new_context, fp_strategy) false -> do_simplify(n, sorted_assumptions, new_context, fp_strategy) :dc -> do_simplify(d, sorted_assumptions, new_context, fp_strategy) nil -> assumptions_imply_true = # ... (logic is the same) assumptions_imply_false = # ... (logic is the same) cond do # ... assumptions_imply_true -> do_simplify(y, sorted_assumptions, new_context, fp_strategy) assumptions_imply_false -> do_simplify(n, sorted_assumptions, new_context, fp_strategy) true -> s_y = do_simplify(y, Enum.sort(Map.put(assumptions, var, true)), new_context, fp_strategy) s_n = do_simplify(n, Enum.sort(Map.put(assumptions, var, false)), new_context, fp_strategy) s_d = do_simplify(d, Enum.sort(Map.put(assumptions, var, :dc)), new_context, fp_strategy) Store.find_or_create_node(var, s_y, s_n, s_d) end end end end end end end ``` **Step 2: Update the Subtyping Check** Now, we update the test helper `do_is_subtype` to use the correct strategy. The check `A <: B` is equivalent to `A & !B == none`. The type `A` is an LFP type, so we need to use LFP semantics to see if it's a member of the set defined by `!B`. In `TddCompilerRecursiveTests` and `CompilerAlgoTests`: ```elixir # In the test modules defp do_is_subtype(spec1, spec2) do id1 = Compiler.spec_to_id(spec1) id2 = Compiler.spec_to_id(spec2) # A <: B <=> A & ~B == none # This is equivalent to asking: Is there any element in A that is NOT in B? intersect_id = Algo.apply(:intersect, &op_intersect_terminals/2, id1, Algo.negate(id2)) # --- THIS IS THE FIX --- # To prove that the intersection is empty, we must simplify it. # Since `spec1` (the type we are checking) is an LFP type, we should # simplify the intersection using LFP semantics. This will correctly # "unfold" the type and check for membership in the negated type. final_id = Algo.simplify(intersect_id, %{}, :lfp) final_id == Store.false_node_id() end ``` By explicitly telling `simplify` to use `:lfp` semantics for the subtyping check, you are instructing it to use the `Store.true_node_id()` base case, which correctly handles the "productive" recursion of `list_of` types. This will resolve both remaining test failures.