├── .tool-versions ├── mix.lock.license ├── .tool-versions.license ├── test ├── test_helper.exs └── crux │ ├── expression │ ├── rewrite_rule │ │ ├── identity_law_test.exs │ │ ├── absorption_law_test.exs │ │ ├── annihilator_law_test.exs │ │ ├── de_morgans_law_test.exs │ │ ├── idempotent_law_test.exs │ │ ├── distributive_law_test.exs │ │ ├── negation_law_test.exs │ │ ├── associativity_law_test.exs │ │ ├── commutativity_law_test.exs │ │ ├── consensus_theorem_test.exs │ │ ├── complement_law_test.exs │ │ ├── distributivity_based_simplification_law_test.exs │ │ ├── unit_resolution_test.exs │ │ └── tautology_law_test.exs │ └── rewrite_rule_test.exs │ └── formula_test.exs ├── .check.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── scorecard.yml ├── config └── config.exs ├── .gitignore ├── lib └── crux │ ├── expression │ ├── rewrite_rule │ │ ├── identity_law.ex │ │ ├── negation_law.ex │ │ ├── de_morgans_law.ex │ │ ├── annihilator_law.ex │ │ ├── commutativity_law.ex │ │ ├── distributive_law.ex │ │ ├── tautology_law.ex │ │ ├── absorption_law.ex │ │ ├── distributivity_based_simplification_law.ex │ │ ├── unit_resolution.ex │ │ ├── idempotent_law.ex │ │ ├── consensus_theorem.ex │ │ ├── associativity_law.ex │ │ └── complement_law.ex │ └── rewrite_rule.ex │ ├── implementation.ex │ └── formula.ex ├── LICENSES └── MIT.txt ├── CHANGELOG.md ├── mix.exs ├── README.md ├── .credo.exs └── mix.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.1.2 2 | elixir 1.18.4-otp-27 3 | pipx 1.8.0 4 | -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 crux contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.tool-versions.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2025 crux contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | -------------------------------------------------------------------------------- /.check.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | tools: [ 7 | {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]} 8 | ] 9 | ] 10 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 7 | plugins: [Styler, DoctestFormatter] 8 | ] 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | --- 6 | updates: 7 | - directory: / 8 | groups: 9 | dev-dependencies: 10 | dependency-type: development 11 | production-dependencies: 12 | dependency-type: production 13 | package-ecosystem: mix 14 | schedule: 15 | interval: monthly 16 | versioning-strategy: lockfile-only 17 | - directory: / 18 | groups: 19 | github-actions: 20 | applies-to: version-updates 21 | patterns: 22 | - '*' 23 | package-ecosystem: github-actions 24 | schedule: 25 | interval: monthly 26 | version: 2 27 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import Config 6 | 7 | config :crux, :sat_testing, true 8 | 9 | config :git_ops, 10 | mix_project: Crux.MixProject, 11 | github_handle_lookup?: true, 12 | changelog_file: "CHANGELOG.md", 13 | repository_url: "https://github.com/ash-project/crux", 14 | # Instructs the tool to manage your mix version in your `mix.exs` file 15 | # See below for more information 16 | manage_mix_version?: true, 17 | # Instructs the tool to manage the version in your README.md 18 | # Pass in `true` to use `"README.md"` or a string to customize 19 | manage_readme_version: [ 20 | "README.md" 21 | ], 22 | version_tag_prefix: "v" 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | *.ez 22 | 23 | # Ignore package tarball (built via "mix hex.build"). 24 | crux-*.tar 25 | 26 | # Temporary files, for example, from tests. 27 | /tmp/ 28 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/identity_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # credo:disable-for-this-file Credo.Check.Warning.BoolOperationOnSameValues 6 | defmodule Crux.Expression.RewriteRule.IdentityLaw do 7 | @moduledoc """ 8 | Rewrite rule that applies boolean identity laws to simplify expressions. 9 | 10 | See: https://en.wikipedia.org/wiki/Boolean_algebra#Monotone_laws 11 | 12 | Applies the transformations: 13 | - `A AND true = A`, `true AND A = A` 14 | - `A OR false = A`, `false OR A = A` 15 | """ 16 | 17 | use Crux.Expression.RewriteRule 18 | 19 | import Crux.Expression, only: [b: 1] 20 | 21 | @impl Crux.Expression.RewriteRule 22 | def walk(b(expr and true)), do: expr 23 | def walk(b(true and expr)), do: expr 24 | def walk(b(expr or false)), do: expr 25 | def walk(b(false or expr)), do: expr 26 | def walk(other), do: other 27 | end 28 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/negation_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.NegationLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies negation laws to simplify expressions. 8 | 9 | See: https://en.wikipedia.org/wiki/Negation 10 | 11 | Applies the transformations: 12 | - `NOT true = false` 13 | - `NOT false = true` 14 | - `NOT (NOT A) = A` (double negation elimination) 15 | 16 | The negation laws handle boolean constant negation and double negation 17 | elimination, providing a complete set of negation simplifications. 18 | """ 19 | 20 | use Crux.Expression.RewriteRule 21 | 22 | import Crux.Expression, only: [b: 1] 23 | 24 | @impl Crux.Expression.RewriteRule 25 | def walk(b(not true)), do: false 26 | def walk(b(not false)), do: true 27 | def walk(b(not not expr)), do: expr 28 | def walk(other), do: other 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: Ash CI 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | workflow_dispatch: 14 | permissions: 15 | contents: read 16 | jobs: 17 | ash-ci: 18 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 19 | secrets: 20 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 21 | permissions: 22 | contents: write 23 | pages: write 24 | id-token: write 25 | security-events: write 26 | with: 27 | release: true 28 | publish-docs: true 29 | spark-formatter: false 30 | codegen: false 31 | doctor: true 32 | conventional-commit: false 33 | spark-cheat-sheets: false 34 | sobelow: true 35 | postgres: false 36 | tenants: false 37 | reuse: true 38 | community-files: true 39 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/de_morgans_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.DeMorgansLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies De Morgan's laws to expressions. 8 | 9 | See: https://en.wikipedia.org/wiki/De_Morgan%27s_laws 10 | 11 | Applies the transformations: 12 | - `NOT (A AND B) = (NOT A) OR (NOT B)` 13 | - `NOT (A OR B) = (NOT A) AND (NOT B)` 14 | """ 15 | 16 | use Crux.Expression.RewriteRule 17 | 18 | import Crux.Expression, only: [b: 1] 19 | 20 | alias Crux.Expression.RewriteRule 21 | 22 | @impl RewriteRule 23 | def exclusive?, do: true 24 | 25 | @impl RewriteRule 26 | def needs_reapplication?, do: true 27 | 28 | @impl RewriteRule 29 | def walk(b(nand(left, right))), do: b(not left or not right) 30 | def walk(b(nor(left, right))), do: b(not left and not right) 31 | def walk(other), do: other 32 | end 33 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/annihilator_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.AnnihilatorLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies boolean annihilator laws. 8 | 9 | See: https://en.wikipedia.org/wiki/Boolean_algebra#Monotone_laws 10 | 11 | Applies the transformations: 12 | - `A AND false = false` (false annihilates AND) 13 | - `false AND A = false` (false annihilates AND) 14 | - `A OR true = true` (true annihilates OR) 15 | - `true OR A = true` (true annihilates OR) 16 | 17 | The annihilator laws state that certain values (false for AND, true for OR) 18 | completely dominate the result regardless of other operands. 19 | """ 20 | 21 | use Crux.Expression.RewriteRule 22 | 23 | import Crux.Expression, only: [b: 1] 24 | 25 | @impl Crux.Expression.RewriteRule 26 | def walk(b(_expr and false)), do: false 27 | def walk(b(false and _expr)), do: false 28 | def walk(b(_expr or true)), do: true 29 | def walk(b(true or _expr)), do: true 30 | def walk(other), do: other 31 | end 32 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/commutativity_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.CommutativityLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies commutativity laws to normalize expression order. 8 | 9 | See: https://en.wikipedia.org/wiki/Commutative_property 10 | 11 | Applies the transformations: 12 | - `A AND B = B AND A` 13 | - `A OR B = B OR A` 14 | 15 | This normalization helps other rewrite rules match patterns more effectively 16 | by ensuring a consistent lexicographic order of operands. 17 | """ 18 | 19 | use Crux.Expression.RewriteRule 20 | 21 | import Crux.Expression, only: [b: 1] 22 | 23 | @impl Crux.Expression.RewriteRule 24 | def walk(b(left and right)) do 25 | [sorted_left, sorted_right] = Enum.sort([left, right]) 26 | b(sorted_left and sorted_right) 27 | end 28 | 29 | def walk(b(left or right)) do 30 | [sorted_left, sorted_right] = Enum.sort([left, right]) 31 | b(sorted_left or sorted_right) 32 | end 33 | 34 | def walk(other), do: other 35 | end 36 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/distributive_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.DistributiveLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies the distributive law to convert expressions to CNF. 8 | 9 | See: https://en.wikipedia.org/wiki/Distributive_property 10 | 11 | Applies the transformations: 12 | - `A OR (B AND C) = (A OR B) AND (A OR C)` 13 | - `(A AND B) OR C = (A OR C) AND (B OR C)` 14 | 15 | This transformation pushes OR operations inside AND operations, which is 16 | necessary for achieving Conjunctive Normal Form (CNF). 17 | """ 18 | 19 | use Crux.Expression.RewriteRule 20 | 21 | import Crux.Expression, only: [b: 1] 22 | 23 | alias Crux.Expression.RewriteRule 24 | 25 | @impl RewriteRule 26 | def needs_reapplication?, do: true 27 | 28 | @impl RewriteRule 29 | def walk(b(left or (right1 and right2))), do: b((left or right1) and (left or right2)) 30 | def walk(b((left1 and left2) or right)), do: b((left1 or right) and (left2 or right)) 31 | def walk(other), do: other 32 | end 33 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/tautology_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.TautologyLaw do 6 | @moduledoc """ 7 | Rewrite rule that detects common tautology patterns. 8 | 9 | Applies the transformations: 10 | - `A OR (NOT A OR B) = true` (tautology with additional terms) 11 | - `(NOT A OR B) OR A = true` (tautology with additional terms) 12 | - `(A OR B) OR NOT A = true` (tautology with additional terms) 13 | - `NOT A OR (A OR B) = true` (tautology with additional terms) 14 | 15 | These patterns represent tautologies where a complement pair appears 16 | in disjunction with additional terms, making the entire expression true. 17 | """ 18 | 19 | use Crux.Expression.RewriteRule 20 | 21 | import Crux.Expression, only: [b: 1] 22 | 23 | @impl Crux.Expression.RewriteRule 24 | def walk(b(a or (not a or _b))), do: true 25 | def walk(b(a or (_b or not a))), do: true 26 | def walk(b(not a or _b or a)), do: true 27 | def walk(b(_b or not a or a)), do: true 28 | def walk(b(a or _b or not a)), do: true 29 | def walk(b(not a or (a or _b))), do: true 30 | def walk(b(not a or (_b or a))), do: true 31 | def walk(other), do: other 32 | end 33 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/absorption_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.AbsorptionLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies absorption laws to simplify expressions. 8 | 9 | See: https://en.wikipedia.org/wiki/Absorption_law 10 | 11 | Applies the transformations: 12 | - `A AND (A OR B) = A` 13 | - `(A OR B) AND A = A` 14 | - `A OR (A AND B) = A` 15 | - `(A AND B) OR A = A` 16 | 17 | The absorption laws state that a term can absorb another term when 18 | one is a logical subset of the other. 19 | """ 20 | 21 | use Crux.Expression.RewriteRule 22 | 23 | import Crux.Expression, only: [b: 1] 24 | 25 | @impl Crux.Expression.RewriteRule 26 | def walk(b(expr and (expr or _other))), do: expr 27 | def walk(b(expr and (_other or expr))), do: expr 28 | 29 | def walk(b((expr or _other) and expr)), do: expr 30 | def walk(b((_other or expr) and expr)), do: expr 31 | 32 | def walk(b(expr or (expr and _other))), do: expr 33 | def walk(b(expr or (_other and expr))), do: expr 34 | 35 | def walk(b((expr and _other) or expr)), do: expr 36 | def walk(b((_other and expr) or expr)), do: expr 37 | 38 | def walk(other), do: other 39 | end 40 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/distributivity_based_simplification_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.DistributivityBasedSimplificationLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies distributivity-based simplifications to expressions. 8 | 9 | See: https://en.wikipedia.org/wiki/Distributive_property 10 | 11 | Applies the transformations: 12 | - `A AND (NOT A OR B) = A AND B` 13 | - `A OR (NOT A AND B) = A OR B` 14 | - `NOT A AND (A OR B) = NOT A AND B` 15 | - `NOT A OR (A AND B) = NOT A OR B` 16 | 17 | These patterns use distributivity properties to eliminate redundant terms 18 | involving complements, simplifying expressions by removing parts that 19 | don't affect the overall result. 20 | """ 21 | 22 | use Crux.Expression.RewriteRule 23 | 24 | import Crux.Expression, only: [b: 1] 25 | 26 | @impl Crux.Expression.RewriteRule 27 | def walk(b(expr and (not expr or right))), do: b(expr and right) 28 | def walk(b(expr or (not expr and right))), do: b(expr or right) 29 | def walk(b(not expr and (expr or right))), do: b(not expr and right) 30 | def walk(b(not expr or (expr and right))), do: b(not expr or right) 31 | def walk(other), do: other 32 | end 33 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/unit_resolution.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.UnitResolution do 6 | @moduledoc """ 7 | Rewrite rule that applies unit resolution to propagate unit clauses. 8 | 9 | See: https://en.wikipedia.org/wiki/Unit_propagation 10 | 11 | Applies the transformations: 12 | - `A AND (NOT A OR B) = A AND B` (unit A eliminates NOT A from clause) 13 | - `(NOT A OR B) AND A = B AND A` (unit A eliminates NOT A from clause) 14 | - `(NOT A) AND (A OR B) = (NOT A) AND B` (unit NOT A eliminates A from clause) 15 | - `(A OR B) AND (NOT A) = B AND (NOT A)` (unit NOT A eliminates A from clause) 16 | 17 | Unit resolution propagates the effect of unit clauses (single literals) 18 | by eliminating contradictory literals from other clauses. 19 | """ 20 | 21 | use Crux.Expression.RewriteRule 22 | 23 | import Crux.Expression, only: [b: 1] 24 | 25 | alias Crux.Expression.RewriteRule 26 | 27 | @impl RewriteRule 28 | def needs_reapplication?, do: true 29 | 30 | @impl RewriteRule 31 | def walk(b(a and (not a or b))), do: b(a and b) 32 | def walk(b((not a or b) and a)), do: b(b and a) 33 | def walk(b(not a and (a or b))), do: b(not a and b) 34 | def walk(b((a or b) and not a)), do: b(b and not a) 35 | def walk(other), do: other 36 | end 37 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/idempotent_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # credo:disable-for-this-file Credo.Check.Warning.BoolOperationOnSameValues 6 | defmodule Crux.Expression.RewriteRule.IdempotentLaw do 7 | @moduledoc """ 8 | Rewrite rule that applies idempotent laws to simplify expressions. 9 | 10 | See: https://en.wikipedia.org/wiki/Idempotence 11 | 12 | Applies the transformations: 13 | - `A AND A = A` 14 | - `A OR A = A` 15 | 16 | The idempotent laws state that applying the same operation twice 17 | has the same effect as applying it once. 18 | """ 19 | 20 | use Crux.Expression.RewriteRule 21 | 22 | @impl Crux.Expression.RewriteRule 23 | def walk({op, left, right}) do 24 | list = 25 | left 26 | |> gather(op) 27 | |> Enum.concat(gather(right, op)) 28 | 29 | uniq = 30 | Enum.uniq(list) 31 | 32 | case uniq do 33 | [single] -> 34 | single 35 | 36 | multiple -> 37 | if Enum.count(list) == Enum.count(uniq) do 38 | {op, left, right} 39 | else 40 | Enum.reduce(multiple, &{op, &2, &1}) 41 | end 42 | end 43 | end 44 | 45 | def walk(other), do: other 46 | 47 | defp gather({op, left, right}, op) do 48 | gather(left, op) ++ gather(right, op) 49 | end 50 | 51 | defp gather(other, _), do: [other] 52 | end 53 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/consensus_theorem.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.ConsensusTheorem do 6 | @moduledoc """ 7 | Rewrite rule that applies the consensus theorem to eliminate redundant clauses. 8 | 9 | See: https://en.wikipedia.org/wiki/Consensus_theorem 10 | 11 | Applies the transformations: 12 | - `(A OR B) AND (NOT A OR C) AND (B OR C) = (A OR B) AND (NOT A OR C)` 13 | - `(A AND B) OR (NOT A AND C) OR (B AND C) = (A AND B) OR (NOT A AND C)` 14 | 15 | The consensus theorem states that in certain patterns, one clause can be 16 | derived from two others and is therefore redundant. 17 | """ 18 | 19 | use Crux.Expression.RewriteRule 20 | 21 | import Crux.Expression, only: [b: 1] 22 | 23 | @impl Crux.Expression.RewriteRule 24 | def walk(b((a or b) and (not a or c) and (b or c))), do: b((a or b) and (not a or c)) 25 | def walk(b((not a or c) and (a or b) and (b or c))), do: b((not a or c) and (a or b)) 26 | def walk(b((b or c) and (a or b) and (not a or c))), do: b((a or b) and (not a or c)) 27 | def walk(b((a and b) or (not a and c) or (b and c))), do: b((a and b) or (not a and c)) 28 | def walk(b((not a and c) or (a and b) or (b and c))), do: b((not a and c) or (a and b)) 29 | def walk(b((b and c) or (a and b) or (not a and c))), do: b((a and b) or (not a and c)) 30 | def walk(other), do: other 31 | end 32 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/associativity_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.AssociativityLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies associativity optimizations to simplify expressions. 8 | 9 | See: https://en.wikipedia.org/wiki/Associative_property 10 | 11 | Applies the transformations: 12 | - `A OR (A OR B) = A OR B` 13 | - `A OR B OR A = A OR B` 14 | - `A AND (A AND B) = A AND B` 15 | - `A AND B AND A = A AND B` 16 | 17 | The associativity optimizations leverage the associative property of boolean 18 | operations to eliminate redundant terms when the same expression appears 19 | multiple times in an associative context. 20 | """ 21 | 22 | use Crux.Expression.RewriteRule 23 | 24 | import Crux.Expression, only: [b: 1] 25 | 26 | @impl Crux.Expression.RewriteRule 27 | def walk(b(expr or (expr or other))), do: b(expr or other) 28 | def walk(b(expr or (other or expr))), do: b(expr or other) 29 | 30 | def walk(b(expr or other or expr)), do: b(expr or other) 31 | def walk(b(other or expr or expr)), do: b(expr or other) 32 | 33 | def walk(b(expr and (expr and other))), do: b(expr and other) 34 | def walk(b(expr and (other and expr))), do: b(expr and other) 35 | 36 | def walk(b(expr and other and expr)), do: b(expr and other) 37 | def walk(b(other and expr and expr)), do: b(expr and other) 38 | 39 | def walk(other), do: other 40 | end 41 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule/complement_law.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.ComplementLaw do 6 | @moduledoc """ 7 | Rewrite rule that applies complement laws to simplify expressions. 8 | 9 | See: https://en.wikipedia.org/wiki/Boolean_algebra#Complement 10 | 11 | Applies the transformations: 12 | - `A OR NOT A = true` (law of excluded middle) 13 | - `NOT A OR A = true` (law of excluded middle) 14 | - `A AND NOT A = false` (law of contradiction) 15 | - `NOT A AND A = false` (law of contradiction) 16 | - `(A AND B) OR (A AND NOT B) = A` (complement distribution) 17 | - `(A OR B) AND (A OR NOT B) = A` (complement distribution) 18 | 19 | The complement laws handle expressions involving logical complements, 20 | detecting contradictions, tautologies, and simplifying distributive 21 | patterns with complements. 22 | """ 23 | 24 | use Crux.Expression.RewriteRule 25 | 26 | import Crux.Expression, only: [b: 1] 27 | 28 | @impl Crux.Expression.RewriteRule 29 | def walk(b(expr or not expr)), do: true 30 | def walk(b(not expr or expr)), do: true 31 | def walk(b(expr and not expr)), do: false 32 | def walk(b(not expr and expr)), do: false 33 | 34 | def walk(b((expr and left) or (expr and not left))), do: expr 35 | def walk(b((left and expr) or (not left and expr))), do: expr 36 | 37 | def walk(b((expr or left) and (expr or not left))), do: expr 38 | def walk(b((left or expr) and (not left or expr))), do: expr 39 | 40 | def walk(other), do: other 41 | end 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # Change Log 7 | 8 | All notable changes to this project will be documented in this file. 9 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 10 | 11 | 12 | 13 | ## [v0.1.2](https://github.com/ash-project/crux/compare/v0.1.1...v0.1.2) (2025-10-18) 14 | 15 | 16 | 17 | 18 | ### Improvements: 19 | 20 | * Balance Formula in Expression Conversion (#7) by [@maennchen](https://github.com/maennchen) [(#7)](https://github.com/ash-project/crux/pull/7) 21 | 22 | * Exhaustive Expression.expand/2 short-circuits (#5) by [@maennchen](https://github.com/maennchen) [(#5)](https://github.com/ash-project/crux/pull/5) 23 | 24 | * enhance idempotent law to handle nested expressions (#6) by [@zachdaniel](https://github.com/zachdaniel) [(#6)](https://github.com/ash-project/crux/pull/6) 25 | 26 | * Remove CommutativityLaw in Simplify (#4) by [@maennchen](https://github.com/maennchen) [(#4)](https://github.com/ash-project/crux/pull/4) 27 | 28 | * Improve SAT Implementations in Testing (#3) by [@maennchen](https://github.com/maennchen) [(#3)](https://github.com/ash-project/crux/pull/3) 29 | 30 | ## [v0.1.1](https://github.com/ash-project/crux/compare/v0.1.0...v0.1.1) (2025-10-15) 31 | 32 | 33 | 34 | 35 | ### Bug Fixes: 36 | 37 | * Properly Handle Boolean Expressions (#2) by [@maennchen](https://github.com/maennchen) [(#2)](https://github.com/ash-project/crux/pull/2) 38 | 39 | ## [v0.1.0](https://github.com/ash-project/crux/compare/v0.1.0...v0.1.0) (2025-10-15) 40 | 41 | ### Features 42 | 43 | * Initial release (@maennchen) 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/identity_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # credo:disable-for-this-file Credo.Check.Warning.BoolOperationOnSameValues 6 | defmodule Crux.Expression.RewriteRule.IdentityLawTest do 7 | use ExUnit.Case, async: true 8 | use ExUnitProperties 9 | 10 | import Crux.Expression, only: [b: 1] 11 | 12 | alias Crux.Expression 13 | alias Crux.Expression.RewriteRule.IdentityLaw 14 | 15 | doctest IdentityLaw, import: true 16 | 17 | describe inspect(&IdentityLaw.walk/1) do 18 | test "applies AND identity" do 19 | assert Expression.postwalk(b(:a and true), &IdentityLaw.walk/1) == :a 20 | assert Expression.postwalk(b(true and :a), &IdentityLaw.walk/1) == :a 21 | end 22 | 23 | test "applies OR identity" do 24 | assert Expression.postwalk(b(:a or false), &IdentityLaw.walk/1) == :a 25 | assert Expression.postwalk(b(false or :a), &IdentityLaw.walk/1) == :a 26 | end 27 | 28 | test "leaves non-matching patterns unchanged" do 29 | assert Expression.postwalk(b(:a and :b), &IdentityLaw.walk/1) == b(:a and :b) 30 | assert Expression.postwalk(b(:a or :b), &IdentityLaw.walk/1) == b(:a or :b) 31 | assert Expression.postwalk(:a, &IdentityLaw.walk/1) == :a 32 | end 33 | end 34 | 35 | property "identity law preserves logical equivalence" do 36 | check all( 37 | assignments <- 38 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 39 | variable_names = Map.keys(assignments), 40 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 41 | ) do 42 | result = Expression.postwalk(expr, &IdentityLaw.walk/1) 43 | eval_fn = &Map.fetch!(assignments, &1) 44 | 45 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 46 | """ 47 | Identity law changed the logical outcome! 48 | Original: #{inspect(expr, pretty: true)} 49 | Transformed: #{inspect(result, pretty: true)} 50 | Assignments: #{inspect(assignments, pretty: true)} 51 | """ 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.MixProject do 6 | @moduledoc false 7 | 8 | use Mix.Project 9 | 10 | @description """ 11 | Library for boolean satisfiability solving and expression manipulation. 12 | """ 13 | 14 | @version "0.1.2" 15 | 16 | def project do 17 | [ 18 | app: :crux, 19 | version: @version, 20 | elixir: "~> 1.11", 21 | start_permanent: Mix.env() == :prod, 22 | package: package(), 23 | deps: deps(), 24 | docs: &docs/0, 25 | description: @description, 26 | source_url: "https://github.com/ash-project/crux", 27 | homepage_url: "https://github.com/ash-project/crux" 28 | ] 29 | end 30 | 31 | def application do 32 | [] 33 | end 34 | 35 | defp docs do 36 | [ 37 | main: "Crux", 38 | source_ref: "v#{@version}", 39 | nest_modules_by_prefix: [Crux.Expression.RewriteRule], 40 | groups_for_modules: [ 41 | "Rewrite Rules": [ 42 | ~r/^Crux\.Expression\.RewriteRule\..+/ 43 | ] 44 | ] 45 | ] 46 | end 47 | 48 | defp package do 49 | [ 50 | maintainers: ["Ash Project"], 51 | licenses: ["MIT"], 52 | files: ~w(lib .formatter.exs mix.exs README* LICENSE*), 53 | links: %{ 54 | "GitHub" => "https://github.com/ash-project/crux", 55 | "Changelog" => "https://github.com/ash-project/crux/releases", 56 | "Discord" => "https://discord.gg/HTHRaaVPUc", 57 | "Website" => "https://ash-hq.org", 58 | "Forum" => "https://elixirforum.com/c/elixir-framework-forums/ash-framework-forum", 59 | "REUSE Compliance" => "https://api.reuse.software/info/github.com/ash-project/crux" 60 | } 61 | ] 62 | end 63 | 64 | defp deps do 65 | # styler:sort 66 | [ 67 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 68 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 69 | {:doctest_formatter, "~> 0.4.1", only: [:dev, :test], runtime: false}, 70 | {:doctor, "~> 0.22.0", only: [:dev, :test], runtime: false}, 71 | {:ex_check, "~> 0.12", only: [:dev, :test]}, 72 | {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false}, 73 | {:git_ops, "~> 2.5", only: [:dev, :test]}, 74 | {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false}, 75 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, 76 | {:picosat_elixir, "~> 0.2", optional: true}, 77 | {:simple_sat, "~> 0.1 and >= 0.1.1", optional: true}, 78 | {:sobelow, ">= 0.0.0", only: [:dev, :test], runtime: false}, 79 | {:stream_data, "~> 1.0", optional: true}, 80 | {:styler, "~> 1.9", only: [:dev, :test], runtime: false}, 81 | {:usage_rules, "~> 0.1", only: [:dev]} 82 | ] 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This workflow uses actions that are not certified by GitHub. They are provided 6 | # by a third-party and are governed by separate terms of service, privacy 7 | # policy, and support documentation. 8 | 9 | name: Scorecard supply-chain security 10 | on: 11 | # For Branch-Protection check. Only the default branch is supported. See 12 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 13 | branch_protection_rule: 14 | # To guarantee Maintained check is occasionally updated. See 15 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 16 | schedule: 17 | - cron: '40 18 * * 4' 18 | push: 19 | branches: [ "main" ] 20 | 21 | # Declare default permissions as read only. 22 | permissions: read-all 23 | 24 | jobs: 25 | analysis: 26 | name: Scorecard analysis 27 | runs-on: ubuntu-latest 28 | # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. 29 | if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' 30 | permissions: 31 | # Needed to upload the results to code-scanning dashboard. 32 | security-events: write 33 | # Needed to publish results and get a badge (see publish_results below). 34 | id-token: write 35 | # Uncomment the permissions below if installing in a private repository. 36 | # contents: read 37 | # actions: read 38 | 39 | steps: 40 | - name: "Checkout code" 41 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 42 | with: 43 | persist-credentials: false 44 | 45 | - name: "Run analysis" 46 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 47 | with: 48 | results_file: results.sarif 49 | results_format: sarif 50 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 51 | # - you want to enable the Branch-Protection check on a *public* repository, or 52 | # - you are installing Scorecard on a *private* repository 53 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 54 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 55 | 56 | # Public repositories: 57 | # - Publish results to OpenSSF REST API for easy access by consumers 58 | # - Allows the repository to include the Scorecard badge. 59 | # - See https://github.com/ossf/scorecard-action#publishing-results. 60 | # For private repositories: 61 | # - `publish_results` will always be set to `false`, regardless 62 | # of the value entered here. 63 | publish_results: true 64 | 65 | # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore 66 | # file_mode: git 67 | 68 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 69 | # format to the repository Actions tab. 70 | - name: "Upload artifact" 71 | uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 72 | with: 73 | name: SARIF file 74 | path: results.sarif 75 | retention-days: 5 76 | 77 | # Upload the results to GitHub's code scanning dashboard (optional). 78 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 79 | - name: "Upload to code-scanning" 80 | uses: github/codeql-action/upload-sarif@v4 81 | with: 82 | sarif_file: results.sarif 83 | -------------------------------------------------------------------------------- /lib/crux/implementation.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | moduledoc = """ 6 | This module provides an interface to a SAT solver. 7 | 8 | It tries to use the `Picosat` module if available, falling back to `SimpleSat` if not. 9 | 10 | If neither is available, it raises an error when attempting to solve an expression. 11 | 12 | You can also specify a custom SAT solver by setting the `SAT_SOLVER` environment variable 13 | to the name of a module that implements the `solve/1` function. 14 | 15 | Alternatively, you can enable SAT testing by setting the `:sat_testing` configuration 16 | for the `:crux` application. This will allow you to specify a custom SAT solver via 17 | the `SAT_SOLVER` environment variable. 18 | """ 19 | 20 | check_doc = """ 21 | Checks if a SAT solver implementation is available. 22 | Raises an error with instructions if not. 23 | """ 24 | 25 | cond do 26 | Application.compile_env(:crux, :sat_testing) -> 27 | defmodule Crux.Implementation do 28 | @moduledoc moduledoc 29 | 30 | @doc false 31 | def solve_expression(cnf) do 32 | env_solver = Module.concat([System.get_env("SAT_SOLVER") || "Picosat"]) 33 | solver = Process.get(__MODULE__, env_solver) 34 | 35 | solver.solve(cnf) 36 | end 37 | 38 | @doc check_doc 39 | def check!, do: :ok 40 | end 41 | 42 | Application.compile_env(:ash, :sat_testing) -> 43 | IO.warn( 44 | """ 45 | The `:sat_testing` configuration for the `:ash` application is deprecated. 46 | Please use the `:sat_testing` configuration for the `:crux` application instead. 47 | """, 48 | __ENV__ 49 | ) 50 | 51 | defmodule Crux.Implementation do 52 | @moduledoc moduledoc 53 | 54 | @doc false 55 | def solve_expression(cnf) do 56 | Module.concat([System.get_env("SAT_SOLVER") || "Picosat"]).solve(cnf) 57 | end 58 | 59 | @doc check_doc 60 | def check!, do: :ok 61 | end 62 | 63 | Code.ensure_loaded?(Picosat) -> 64 | defmodule Crux.Implementation do 65 | @moduledoc moduledoc 66 | 67 | @doc false 68 | def solve_expression(cnf) do 69 | Picosat.solve(cnf) 70 | end 71 | 72 | @doc check_doc 73 | def check!, do: :ok 74 | end 75 | 76 | Code.ensure_loaded?(SimpleSat) -> 77 | defmodule Crux.Implementation do 78 | @moduledoc moduledoc 79 | @doc false 80 | def solve_expression(cnf) do 81 | SimpleSat.solve(cnf) 82 | end 83 | 84 | @doc check_doc 85 | def check!, do: :ok 86 | end 87 | 88 | true -> 89 | defmodule Crux.Implementation do 90 | @moduledoc moduledoc 91 | def solve_expression(_cnf) do 92 | check!() 93 | 94 | # make the type checker happy 95 | apply(__MODULE__, :ok, []) 96 | end 97 | 98 | def ok do 99 | :ok 100 | end 101 | 102 | @doc check_doc 103 | def check! do 104 | if Code.ensure_loaded?(Picosat) || Code.ensure_loaded?(SimpleSat) do 105 | raise """ 106 | No SAT solver available, although one was loaded. 107 | 108 | This typically means that you need to run `mix deps.compile crux --force` 109 | 110 | If that doesn't work, please ensure that one of the following dependencies is present in your application to use SAT solver features: 111 | 112 | * `:picosat_elixir` (recommended) - A NIF wrapper around the PicoSAT SAT solver. Fast, production ready, battle tested. 113 | * `:simple_sat` - A pure Elixir SAT solver. Slower than PicoSAT, but no NIF dependency. 114 | """ 115 | end 116 | 117 | raise """ 118 | No SAT solver available. 119 | 120 | Please add one of the following dependencies to your application to use SAT solver features: 121 | 122 | * `:picosat_elixir` (recommended) - A NIF wrapper around the PicoSAT SAT solver. Fast, production ready, battle tested. 123 | * `:simple_sat` - A pure Elixir SAT solver. Slower than PicoSAT, but no NIF dependency. 124 | """ 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/absorption_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.AbsorptionLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.AbsorptionLaw 13 | 14 | doctest AbsorptionLaw, import: true 15 | 16 | describe inspect(&AbsorptionLaw.walk/1) do 17 | test "applies AND absorption: A AND (A OR B)" do 18 | assert Expression.postwalk(b(:a and (:a or :b)), &AbsorptionLaw.walk/1) == :a 19 | end 20 | 21 | test "applies AND absorption: (A OR B) AND A" do 22 | assert Expression.postwalk(b((:a or :b) and :a), &AbsorptionLaw.walk/1) == :a 23 | end 24 | 25 | test "applies OR absorption: A OR (A AND B)" do 26 | assert Expression.postwalk(b(:a or (:a and :b)), &AbsorptionLaw.walk/1) == :a 27 | end 28 | 29 | test "applies OR absorption: (A AND B) OR A" do 30 | assert Expression.postwalk(b((:a and :b) or :a), &AbsorptionLaw.walk/1) == :a 31 | end 32 | 33 | test "works with complex nested expressions" do 34 | expr = b(:x and :y and ((:x and :y) or :z)) 35 | assert Expression.postwalk(expr, &AbsorptionLaw.walk/1) == b(:x and :y) 36 | end 37 | 38 | test "works with negated expressions" do 39 | expr = b(not :a and (not :a or :b)) 40 | assert Expression.postwalk(expr, &AbsorptionLaw.walk/1) == b(not :a) 41 | end 42 | 43 | test "leaves non-matching patterns unchanged" do 44 | assert Expression.postwalk(b(:a and :b), &AbsorptionLaw.walk/1) == b(:a and :b) 45 | 46 | assert Expression.postwalk(b(:a and (:b or :c)), &AbsorptionLaw.walk/1) == 47 | b(:a and (:b or :c)) 48 | end 49 | 50 | test "handles multiple absorption opportunities" do 51 | expr = b((:a and (:a or :b)) or (:c or (:c and :d))) 52 | result = Expression.postwalk(expr, &AbsorptionLaw.walk/1) 53 | assert result == b(:a or :c) 54 | end 55 | end 56 | 57 | describe "edge cases" do 58 | test "handles reverse absorption patterns" do 59 | # Test that all four patterns work correctly 60 | exprs_and_expected = [ 61 | {b(:x and (:x or :y)), :x}, 62 | {b((:x or :y) and :x), :x}, 63 | {b(:x or (:x and :y)), :x}, 64 | {b((:x and :y) or :x), :x} 65 | ] 66 | 67 | for {expr, expected} <- exprs_and_expected do 68 | assert Expression.postwalk(expr, &AbsorptionLaw.walk/1) == expected 69 | end 70 | end 71 | 72 | test "works with very complex identical sub-expressions" do 73 | complex_expr = b(not (:a and :b) or (:c and not :d)) 74 | expr = b(complex_expr and (complex_expr or :simple)) 75 | assert Expression.postwalk(expr, &AbsorptionLaw.walk/1) == complex_expr 76 | end 77 | 78 | test "preserves non-absorbing complex patterns" do 79 | expr = b((:a and :b) or (:c and (:a or :b))) 80 | # This doesn't match absorption patterns 81 | assert Expression.postwalk(expr, &AbsorptionLaw.walk/1) == expr 82 | end 83 | end 84 | 85 | property "applying absorption law is idempotent" do 86 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 87 | result1 = Expression.postwalk(expr, &AbsorptionLaw.walk/1) 88 | result2 = Expression.postwalk(result1, &AbsorptionLaw.walk/1) 89 | assert result1 == result2 90 | end 91 | end 92 | 93 | property "absorption law preserves logical equivalence" do 94 | check all( 95 | assignments <- 96 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 97 | variable_names = Map.keys(assignments), 98 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 99 | ) do 100 | result = Expression.postwalk(expr, &AbsorptionLaw.walk/1) 101 | eval_fn = &Map.fetch!(assignments, &1) 102 | 103 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 104 | """ 105 | Absorption law changed the logical outcome! 106 | Original: #{inspect(expr, pretty: true)} 107 | Transformed: #{inspect(result, pretty: true)} 108 | Assignments: #{inspect(assignments, pretty: true)} 109 | """ 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/annihilator_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.AnnihilatorLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.AnnihilatorLaw 13 | 14 | doctest AnnihilatorLaw, import: true 15 | 16 | describe inspect(&AnnihilatorLaw.walk/1) do 17 | test "applies AND annihilator: A AND false" do 18 | refute Expression.postwalk(b(:a and false), &AnnihilatorLaw.walk/1) 19 | end 20 | 21 | test "applies AND annihilator: false AND A" do 22 | refute Expression.postwalk(b(false and :a), &AnnihilatorLaw.walk/1) 23 | end 24 | 25 | test "applies OR annihilator: A OR true" do 26 | assert Expression.postwalk(b(:a or true), &AnnihilatorLaw.walk/1) 27 | end 28 | 29 | test "applies OR annihilator: true OR A" do 30 | assert Expression.postwalk(b(true or :a), &AnnihilatorLaw.walk/1) 31 | end 32 | 33 | test "works with complex sub-expressions" do 34 | expr = b(:a and :b and false) 35 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 36 | refute result 37 | end 38 | 39 | test "works with complex tautology patterns" do 40 | expr = b(:x or :y or true) 41 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 42 | assert result 43 | end 44 | 45 | test "handles nested annihilators" do 46 | expr = b((:a or true) and :b) 47 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 48 | assert result == b(true and :b) 49 | end 50 | 51 | test "works with deeply nested expressions" do 52 | expr = b(:p and :q and (false and :r)) 53 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 54 | refute result 55 | end 56 | 57 | test "handles multiple variables in complex expressions" do 58 | expr = b(((:a and :b) or true) and :c) 59 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 60 | assert result == b(true and :c) 61 | end 62 | 63 | test "leaves non-matching patterns unchanged" do 64 | assert Expression.postwalk(b(:a and :b), &AnnihilatorLaw.walk/1) == b(:a and :b) 65 | assert Expression.postwalk(b(:a or :b), &AnnihilatorLaw.walk/1) == b(:a or :b) 66 | assert Expression.postwalk(b(:a and true), &AnnihilatorLaw.walk/1) == b(:a and true) 67 | assert Expression.postwalk(b(:a or false), &AnnihilatorLaw.walk/1) == b(:a or false) 68 | end 69 | 70 | test "leaves single variables unchanged" do 71 | assert Expression.postwalk(:a, &AnnihilatorLaw.walk/1) == :a 72 | end 73 | 74 | test "leaves boolean constants unchanged" do 75 | assert Expression.postwalk(true, &AnnihilatorLaw.walk/1) 76 | refute Expression.postwalk(false, &AnnihilatorLaw.walk/1) 77 | end 78 | 79 | test "handles multiple annihilator opportunities" do 80 | expr = b((false and :a) or (true or :b)) 81 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 82 | assert result 83 | end 84 | 85 | test "works in complex mixed expressions" do 86 | expr = b((false and :x) or (true or :y)) 87 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 88 | assert result 89 | end 90 | end 91 | 92 | describe "edge cases" do 93 | test "handles complex boolean structures" do 94 | expr = b((:a or :b) and false and (:c and :d)) 95 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 96 | refute result 97 | end 98 | 99 | test "preserves non-annihilator patterns" do 100 | expr = b((:a and :b) or (:c and :d)) 101 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 102 | assert result == b((:a and :b) or (:c and :d)) 103 | end 104 | end 105 | 106 | property "applying annihilator law is idempotent" do 107 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 108 | result1 = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 109 | result2 = Expression.postwalk(result1, &AnnihilatorLaw.walk/1) 110 | assert result1 == result2 111 | end 112 | end 113 | 114 | property "annihilator laws preserve logical equivalence" do 115 | check all( 116 | assignments <- 117 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 118 | variable_names = Map.keys(assignments), 119 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 120 | ) do 121 | result = Expression.postwalk(expr, &AnnihilatorLaw.walk/1) 122 | eval_fn = &Map.fetch!(assignments, &1) 123 | 124 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 125 | """ 126 | Annihilator law changed the logical outcome! 127 | Original: #{inspect(expr, pretty: true)} 128 | Transformed: #{inspect(result, pretty: true)} 129 | Assignments: #{inspect(assignments, pretty: true)} 130 | """ 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/crux/formula_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.FormulaTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Formula 13 | 14 | doctest Formula 15 | 16 | describe inspect(&Formula.from_expression/1) do 17 | test "converts an expression to a formula with bindings" do 18 | expression = b((:a and not :b) or (not :c and :d)) 19 | 20 | # (:a and not :b) or (not :c and :d) in CNF becomes: 21 | # (:a or not :c) and (:a or :d) and (not :b or not :c) and (not :b or :d) 22 | result = Formula.from_expression(expression) 23 | 24 | assert %Formula{ 25 | cnf: [ 26 | # :a or :d 27 | [1, 2], 28 | # not :b or :d 29 | [-3, 2], 30 | # :a or not :c 31 | [1, -4], 32 | # not :b or not :c 33 | [-3, -4] 34 | ], 35 | bindings: %{ 36 | 1 => :a, 37 | 2 => :d, 38 | 3 => :b, 39 | 4 => :c 40 | } 41 | } = result 42 | end 43 | 44 | test "converts simple expressions" do 45 | # Single variable 46 | result = Formula.from_expression(b(:a)) 47 | assert %Formula{cnf: [[1]], bindings: %{1 => :a}} = result 48 | 49 | # Single negated variable 50 | result = Formula.from_expression(b(not :a)) 51 | assert %Formula{cnf: [[-1]], bindings: %{1 => :a}} = result 52 | 53 | # Simple OR 54 | result = Formula.from_expression(b(:a or :b)) 55 | assert %Formula{cnf: [[1, 2]], bindings: %{1 => :a, 2 => :b}} = result 56 | 57 | # Simple AND 58 | result = Formula.from_expression(b(:a and :b)) 59 | assert %Formula{cnf: [[1], [2]], bindings: %{1 => :a, 2 => :b}} = result 60 | 61 | # Booleans 62 | assert %Formula{cnf: [], bindings: %{}} = Formula.from_expression(true) 63 | assert %Formula{cnf: [[1], [-1]], bindings: %{}} = Formula.from_expression(false) 64 | end 65 | end 66 | 67 | describe inspect(&Formula.to_expression/1) do 68 | test "converts a formula back to expression" do 69 | formula = %Formula{ 70 | cnf: [[1], [2]], 71 | bindings: %{1 => :a, 2 => :b}, 72 | reverse_bindings: %{a: 1, b: 2} 73 | } 74 | 75 | result = Formula.to_expression(formula) 76 | assert result == b(:a and :b) 77 | end 78 | 79 | test "converts formula with OR clause" do 80 | formula = %Formula{ 81 | cnf: [[1, -2]], 82 | bindings: %{1 => :x, 2 => :y}, 83 | reverse_bindings: %{x: 1, y: 2} 84 | } 85 | 86 | result = Formula.to_expression(formula) 87 | assert result == b(:x or not :y) 88 | end 89 | 90 | test "converts back boolean formulas" do 91 | assert true |> Formula.from_expression() |> Formula.to_expression() == true 92 | assert false |> Formula.from_expression() |> Formula.to_expression() == false 93 | end 94 | 95 | property "roundtrip from_expression to to_expression preserves equivalence" do 96 | check all( 97 | assignments <- 98 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 99 | variable_names = Map.keys(assignments), 100 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 101 | ) do 102 | formula = Formula.from_expression(expr) 103 | result = Formula.to_expression(formula) 104 | eval_fn = &Map.fetch!(assignments, &1) 105 | 106 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 107 | """ 108 | Roundtrip conversion changed the logical outcome! 109 | Original: #{inspect(expr, pretty: true)} 110 | Roundtrip: #{inspect(result, pretty: true)} 111 | Assignments: #{inspect(assignments, pretty: true)} 112 | """ 113 | end 114 | end 115 | end 116 | 117 | describe inspect(&Formula.to_picosat/1) do 118 | test "converts a formula to DIMACS format" do 119 | # Simple conjunction: :a and :b 120 | expression = b(:a and :b) 121 | formula = Formula.from_expression(expression) 122 | result = Formula.to_picosat(formula) 123 | 124 | # Should produce CNF with 2 variables and 2 clauses 125 | assert result == "p cnf 2 2\n1 0\n2 0" 126 | end 127 | 128 | test "converts more complex formulas" do 129 | # Disjunction: :a or :b 130 | expression = b(:a or :b) 131 | formula = Formula.from_expression(expression) 132 | result = Formula.to_picosat(formula) 133 | 134 | # Should produce CNF with 2 variables and 1 clause 135 | assert result == "p cnf 2 1\n1 2 0" 136 | end 137 | 138 | test "handles negated variables" do 139 | # Not :a 140 | expression = b(not :a) 141 | formula = Formula.from_expression(expression) 142 | result = Formula.to_picosat(formula) 143 | 144 | # Should produce CNF with 1 variable and 1 clause 145 | assert result == "p cnf 1 1\n-1 0" 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/de_morgans_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.DeMorgansLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule 13 | alias Crux.Expression.RewriteRule.DeMorgansLaw 14 | 15 | doctest DeMorgansLaw, import: true 16 | 17 | describe inspect(&DeMorgansLaw.walk/1) do 18 | test "transforms NOT (A AND B)" do 19 | expr = b(not (:a and :b)) 20 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 21 | assert result == b(not :a or not :b) 22 | end 23 | 24 | test "transforms NOT (A OR B)" do 25 | expr = b(not (:a or :b)) 26 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 27 | assert result == b(not :a and not :b) 28 | end 29 | 30 | test "handles nested applications requiring reapplication" do 31 | expr = b(not ((:a and :b) or (:c and :d))) 32 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 33 | # Should apply De Morgan's to outer OR, then to inner ANDs 34 | assert result == b((not :a or not :b) and (not :c or not :d)) 35 | end 36 | 37 | test "leaves non-matching patterns unchanged" do 38 | expr = b(not :a) 39 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 40 | assert result == b(not :a) 41 | end 42 | 43 | test "works with complex nested expressions" do 44 | expr = b(not ((not :a and :b) or (not :c and :d))) 45 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 46 | assert result == b((not not :a or not :b) and (not not :c or not :d)) 47 | end 48 | 49 | test "handles multiple separate applications" do 50 | expr = b(not (:a and :b) or not (:c or :d)) 51 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 52 | assert result == b(not :a or not :b or (not :c and not :d)) 53 | end 54 | 55 | test "applies to deeply nested conjunctions" do 56 | expr = b(not (:a and :b and (:c and :d))) 57 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 58 | # Should distribute through all levels 59 | assert result == b(not :a or not :b or (not :c or not :d)) 60 | end 61 | 62 | test "applies to deeply nested disjunctions" do 63 | expr = b(not (:a or :b or (:c or :d))) 64 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 65 | # Should distribute through all levels 66 | assert result == b(not :a and not :b and (not :c and not :d)) 67 | end 68 | 69 | test "works with boolean constants" do 70 | expr = b(not (true and false)) 71 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 72 | assert result == b(not true or not false) 73 | end 74 | 75 | test "handles mixed AND and OR patterns" do 76 | expr = b(not ((:a and :b) or (:c and :d) or (:e and :f))) 77 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 78 | # Should apply De Morgan's recursively to break down the structure 79 | expected = b((not :a or not :b) and (not :c or not :d) and (not :e or not :f)) 80 | assert result == expected 81 | end 82 | 83 | test "preserves expressions that don't match patterns" do 84 | expr = b(:a and (:b or :c)) 85 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 86 | assert result == b(:a and (:b or :c)) 87 | end 88 | 89 | test "handles single variable negations" do 90 | expr = b(not :x and not :y) 91 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 92 | assert result == b(not :x and not :y) 93 | end 94 | end 95 | 96 | describe "reapplication behavior" do 97 | test "continues applying until no more transformations possible" do 98 | expr = b(not (not (:a and :b) and not (:c or :d))) 99 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 100 | # Should keep applying De Morgan's laws until exhausted 101 | expected = b((not not :a and not not :b) or (not not :c or not not :d)) 102 | assert result == expected 103 | end 104 | 105 | test "handles complex nested patterns requiring multiple passes" do 106 | expr = b(not (not (:a and :b) and not (:c or :d))) 107 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 108 | # Should apply De Morgan's at multiple levels 109 | expected = b((not not :a and not not :b) or (not not :c or not not :d)) 110 | assert result == expected 111 | end 112 | end 113 | 114 | property "De Morgan's laws preserve logical equivalence" do 115 | check all( 116 | assignments <- 117 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 118 | variable_names = Map.keys(assignments), 119 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 120 | ) do 121 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 122 | eval_fn = &Map.fetch!(assignments, &1) 123 | 124 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 125 | """ 126 | De Morgan's law changed the logical outcome! 127 | Original: #{inspect(expr, pretty: true)} 128 | Transformed: #{inspect(result, pretty: true)} 129 | Assignments: #{inspect(assignments, pretty: true)} 130 | """ 131 | end 132 | end 133 | 134 | property "applying De Morgan's laws is idempotent after first application" do 135 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 136 | {result1, _acc_map1} = RewriteRule.apply(expr, [DeMorgansLaw]) 137 | {result2, _acc_map2} = RewriteRule.apply(result1, [DeMorgansLaw]) 138 | assert result1 == result2 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/idempotent_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # credo:disable-for-this-file Credo.Check.Warning.BoolOperationOnSameValues 6 | defmodule Crux.Expression.RewriteRule.IdempotentLawTest do 7 | use ExUnit.Case, async: true 8 | use ExUnitProperties 9 | 10 | import Crux.Expression, only: [b: 1] 11 | 12 | alias Crux.Expression 13 | alias Crux.Expression.RewriteRule.IdempotentLaw 14 | 15 | doctest IdempotentLaw, import: true 16 | 17 | describe inspect(&IdempotentLaw.walk/1) do 18 | test "applies AND idempotent law" do 19 | assert Expression.postwalk(b(:a and :a), &IdempotentLaw.walk/1) == :a 20 | end 21 | 22 | test "applies OR idempotent law" do 23 | assert Expression.postwalk(b(:a or :a), &IdempotentLaw.walk/1) == :a 24 | end 25 | 26 | test "works with complex sub-expressions" do 27 | assert Expression.postwalk(b(:a and :b and (:a and :b)), &IdempotentLaw.walk/1) == 28 | b(:a and :b) 29 | end 30 | 31 | test "works with complex OR expressions" do 32 | assert Expression.postwalk(b(:x or :y or (:x or :y)), &IdempotentLaw.walk/1) == b(:x or :y) 33 | end 34 | 35 | test "handles nested expressions" do 36 | assert Expression.postwalk(b(not :a and not :a), &IdempotentLaw.walk/1) == b(not :a) 37 | end 38 | 39 | test "works with negated complex expressions" do 40 | assert Expression.postwalk(b(not (:a and :b) or not (:a and :b)), &IdempotentLaw.walk/1) == 41 | b(not (:a and :b)) 42 | end 43 | 44 | test "handles deeply nested identical expressions" do 45 | expr = b(:a and (:b or :c) and (:a and (:b or :c))) 46 | assert Expression.postwalk(expr, &IdempotentLaw.walk/1) == b(:a and (:b or :c)) 47 | end 48 | 49 | test "works with boolean constants" do 50 | assert Expression.postwalk(b(true and true), &IdempotentLaw.walk/1) 51 | refute Expression.postwalk(b(false and false), &IdempotentLaw.walk/1) 52 | assert Expression.postwalk(b(true or true), &IdempotentLaw.walk/1) 53 | refute Expression.postwalk(b(false or false), &IdempotentLaw.walk/1) 54 | end 55 | 56 | test "handles multiple variables in identical expressions" do 57 | assert Expression.postwalk( 58 | b((:a and :b and :c) or (:a and :b and :c)), 59 | &IdempotentLaw.walk/1 60 | ) == 61 | b(:a and :b and :c) 62 | end 63 | 64 | test "works with complex nested structures" do 65 | expr = b((:a or :b) and (:c or :d) and ((:a or :b) and (:c or :d))) 66 | result = Expression.postwalk(expr, &IdempotentLaw.walk/1) 67 | assert result == b((:a or :b) and (:c or :d)) 68 | end 69 | 70 | test "handles mixed AND and OR in identical expressions" do 71 | expr = b((:a and (:b or :c)) or (:a and (:b or :c))) 72 | assert Expression.postwalk(expr, &IdempotentLaw.walk/1) == b(:a and (:b or :c)) 73 | end 74 | 75 | test "leaves non-identical patterns unchanged" do 76 | assert Expression.postwalk(b(:a and :b), &IdempotentLaw.walk/1) == b(:a and :b) 77 | end 78 | 79 | test "leaves different variables unchanged" do 80 | assert Expression.postwalk(b(:a or :b), &IdempotentLaw.walk/1) == b(:a or :b) 81 | end 82 | 83 | test "leaves single variables unchanged" do 84 | assert Expression.postwalk(:a, &IdempotentLaw.walk/1) == :a 85 | end 86 | 87 | test "leaves negations unchanged" do 88 | assert Expression.postwalk(b(not :a), &IdempotentLaw.walk/1) == b(not :a) 89 | end 90 | 91 | test "leaves boolean constants unchanged" do 92 | assert Expression.postwalk(true, &IdempotentLaw.walk/1) 93 | refute Expression.postwalk(false, &IdempotentLaw.walk/1) 94 | end 95 | 96 | test "handles partial identical structures" do 97 | # Only part of the expression is identical - should not apply 98 | expr = b((:a and :a) or (:b and :c)) 99 | result = Expression.postwalk(expr, &IdempotentLaw.walk/1) 100 | assert result == b(:a or (:b and :c)) 101 | end 102 | 103 | test "works with very complex identical expressions" do 104 | expr = b((not (:a and :b) or (:c and not :d)) and (not (:a and :b) or (:c and not :d))) 105 | expected = b(not (:a and :b) or (:c and not :d)) 106 | assert Expression.postwalk(expr, &IdempotentLaw.walk/1) == expected 107 | end 108 | 109 | test "handles multiple idempotent opportunities" do 110 | expr = b((:a and :a) or (:b or :b)) 111 | result = Expression.postwalk(expr, &IdempotentLaw.walk/1) 112 | assert result == b(:a or :b) 113 | end 114 | 115 | test "preserves expression structure for non-idempotent cases" do 116 | expr = b((:a and :b) or (:c and :d)) 117 | assert Expression.postwalk(expr, &IdempotentLaw.walk/1) == expr 118 | end 119 | end 120 | 121 | describe "edge cases" do 122 | test "handles triple identical expressions" do 123 | # The rule applies recursively, so this fully reduces 124 | expr = b(:a and :a and :a) 125 | result = Expression.postwalk(expr, &IdempotentLaw.walk/1) 126 | # This reduces fully to :a through recursive application 127 | assert result == :a 128 | end 129 | 130 | test "works with identical expressions at different nesting levels" do 131 | expr = b((:a and :b and (:a and :b)) or :c) 132 | result = Expression.postwalk(expr, &IdempotentLaw.walk/1) 133 | assert result == b((:a and :b) or :c) 134 | end 135 | 136 | test "preserves non-idempotent complex patterns" do 137 | expr = b(:a and :b and (:a or :b)) 138 | assert Expression.postwalk(expr, &IdempotentLaw.walk/1) == expr 139 | end 140 | end 141 | 142 | property "applying idempotent law is idempotent" do 143 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 144 | result1 = Expression.postwalk(expr, &IdempotentLaw.walk/1) 145 | result2 = Expression.postwalk(result1, &IdempotentLaw.walk/1) 146 | assert result1 == result2 147 | end 148 | end 149 | 150 | property "idempotent laws preserve logical equivalence" do 151 | check all( 152 | assignments <- 153 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 154 | variable_names = Map.keys(assignments), 155 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 156 | ) do 157 | result = Expression.postwalk(expr, &IdempotentLaw.walk/1) 158 | eval_fn = &Map.fetch!(assignments, &1) 159 | 160 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 161 | """ 162 | Idempotent law changed the logical outcome! 163 | Original: #{inspect(expr, pretty: true)} 164 | Transformed: #{inspect(result, pretty: true)} 165 | Assignments: #{inspect(assignments, pretty: true)} 166 | """ 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/distributive_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.DistributiveLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule 13 | alias Crux.Expression.RewriteRule.DistributiveLaw 14 | 15 | doctest DistributiveLaw, import: true 16 | 17 | describe inspect(&DistributiveLaw.walk/1) do 18 | test "distributes A OR (B AND C)" do 19 | expr = b(:a or (:b and :c)) 20 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 21 | assert result == b((:a or :b) and (:a or :c)) 22 | end 23 | 24 | test "distributes (A AND B) OR C" do 25 | expr = b((:a and :b) or :c) 26 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 27 | assert result == b((:a or :c) and (:b or :c)) 28 | end 29 | 30 | test "handles complex nested distributions requiring reapplication" do 31 | expr = b(:a or ((:b and :c) or (:d and :e))) 32 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 33 | # The actual result shows the structure the distributive law produces 34 | expected = 35 | b( 36 | (:a or (:b or :d)) and (:a or (:c or :d)) and 37 | ((:a or (:b or :e)) and (:a or (:c or :e))) 38 | ) 39 | 40 | assert result == expected 41 | end 42 | 43 | test "leaves non-matching patterns unchanged" do 44 | expr = b(:a and (:b or :c)) 45 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 46 | assert result == b(:a and (:b or :c)) 47 | end 48 | 49 | test "handles multiple distributive opportunities" do 50 | expr = b((:a or (:b and :c)) and (:d or (:e and :f))) 51 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 52 | assert result == b((:a or :b) and (:a or :c) and ((:d or :e) and (:d or :f))) 53 | end 54 | 55 | test "works with deeply nested expressions" do 56 | expr = b(:x or (:y and (:z or (:w and :v)))) 57 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 58 | # Should distribute :x over the AND, then continue distributing within 59 | expected = b((:x or :y) and ((:x or (:z or :w)) and (:x or (:z or :v)))) 60 | assert result == expected 61 | end 62 | 63 | test "distributes with boolean constants" do 64 | expr = b(true or (false and :a)) 65 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 66 | assert result == b((true or false) and (true or :a)) 67 | end 68 | 69 | test "handles symmetric distribution patterns" do 70 | expr = b((:x and :y) or (:z and :w)) 71 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 72 | # The actual distribution creates this nested AND structure 73 | expected = b((:x or :z) and (:y or :z) and ((:x or :w) and (:y or :w))) 74 | assert result == expected 75 | end 76 | 77 | test "applies to nested AND structures" do 78 | expr = b(:a or (:b and :c and (:d and :e))) 79 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 80 | # Should distribute :a over the outer AND 81 | expected = b((:a or :b) and (:a or :c) and ((:a or :d) and (:a or :e))) 82 | assert result == expected 83 | end 84 | 85 | test "preserves expressions that don't need distribution" do 86 | expr = b((:a or :b) and (:c or :d)) 87 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 88 | assert result == b((:a or :b) and (:c or :d)) 89 | end 90 | 91 | test "handles single variable cases" do 92 | expr = b(:a or :b) 93 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 94 | assert result == b(:a or :b) 95 | end 96 | 97 | test "distributes complex nested OR over AND" do 98 | expr = b(:a or :b or :c or (:d and :e)) 99 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 100 | assert result == b((:a or :b or :c or :d) and (:a or :b or :c or :e)) 101 | end 102 | end 103 | 104 | describe "reapplication behavior" do 105 | test "continues applying until CNF is achieved" do 106 | expr = b(:a or (:b or (:c and :d))) 107 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 108 | # Should keep distributing until all OR-over-AND patterns are resolved 109 | expected = b((:a or (:b or :c)) and (:a or (:b or :d))) 110 | assert result == expected 111 | end 112 | 113 | test "handles deeply nested distributions" do 114 | expr = b(:w or (:x or (:y and (:z1 and :z2)))) 115 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 116 | # Should distribute at multiple levels 117 | expected = b((:w or (:x or :y)) and ((:w or (:x or :z1)) and (:w or (:x or :z2)))) 118 | assert result == expected 119 | end 120 | 121 | test "converts complex expressions to CNF form" do 122 | expr = b(:a or (:b and :c) or (:d and (:e or :f))) 123 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 124 | # Should result in a conjunction of disjunctions (CNF) 125 | # This is a complex case - the exact result depends on order of application 126 | # but should be in CNF form (conjunction of disjunctions) 127 | assert Expression.in_cnf?(result) 128 | end 129 | end 130 | 131 | describe "CNF conversion properties" do 132 | test "produces CNF for simple cases" do 133 | expr = b(:a or (:b and :c)) 134 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 135 | assert Expression.in_cnf?(result) 136 | end 137 | 138 | test "maintains CNF for already CNF expressions" do 139 | expr = b((:a or :b) and (:c or :d)) 140 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 141 | assert Expression.in_cnf?(result) 142 | assert result == expr 143 | end 144 | end 145 | 146 | property "applying distributive law is idempotent after first application" do 147 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 148 | {result1, _acc_map1} = RewriteRule.apply(expr, [DistributiveLaw]) 149 | {result2, _acc_map2} = RewriteRule.apply(result1, [DistributiveLaw]) 150 | assert result1 == result2 151 | end 152 | end 153 | 154 | property "distributive law preserves logical equivalence" do 155 | check all( 156 | assignments <- 157 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 158 | variable_names = Map.keys(assignments), 159 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 160 | ) do 161 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 162 | eval_fn = &Map.fetch!(assignments, &1) 163 | 164 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 165 | """ 166 | Distributive law changed the logical outcome! 167 | Original: #{inspect(expr, pretty: true)} 168 | Transformed: #{inspect(result, pretty: true)} 169 | Assignments: #{inspect(assignments, pretty: true)} 170 | """ 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/negation_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # credo:disable-for-this-file Credo.Check.Warning.BoolOperationOnSameValues 6 | defmodule Crux.Expression.RewriteRule.NegationLawTest do 7 | use ExUnit.Case, async: true 8 | use ExUnitProperties 9 | 10 | import Crux.Expression, only: [b: 1] 11 | 12 | alias Crux.Expression 13 | alias Crux.Expression.RewriteRule.NegationLaw 14 | 15 | doctest NegationLaw, import: true 16 | 17 | describe inspect(&NegationLaw.walk/1) do 18 | test "applies NOT true = false" do 19 | refute Expression.postwalk(b(not true), &NegationLaw.walk/1) 20 | end 21 | 22 | test "applies NOT false = true" do 23 | assert Expression.postwalk(b(not false), &NegationLaw.walk/1) 24 | end 25 | 26 | test "applies NOT (NOT A) = A" do 27 | assert Expression.postwalk(b(not not :a), &NegationLaw.walk/1) == :a 28 | end 29 | 30 | test "eliminates nested double negations" do 31 | expr = b(not not not not :a) 32 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 33 | assert result == :a 34 | end 35 | 36 | test "works with complex sub-expressions" do 37 | expr = b(not not (:a and :b)) 38 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 39 | assert result == b(:a and :b) 40 | end 41 | 42 | test "leaves single negations unchanged" do 43 | expr = b(not :a) 44 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 45 | assert result == b(not :a) 46 | end 47 | 48 | test "handles multiple separate double negations" do 49 | expr = b(not not :a and not not :b) 50 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 51 | assert result == b(:a and :b) 52 | end 53 | 54 | test "works within complex expressions" do 55 | expr = b((:c or not not :a) and (not not :b or :d)) 56 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 57 | assert result == b((:c or :a) and (:b or :d)) 58 | end 59 | 60 | test "eliminates quadruple negations" do 61 | expr = b(not not not not (:x and :y)) 62 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 63 | assert result == b(:x and :y) 64 | end 65 | 66 | test "handles mixed double and single negations" do 67 | expr = b(not :a and not not :b and not not not :c) 68 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 69 | assert result == b(not :a and :b and not :c) 70 | end 71 | 72 | test "works with boolean constants in complex expressions" do 73 | expr = b(not not true and not not false) 74 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 75 | assert result == b(true and false) 76 | end 77 | 78 | test "handles negated boolean constants" do 79 | expr = b((not true or not false) and :a) 80 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 81 | assert result == b((false or true) and :a) 82 | end 83 | 84 | test "works with deeply nested negations" do 85 | expr = b(not (not (not (not (:a or :b))))) 86 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 87 | assert result == b(:a or :b) 88 | end 89 | 90 | test "preserves non-double negation patterns" do 91 | expr = b(:a or not (:b and not :c)) 92 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 93 | assert result == b(:a or not (:b and not :c)) 94 | end 95 | 96 | test "handles mixed boolean constant negations" do 97 | expr = b(not true or (not false and :x)) 98 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 99 | assert result == b(false or (true and :x)) 100 | end 101 | 102 | test "works with complex nested boolean structures" do 103 | expr = b(not not ((:a or :b) and not not (:c and :d))) 104 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 105 | assert result == b((:a or :b) and (:c and :d)) 106 | end 107 | 108 | test "leaves non-negation patterns unchanged" do 109 | assert Expression.postwalk(b(:a and :b), &NegationLaw.walk/1) == b(:a and :b) 110 | end 111 | 112 | test "leaves different variables unchanged" do 113 | assert Expression.postwalk(b(:a or :b), &NegationLaw.walk/1) == b(:a or :b) 114 | end 115 | 116 | test "leaves single variables unchanged" do 117 | assert Expression.postwalk(:a, &NegationLaw.walk/1) == :a 118 | end 119 | 120 | test "leaves boolean constants unchanged" do 121 | assert Expression.postwalk(true, &NegationLaw.walk/1) 122 | refute Expression.postwalk(false, &NegationLaw.walk/1) 123 | end 124 | 125 | test "handles partial negation opportunities" do 126 | expr = b(not not :a and (:b or :c)) 127 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 128 | assert result == b(:a and (:b or :c)) 129 | end 130 | 131 | test "works with symmetric negation patterns" do 132 | expr = b((not not :a or :b) and (not true or not not :c)) 133 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 134 | assert result == b((:a or :b) and (false or :c)) 135 | end 136 | 137 | test "handles multiple negation opportunities" do 138 | expr = b(not not :a or (not false and not not :b)) 139 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 140 | assert result == b(:a or (true and :b)) 141 | end 142 | 143 | test "preserves expressions without negation patterns" do 144 | expr = b((:a or :b) and (:c or :d)) 145 | assert Expression.postwalk(expr, &NegationLaw.walk/1) == expr 146 | end 147 | 148 | test "works with very complex expressions" do 149 | expr = b(not not (not true or (not not false and :x))) 150 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 151 | assert result == b(false or (false and :x)) 152 | end 153 | 154 | test "handles alternating negations" do 155 | expr = b(not (not (not (not (not :a))))) 156 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 157 | assert result == b(not :a) 158 | end 159 | end 160 | 161 | describe "edge cases" do 162 | test "handles all negation patterns" do 163 | exprs_and_expected = [ 164 | {b(not true), false}, 165 | {b(not false), true}, 166 | {b(not not :x), :x} 167 | ] 168 | 169 | for {expr, expected} <- exprs_and_expected do 170 | assert Expression.postwalk(expr, &NegationLaw.walk/1) == expected 171 | end 172 | end 173 | 174 | test "works with deeply nested double negations" do 175 | expr = b(not not (not not (not not :complex_expr))) 176 | assert Expression.postwalk(expr, &NegationLaw.walk/1) == :complex_expr 177 | end 178 | 179 | test "preserves single negations in complex patterns" do 180 | expr = b(not (:a and not (:b or not :c))) 181 | assert Expression.postwalk(expr, &NegationLaw.walk/1) == expr 182 | end 183 | 184 | test "handles mixed constant and variable negations" do 185 | expr = b((not not true and not false) or not not :x) 186 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 187 | assert result == b((true and true) or :x) 188 | end 189 | 190 | test "works with very long negation chains" do 191 | # 8 consecutive negations should reduce to the original expression 192 | expr = b(not not not not not not not not :a) 193 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 194 | assert result == :a 195 | end 196 | end 197 | 198 | property "applying negation law is idempotent" do 199 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 200 | result1 = Expression.postwalk(expr, &NegationLaw.walk/1) 201 | result2 = Expression.postwalk(result1, &NegationLaw.walk/1) 202 | assert result1 == result2 203 | end 204 | end 205 | 206 | property "negation law preserves logical equivalence" do 207 | check all( 208 | assignments <- 209 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 210 | variable_names = Map.keys(assignments), 211 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 212 | ) do 213 | result = Expression.postwalk(expr, &NegationLaw.walk/1) 214 | eval_fn = &Map.fetch!(assignments, &1) 215 | 216 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 217 | """ 218 | Negation law changed the logical outcome! 219 | Original: #{inspect(expr, pretty: true)} 220 | Transformed: #{inspect(result, pretty: true)} 221 | Assignments: #{inspect(assignments, pretty: true)} 222 | """ 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/associativity_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.AssociativityLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.AssociativityLaw 13 | 14 | doctest AssociativityLaw, import: true 15 | 16 | describe inspect(&AssociativityLaw.walk/1) do 17 | test "applies OR associativity: A OR (A OR B)" do 18 | assert Expression.postwalk(b(:a or (:a or :b)), &AssociativityLaw.walk/1) == b(:a or :b) 19 | end 20 | 21 | test "applies OR associativity: A OR B OR A" do 22 | assert Expression.postwalk(b(:a or :b or :a), &AssociativityLaw.walk/1) == b(:a or :b) 23 | end 24 | 25 | test "applies AND associativity: A AND (A AND B)" do 26 | assert Expression.postwalk(b(:a and (:a and :b)), &AssociativityLaw.walk/1) == b(:a and :b) 27 | end 28 | 29 | test "applies AND associativity: A AND B AND A" do 30 | assert Expression.postwalk(b(:a and :b and :a), &AssociativityLaw.walk/1) == b(:a and :b) 31 | end 32 | 33 | test "works with complex sub-expressions" do 34 | expr = b((:x and :y) or ((:x and :y) or :z)) 35 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b((:x and :y) or :z) 36 | end 37 | 38 | test "works with negated expressions" do 39 | expr = b(not :a or (not :a or :b)) 40 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b(not :a or :b) 41 | end 42 | 43 | test "handles complex nested associativity" do 44 | expr = b(:p and :q and (:p and :q and (:r and :s))) 45 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b(:p and :q and (:r and :s)) 46 | end 47 | 48 | test "works with deeply nested expressions" do 49 | expr = b(((:a or :b) and :c) or (((:a or :b) and :c) or :d)) 50 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b(((:a or :b) and :c) or :d) 51 | end 52 | 53 | test "handles multiple variables in associativity" do 54 | expr = b(:x or :y or :z or (:x or :y or :z or :w)) 55 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b(:x or :y or :z or :w) 56 | end 57 | 58 | test "works with boolean constants" do 59 | expr = b(true or (true or false)) 60 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b(true or false) 61 | end 62 | 63 | test "handles mixed boolean constants and variables" do 64 | expr = b((:a and false) or ((:a and false) or true)) 65 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b((:a and false) or true) 66 | end 67 | 68 | test "works with complex nested boolean structures" do 69 | expr = b(not (:a or :b) and :c and (not (:a or :b) and :c and (:d or :e))) 70 | expected = b(not (:a or :b) and :c and (:d or :e)) 71 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == expected 72 | end 73 | 74 | test "leaves non-associativity patterns unchanged" do 75 | assert Expression.postwalk(b(:a and :b), &AssociativityLaw.walk/1) == b(:a and :b) 76 | end 77 | 78 | test "leaves different variables unchanged" do 79 | assert Expression.postwalk(b(:a or (:b or :c)), &AssociativityLaw.walk/1) == 80 | b(:a or (:b or :c)) 81 | end 82 | 83 | test "leaves single variables unchanged" do 84 | assert Expression.postwalk(:a, &AssociativityLaw.walk/1) == :a 85 | end 86 | 87 | test "leaves negations unchanged" do 88 | assert Expression.postwalk(b(not :a), &AssociativityLaw.walk/1) == b(not :a) 89 | end 90 | 91 | test "leaves boolean constants unchanged" do 92 | assert Expression.postwalk(true, &AssociativityLaw.walk/1) 93 | refute Expression.postwalk(false, &AssociativityLaw.walk/1) 94 | end 95 | 96 | test "handles partial associativity opportunities" do 97 | expr = b((:a or (:a or :b)) and (:c or :d)) 98 | result = Expression.postwalk(expr, &AssociativityLaw.walk/1) 99 | assert result == b((:a or :b) and (:c or :d)) 100 | end 101 | 102 | test "works with symmetric associativity patterns" do 103 | expr = b(:a and :b and ((:a and :b) or (:c and :d))) 104 | 105 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == 106 | b(:a and :b and ((:a and :b) or (:c and :d))) 107 | end 108 | 109 | test "handles multiple associativity opportunities" do 110 | expr = b((:a or (:a or :b)) and (:c and (:c and :d))) 111 | result = Expression.postwalk(expr, &AssociativityLaw.walk/1) 112 | assert result == b((:a or :b) and (:c and :d)) 113 | end 114 | 115 | test "preserves expressions without associativity patterns" do 116 | expr = b((:a or :b) and (:c or :d)) 117 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == expr 118 | end 119 | 120 | test "works with triple associative expressions" do 121 | expr = b(:x or (:x or (:x or :y))) 122 | result = Expression.postwalk(expr, &AssociativityLaw.walk/1) 123 | assert result == b(:x or :y) 124 | end 125 | 126 | test "handles mixed AND/OR associativity" do 127 | expr = b((:a and (:a and :b)) or (:c or (:c or :d))) 128 | result = Expression.postwalk(expr, &AssociativityLaw.walk/1) 129 | assert result == b((:a and :b) or (:c or :d)) 130 | end 131 | 132 | test "works with very complex identical sub-expressions" do 133 | complex_expr = b(not (:a or :b) and (:c or not :d)) 134 | expr = b(complex_expr or (complex_expr or :simple)) 135 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b(complex_expr or :simple) 136 | end 137 | 138 | test "preserves non-associative complex patterns" do 139 | expr = b((:a and :b) or (:c and (:a or :b))) 140 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == expr 141 | end 142 | end 143 | 144 | describe "edge cases" do 145 | test "handles reverse associativity patterns" do 146 | exprs_and_expected = [ 147 | {b(:x or (:x or :y)), b(:x or :y)}, 148 | {b(:x or :y or :x), b(:x or :y)}, 149 | {b(:x and (:x and :y)), b(:x and :y)}, 150 | {b(:x and :y and :x), b(:x and :y)} 151 | ] 152 | 153 | for {expr, expected} <- exprs_and_expected do 154 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == expected 155 | end 156 | end 157 | 158 | test "works with very complex identical sub-expressions" do 159 | complex_expr = b(not (:a and :b) or (:c and not :d)) 160 | expr = b(complex_expr and (complex_expr and :simple)) 161 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == b(complex_expr and :simple) 162 | end 163 | 164 | test "preserves non-associative complex patterns" do 165 | expr = b(:a and :b and (:c or (:a and :b))) 166 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == expr 167 | end 168 | 169 | test "handles deeply nested associative patterns" do 170 | expr = b(:a and (:b or :c) and (:a and (:b or :c) and (:d and :e))) 171 | expected = b(:a and (:b or :c) and (:d and :e)) 172 | assert Expression.postwalk(expr, &AssociativityLaw.walk/1) == expected 173 | end 174 | 175 | test "works with multiple levels of associativity" do 176 | expr = b(:a or (:b or (:a or (:b or :c)))) 177 | result = Expression.postwalk(expr, &AssociativityLaw.walk/1) 178 | assert result == b(:a or (:b or (:a or (:b or :c)))) 179 | end 180 | end 181 | 182 | property "applying associativity law is idempotent" do 183 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 184 | result1 = Expression.postwalk(expr, &AssociativityLaw.walk/1) 185 | result2 = Expression.postwalk(result1, &AssociativityLaw.walk/1) 186 | assert result1 == result2 187 | end 188 | end 189 | 190 | property "associativity law preserves logical equivalence" do 191 | check all( 192 | assignments <- 193 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 194 | variable_names = Map.keys(assignments), 195 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 196 | ) do 197 | result = Expression.postwalk(expr, &AssociativityLaw.walk/1) 198 | eval_fn = &Map.fetch!(assignments, &1) 199 | 200 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 201 | """ 202 | Associativity law changed the logical outcome! 203 | Original: #{inspect(expr, pretty: true)} 204 | Transformed: #{inspect(result, pretty: true)} 205 | Assignments: #{inspect(assignments, pretty: true)} 206 | """ 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/commutativity_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # credo:disable-for-this-file Credo.Check.Warning.BoolOperationOnSameValues 6 | defmodule Crux.Expression.RewriteRule.CommutativityLawTest do 7 | use ExUnit.Case, async: true 8 | use ExUnitProperties 9 | 10 | import Crux.Expression, only: [b: 1] 11 | 12 | alias Crux.Expression 13 | alias Crux.Expression.RewriteRule.CommutativityLaw 14 | 15 | doctest CommutativityLaw, import: true 16 | 17 | describe inspect(&CommutativityLaw.walk/1) do 18 | test "sorts operands in AND expressions" do 19 | assert Expression.postwalk(b(:b and :a), &CommutativityLaw.walk/1) == b(:a and :b) 20 | end 21 | 22 | test "sorts operands in OR expressions" do 23 | assert Expression.postwalk(b(:z or :x), &CommutativityLaw.walk/1) == b(:x or :z) 24 | end 25 | 26 | test "handles complex expressions with sorting" do 27 | expr = b((:z and :y) or (:b and :a)) 28 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 29 | assert result == b((:a and :b) or (:y and :z)) 30 | end 31 | 32 | test "sorts nested expressions recursively" do 33 | expr = b((:z or :x) and (:b or :a)) 34 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 35 | assert result == b((:a or :b) and (:x or :z)) 36 | end 37 | 38 | test "handles deeply nested expressions" do 39 | expr = b(((:z and :y) or :x) and (:c or (:b and :a))) 40 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 41 | assert result == b((:c or (:a and :b)) and (:x or (:y and :z))) 42 | end 43 | 44 | test "works with boolean constants" do 45 | assert Expression.postwalk(b(false and true), &CommutativityLaw.walk/1) == b(false and true) 46 | assert Expression.postwalk(b(true or false), &CommutativityLaw.walk/1) == b(false or true) 47 | end 48 | 49 | test "handles mixed boolean constants and variables" do 50 | expr = b((:b and true) or (false and :a)) 51 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 52 | assert result == b((:a and false) or (:b and true)) 53 | end 54 | 55 | test "sorts complex nested structures" do 56 | expr = b((not (:z or :x) and :b) or (:a and not (:y or :w))) 57 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 58 | expected = b((:a and not (:w or :y)) or (:b and not (:x or :z))) 59 | assert result == expected 60 | end 61 | 62 | test "leaves single variables unchanged" do 63 | assert Expression.postwalk(:a, &CommutativityLaw.walk/1) == :a 64 | end 65 | 66 | test "leaves simple negations unchanged" do 67 | assert Expression.postwalk(b(not :a), &CommutativityLaw.walk/1) == b(not :a) 68 | end 69 | 70 | test "leaves boolean constants unchanged" do 71 | assert Expression.postwalk(true, &CommutativityLaw.walk/1) 72 | refute Expression.postwalk(false, &CommutativityLaw.walk/1) 73 | end 74 | 75 | test "handles multiple levels of nesting with sorting" do 76 | expr = b((((:d or :c) and (:b or :a)) or :z) and :x) 77 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 78 | expected = b(:x and (:z or ((:a or :b) and (:c or :d)))) 79 | assert result == expected 80 | end 81 | 82 | test "works with symmetric expressions" do 83 | expr = b((:b and :a) or (:a and :b)) 84 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 85 | assert result == b((:a and :b) or (:a and :b)) 86 | end 87 | 88 | test "handles alternating operators with sorting" do 89 | expr = b((:z or (:y and :x)) and (:c or (:b and :a))) 90 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 91 | expected = b((:c or (:a and :b)) and (:z or (:x and :y))) 92 | assert result == expected 93 | end 94 | 95 | test "preserves expression structure while sorting" do 96 | expr = b((:d and (:c or :b)) or (:a and (:z or :y))) 97 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 98 | expected = b((:a and (:y or :z)) or (:d and (:b or :c))) 99 | assert result == expected 100 | end 101 | 102 | test "sorts identical variables consistently" do 103 | expr = b((:a and :a) or (:a and :a)) 104 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 105 | assert result == b((:a and :a) or (:a and :a)) 106 | end 107 | 108 | test "handles very complex nested sorting" do 109 | expr = b(((:w or :v) and (:u or :t)) or ((:s or :r) and (:q or :p))) 110 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 111 | expected = b(((:p or :q) and (:r or :s)) or ((:t or :u) and (:v or :w))) 112 | assert result == expected 113 | end 114 | 115 | test "works with negated complex expressions" do 116 | expr = b(not (:z and :y) or not (:x and :w)) 117 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 118 | expected = b(not (:w and :x) or not (:y and :z)) 119 | assert result == expected 120 | end 121 | 122 | test "preserves non-sortable patterns" do 123 | expr = b(not (not :a)) 124 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 125 | assert result == b(not (not :a)) 126 | end 127 | 128 | test "handles triple nested expressions" do 129 | expr = b(((:c or :b) and :a) or ((:f or :e) and :d)) 130 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 131 | expected = b((:a and (:b or :c)) or (:d and (:e or :f))) 132 | assert result == expected 133 | end 134 | 135 | test "sorts with very long expressions" do 136 | expr = b((:h or :g) and (:f or :e) and ((:d or :c) and (:b or :a))) 137 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 138 | expected = b((:a or :b) and (:c or :d) and ((:e or :f) and (:g or :h))) 139 | assert result == expected 140 | end 141 | end 142 | 143 | describe "edge cases" do 144 | test "handles all balance patterns" do 145 | exprs_and_expected = [ 146 | {b(:b and :a), b(:a and :b)}, 147 | {b(:z or :x), b(:x or :z)}, 148 | {b((:y and :x) or (:b and :a)), b((:a and :b) or (:x and :y))}, 149 | {b((:z or :y) and (:x or :w)), b((:w or :x) and (:y or :z))} 150 | ] 151 | 152 | for {expr, expected} <- exprs_and_expected do 153 | assert Expression.postwalk(expr, &CommutativityLaw.walk/1) == expected 154 | end 155 | end 156 | 157 | test "works with deeply nested balance requirements" do 158 | expr = b((((:e or :d) and (:c or :b)) or :a) and :z and :y) 159 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 160 | expected = b(:y and (:z and (:a or ((:b or :c) and (:d or :e))))) 161 | assert result == expected 162 | end 163 | 164 | test "preserves non-balance complex patterns" do 165 | expr = b(not (:a and :b) and not (:c or :d)) 166 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 167 | expected = b(not (:a and :b) and not (:c or :d)) 168 | assert result == expected 169 | end 170 | 171 | test "handles mixed balance and non-balance" do 172 | expr = b(:b and :a and not (:c or :d)) 173 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 174 | expected = b(not (:c or :d) and (:a and :b)) 175 | assert result == expected 176 | end 177 | 178 | test "works with complex mixed operators" do 179 | expr = b(((:d or :c) and (:b and :a)) or ((:h or :g) and (:f and :e))) 180 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 181 | expected = b((:a and :b and (:c or :d)) or (:e and :f and (:g or :h))) 182 | assert result == expected 183 | end 184 | end 185 | 186 | property "applying balance law is idempotent" do 187 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 188 | result1 = Expression.postwalk(expr, &CommutativityLaw.walk/1) 189 | result2 = Expression.postwalk(result1, &CommutativityLaw.walk/1) 190 | assert result1 == result2 191 | end 192 | end 193 | 194 | property "balance law preserves logical equivalence" do 195 | check all( 196 | assignments <- 197 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 198 | variable_names = Map.keys(assignments), 199 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 200 | ) do 201 | result = Expression.postwalk(expr, &CommutativityLaw.walk/1) 202 | eval_fn = &Map.fetch!(assignments, &1) 203 | 204 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 205 | """ 206 | Balance law changed the logical outcome! 207 | Original: #{inspect(expr, pretty: true)} 208 | Transformed: #{inspect(result, pretty: true)} 209 | Assignments: #{inspect(assignments, pretty: true)} 210 | """ 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/consensus_theorem_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.ConsensusTheoremTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.ConsensusTheorem 13 | 14 | doctest ConsensusTheorem, import: true 15 | 16 | describe inspect(&ConsensusTheorem.walk/1) do 17 | test "applies consensus theorem: (A OR B) AND (NOT A OR C) AND (B OR C)" do 18 | expr = b((:a or :b) and (not :a or :c) and (:b or :c)) 19 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 20 | assert result == b((:a or :b) and (not :a or :c)) 21 | end 22 | 23 | test "applies consensus theorem: (NOT A OR C) AND (A OR B) AND (B OR C)" do 24 | expr = b((not :a or :c) and (:a or :b) and (:b or :c)) 25 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 26 | assert result == b((not :a or :c) and (:a or :b)) 27 | end 28 | 29 | test "applies consensus theorem: (B OR C) AND (A OR B) AND (NOT A OR C)" do 30 | expr = b((:b or :c) and (:a or :b) and (not :a or :c)) 31 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 32 | assert result == b((:a or :b) and (not :a or :c)) 33 | end 34 | 35 | test "applies consensus theorem: (A AND B) OR (NOT A AND C) OR (B AND C)" do 36 | expr = b((:a and :b) or (not :a and :c) or (:b and :c)) 37 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 38 | assert result == b((:a and :b) or (not :a and :c)) 39 | end 40 | 41 | test "applies consensus theorem: (NOT A AND C) OR (A AND B) OR (B AND C)" do 42 | expr = b((not :a and :c) or (:a and :b) or (:b and :c)) 43 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 44 | assert result == b((not :a and :c) or (:a and :b)) 45 | end 46 | 47 | test "applies consensus theorem: (B AND C) OR (A AND B) OR (NOT A AND C)" do 48 | expr = b((:b and :c) or (:a and :b) or (not :a and :c)) 49 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 50 | assert result == b((:a and :b) or (not :a and :c)) 51 | end 52 | 53 | test "works with complex sub-expressions" do 54 | # Using (X AND Y) as A, Z as B, W as C 55 | expr = b(((:x and :y) or :z) and (not (:x and :y) or :w) and (:z or :w)) 56 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 57 | assert result == b(((:x and :y) or :z) and (not (:x and :y) or :w)) 58 | end 59 | 60 | test "works with negated complex sub-expressions" do 61 | # Using NOT (P OR Q) as A, R as B, S as C 62 | complex_a = b(not (:p or :q)) 63 | expr = b((complex_a or :r) and (not complex_a or :s) and (:r or :s)) 64 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 65 | assert result == b((complex_a or :r) and (not complex_a or :s)) 66 | end 67 | 68 | test "handles multiple consensus opportunities" do 69 | # Two separate consensus patterns in one expression 70 | expr = 71 | b( 72 | (:a or :b) and (not :a or :c) and (:b or :c) and 73 | ((:x or :y) and (not :x or :z) and (:y or :z)) 74 | ) 75 | 76 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 77 | assert result == b((:a or :b) and (not :a or :c) and ((:x or :y) and (not :x or :z))) 78 | end 79 | 80 | test "works with deeply nested expressions" do 81 | # Consensus within a larger expression 82 | expr = b((:m and :n) or ((:a or :b) and (not :a or :c) and (:b or :c))) 83 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 84 | assert result == b((:m and :n) or ((:a or :b) and (not :a or :c))) 85 | end 86 | 87 | test "leaves non-consensus patterns unchanged" do 88 | # Missing the third clause 89 | expr = b((:a or :b) and (not :a or :c)) 90 | assert Expression.postwalk(expr, &ConsensusTheorem.walk/1) == expr 91 | end 92 | 93 | test "leaves different variable patterns unchanged" do 94 | # Variables don't match consensus pattern 95 | expr = b((:a or :b) and (not :c or :d) and (:e or :f)) 96 | assert Expression.postwalk(expr, &ConsensusTheorem.walk/1) == expr 97 | end 98 | 99 | test "leaves single variables unchanged" do 100 | assert Expression.postwalk(:a, &ConsensusTheorem.walk/1) == :a 101 | end 102 | 103 | test "leaves simple expressions unchanged" do 104 | assert Expression.postwalk(b(:a and :b), &ConsensusTheorem.walk/1) == b(:a and :b) 105 | assert Expression.postwalk(b(:a or :b), &ConsensusTheorem.walk/1) == b(:a or :b) 106 | end 107 | 108 | test "leaves boolean constants unchanged" do 109 | assert Expression.postwalk(true, &ConsensusTheorem.walk/1) 110 | refute Expression.postwalk(false, &ConsensusTheorem.walk/1) 111 | end 112 | 113 | test "handles partial consensus patterns" do 114 | # Has two consensus clauses but third doesn't match 115 | expr = b((:a or :b) and (not :a or :c) and (:x or :y)) 116 | assert Expression.postwalk(expr, &ConsensusTheorem.walk/1) == expr 117 | end 118 | 119 | test "works with very complex identical sub-expressions" do 120 | complex_expr = b((:p and :q) or (:r and not :s)) 121 | expr = b((complex_expr or :t) and (not complex_expr or :u) and (:t or :u)) 122 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 123 | assert result == b((complex_expr or :t) and (not complex_expr or :u)) 124 | end 125 | 126 | test "preserves non-consensus complex patterns" do 127 | expr = b((:a and :b) or (:c and :d) or (:e and :f)) 128 | assert Expression.postwalk(expr, &ConsensusTheorem.walk/1) == expr 129 | end 130 | end 131 | 132 | describe "edge cases" do 133 | test "handles all consensus ordering patterns" do 134 | # Test all possible orderings systematically 135 | base_clauses = [ 136 | {:a_or_b, b((:a or :b) and (not :a or :c) and (:b or :c))}, 137 | {:not_a_or_c, b((not :a or :c) and (:a or :b) and (:b or :c))}, 138 | {:b_or_c, b((:b or :c) and (:a or :b) and (not :a or :c))} 139 | ] 140 | 141 | for {_label, expr} <- base_clauses do 142 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 143 | # CommutativityLaw may reorder clauses, so check for logical equivalence 144 | expected = b((:a or :b) and (not :a or :c)) 145 | assert result == expected or result == b((not :a or :c) and (:a or :b)) 146 | end 147 | end 148 | 149 | test "handles consensus with boolean constants" do 150 | # A = true, B = :b, C = :c 151 | expr = b((true or :b) and (not true or :c) and (:b or :c)) 152 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 153 | assert result == b((true or :b) and (not true or :c)) 154 | end 155 | 156 | test "preserves expressions without consensus patterns" do 157 | expr = b((:a or :b) and (:c or :d) and (:e or :f)) 158 | assert Expression.postwalk(expr, &ConsensusTheorem.walk/1) == expr 159 | end 160 | 161 | test "handles mixed AND and OR operations" do 162 | # Should not match consensus (mixing AND/OR incorrectly) 163 | expr = b(:a or :b or (not :a and :c) or (:b and :c)) 164 | assert Expression.postwalk(expr, &ConsensusTheorem.walk/1) == expr 165 | end 166 | 167 | test "works with symmetric consensus patterns" do 168 | # Multiple variables in symmetric positions 169 | expr = b((:x or :y) and (not :x or :z) and (:y or :z)) 170 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 171 | assert result == b((:x or :y) and (not :x or :z)) 172 | end 173 | end 174 | 175 | property "applying consensus theorem is idempotent" do 176 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 177 | result1 = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 178 | result2 = Expression.postwalk(result1, &ConsensusTheorem.walk/1) 179 | assert result1 == result2 180 | end 181 | end 182 | 183 | property "consensus theorem preserves logical equivalence" do 184 | check all( 185 | assignments <- 186 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 187 | variable_names = Map.keys(assignments), 188 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 189 | ) do 190 | result = Expression.postwalk(expr, &ConsensusTheorem.walk/1) 191 | eval_fn = &Map.fetch!(assignments, &1) 192 | 193 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 194 | """ 195 | Consensus theorem changed the logical outcome! 196 | Original: #{inspect(expr, pretty: true)} 197 | Transformed: #{inspect(result, pretty: true)} 198 | Assignments: #{inspect(assignments, pretty: true)} 199 | """ 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/complement_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.ComplementLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.ComplementLaw 13 | 14 | doctest ComplementLaw, import: true 15 | 16 | describe inspect(&ComplementLaw.walk/1) do 17 | test "applies A OR NOT A = true" do 18 | assert Expression.postwalk(b(:a or not :a), &ComplementLaw.walk/1) 19 | end 20 | 21 | test "applies NOT A OR A = true" do 22 | assert Expression.postwalk(b(not :a or :a), &ComplementLaw.walk/1) 23 | end 24 | 25 | test "applies A AND NOT A = false" do 26 | refute Expression.postwalk(b(:a and not :a), &ComplementLaw.walk/1) 27 | end 28 | 29 | test "applies NOT A AND A = false" do 30 | refute Expression.postwalk(b(not :a and :a), &ComplementLaw.walk/1) 31 | end 32 | 33 | test "applies (A AND B) OR (A AND NOT B) = A" do 34 | expr = b((:a and :b) or (:a and not :b)) 35 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == :a 36 | end 37 | 38 | test "applies (A OR B) AND (A OR NOT B) = A" do 39 | expr = b((:a or :b) and (:a or not :b)) 40 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == :a 41 | end 42 | 43 | test "works with complex sub-expressions" do 44 | expr = b((:x and :y) or not (:x and :y)) 45 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) 46 | end 47 | 48 | test "works with negated complex expressions" do 49 | expr = b(not (:a and :b) and (:a and :b)) 50 | refute Expression.postwalk(expr, &ComplementLaw.walk/1) 51 | end 52 | 53 | test "handles deeply nested complement patterns" do 54 | expr = b(((:a or :b) and :c) or not ((:a or :b) and :c)) 55 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) 56 | end 57 | 58 | test "works with boolean constants" do 59 | assert Expression.postwalk(b(true or not true), &ComplementLaw.walk/1) 60 | refute Expression.postwalk(b(false and not false), &ComplementLaw.walk/1) 61 | end 62 | 63 | test "handles mixed boolean constants and variables" do 64 | assert Expression.postwalk(b((:a and true) or not (:a and true)), &ComplementLaw.walk/1) 65 | end 66 | 67 | test "works with complement distribution patterns" do 68 | expr = b((:a and :b and :x) or (:a and :b and not :x)) 69 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == b(:a and :b) 70 | end 71 | 72 | test "handles OR distribution with complements" do 73 | expr = b((:a or :b or :x) and (:a or :b or not :x)) 74 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == b(:a or :b) 75 | end 76 | 77 | test "works with multiple variables in complement patterns" do 78 | expr = b((:x and :y and :z) or not (:x and :y and :z)) 79 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) 80 | end 81 | 82 | test "handles nested complement distribution" do 83 | expr = b(((not :a or :b) and :c) or ((not :a or :b) and not :c)) 84 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == b(not :a or :b) 85 | end 86 | 87 | test "works with symmetric complement patterns" do 88 | expr = b((not (:a and :b) or :c) and (not (:a and :b) or not :c)) 89 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == b(not (:a and :b)) 90 | end 91 | 92 | test "leaves non-complement patterns unchanged" do 93 | assert Expression.postwalk(b(:a and :b), &ComplementLaw.walk/1) == b(:a and :b) 94 | end 95 | 96 | test "leaves different variables unchanged" do 97 | assert Expression.postwalk(b(:a or not :b), &ComplementLaw.walk/1) == b(:a or not :b) 98 | end 99 | 100 | test "leaves single variables unchanged" do 101 | assert Expression.postwalk(:a, &ComplementLaw.walk/1) == :a 102 | end 103 | 104 | test "leaves simple negations unchanged" do 105 | assert Expression.postwalk(b(not :a), &ComplementLaw.walk/1) == b(not :a) 106 | end 107 | 108 | test "leaves boolean constants unchanged" do 109 | assert Expression.postwalk(true, &ComplementLaw.walk/1) 110 | refute Expression.postwalk(false, &ComplementLaw.walk/1) 111 | end 112 | 113 | test "handles partial complement opportunities" do 114 | expr = b((:a or not :a) and (:b and :c)) 115 | result = Expression.postwalk(expr, &ComplementLaw.walk/1) 116 | assert result == b(true and (:b and :c)) 117 | end 118 | 119 | test "works with mixed complement types" do 120 | expr = b(:a or not :a or (:b and not :b)) 121 | result = Expression.postwalk(expr, &ComplementLaw.walk/1) 122 | assert result == b(true or false) 123 | end 124 | 125 | test "handles multiple complement opportunities" do 126 | expr = b((:a or not :a) and (:b and not :b)) 127 | result = Expression.postwalk(expr, &ComplementLaw.walk/1) 128 | assert result == b(true and false) 129 | end 130 | 131 | test "preserves expressions without complement patterns" do 132 | expr = b((:a or :b) and (:c or :d)) 133 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == expr 134 | end 135 | 136 | test "works with triple complement expressions" do 137 | expr = b(:x or (not :x or (:x and not :x))) 138 | result = Expression.postwalk(expr, &ComplementLaw.walk/1) 139 | assert result == b(:x or (not :x or false)) 140 | end 141 | 142 | test "handles complex distribution patterns" do 143 | expr = b((not :a and :b and :x) or (not :a and :b and not :x)) 144 | result = Expression.postwalk(expr, &ComplementLaw.walk/1) 145 | assert result == b(not :a and :b) 146 | end 147 | 148 | test "works with very complex identical sub-expressions" do 149 | complex_expr = b(not (:a or :b) and (:c or not :d)) 150 | expr = b(complex_expr or not complex_expr) 151 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) 152 | end 153 | 154 | test "preserves non-complement complex patterns" do 155 | expr = b((:a and :b) or (:c and not :d)) 156 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == expr 157 | end 158 | end 159 | 160 | describe "edge cases" do 161 | test "handles all complement patterns" do 162 | exprs_and_expected = [ 163 | {b(:x or not :x), true}, 164 | {b(not :x or :x), true}, 165 | {b(:x and not :x), false}, 166 | {b(not :x and :x), false} 167 | ] 168 | 169 | for {expr, expected} <- exprs_and_expected do 170 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == expected 171 | end 172 | end 173 | 174 | test "handles distribution patterns" do 175 | exprs_and_expected = [ 176 | {b((:x and :y) or (:x and not :y)), :x}, 177 | {b((:x or :y) and (:x or not :y)), :x} 178 | ] 179 | 180 | for {expr, expected} <- exprs_and_expected do 181 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == expected 182 | end 183 | end 184 | 185 | test "works with deeply nested complement distribution" do 186 | expr = b((:a and (:b or :c) and :x) or (:a and (:b or :c) and not :x)) 187 | expected = b(:a and (:b or :c)) 188 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == expected 189 | end 190 | 191 | test "preserves non-complement distributive patterns" do 192 | expr = b((:a and :b) or (:a and :c)) 193 | assert Expression.postwalk(expr, &ComplementLaw.walk/1) == expr 194 | end 195 | 196 | test "handles mixed complement and non-complement" do 197 | expr = b((:a or not :a) and (:b or :c)) 198 | result = Expression.postwalk(expr, &ComplementLaw.walk/1) 199 | assert result == b(true and (:b or :c)) 200 | end 201 | end 202 | 203 | property "applying complement law is idempotent" do 204 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 205 | result1 = Expression.postwalk(expr, &ComplementLaw.walk/1) 206 | result2 = Expression.postwalk(result1, &ComplementLaw.walk/1) 207 | assert result1 == result2 208 | end 209 | end 210 | 211 | property "complement law preserves logical equivalence" do 212 | check all( 213 | assignments <- 214 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 215 | variable_names = Map.keys(assignments), 216 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 217 | ) do 218 | result = Expression.postwalk(expr, &ComplementLaw.walk/1) 219 | eval_fn = &Map.fetch!(assignments, &1) 220 | 221 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 222 | """ 223 | Complement law changed the logical outcome! 224 | Original: #{inspect(expr, pretty: true)} 225 | Transformed: #{inspect(result, pretty: true)} 226 | Assignments: #{inspect(assignments, pretty: true)} 227 | """ 228 | end 229 | end 230 | end 231 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | # Crux 8 | 9 | 10 | ![Elixir CI](https://github.com/ash-project/crux/workflows/Ash%20CI/badge.svg) 11 | [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ash-project/crux/badge)](https://scorecard.dev/viewer/?uri=github.com/ash-project/crux) 12 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 13 | [![Hex version badge](https://img.shields.io/hexpm/v/crux.svg)](https://hex.pm/packages/crux) 14 | [![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/crux) 15 | [![REUSE status](https://api.reuse.software/badge/github.com/ash-project/crux)](https://api.reuse.software/info/github.com/ash-project/crux) 16 | [![Crux DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ash-project/crux) 17 | 18 | Crux is a powerful Elixir library for boolean satisfiability (SAT) solving, 19 | boolean expression manipulation, and constraint satisfaction. It provides an 20 | intuitive DSL for creating boolean expressions and a comprehensive toolkit for 21 | working with satisfiability problems. 22 | 23 | ## Features 24 | 25 | - **Boolean Expression DSL** - Intuitive macro for creating complex boolean 26 | expressions 27 | - **SAT Solving** - Solve satisfiability problems with multiple backend solvers 28 | - **Expression Manipulation** - Simplify, evaluate, and transform boolean 29 | expressions 30 | - **CNF Conversion** - Convert expressions to Conjunctive Normal Form for SAT 31 | solving 32 | - **Decision Trees** - Build binary decision trees for exploring satisfying 33 | assignments 34 | - **Constraint Helpers** - Built-in functions for common constraint patterns 35 | - **Multiple Backends** - Support for PicoSAT (fast NIF) and SimpleSAT (pure 36 | Elixir) 37 | 38 | ## Installation 39 | 40 | Add `crux` to your list of dependencies in `mix.exs`: 41 | 42 | ```elixir 43 | def deps do 44 | [ 45 | {:crux, "~> 0.1.2"}, 46 | # Choose one SAT solver backend: 47 | {:picosat_elixir, "~> 0.2"}, # Recommended: Fast NIF-based solver 48 | # OR 49 | {:simple_sat, "~> 0.1"} # Pure Elixir alternative 50 | ] 51 | end 52 | ``` 53 | 54 | ## Quick Start 55 | 56 | ### Creating Boolean Expressions 57 | 58 | Use the `b/1` macro to create boolean expressions with an intuitive syntax: 59 | 60 | ```elixir 61 | import Crux.Expression 62 | 63 | # Basic boolean operations 64 | expr = b(:user_logged_in and (:is_admin or :is_moderator)) 65 | 66 | # Advanced boolean operators 67 | expr = b(xor(:payment_cash, :payment_card)) # exactly one payment method 68 | expr = b(implies(:is_student, :gets_discount)) # if student then discount 69 | ``` 70 | 71 | ### Solving Satisfiability Problems 72 | 73 | Convert expressions to formulas and solve them: 74 | 75 | ```elixir 76 | alias Crux.{Expression, Formula} 77 | 78 | # Create and solve a formula 79 | expression = Expression.b(:a and (:b or :c)) 80 | formula = Formula.from_expression(expression) 81 | 82 | case Crux.solve(formula) do 83 | {:ok, solution} -> 84 | IO.inspect(solution) # %{a: true, b: true, c: false} 85 | {:error, :unsatisfiable} -> 86 | IO.puts("No solution exists") 87 | end 88 | ``` 89 | 90 | ### Expression Manipulation 91 | 92 | ```elixir 93 | import Crux.Expression 94 | 95 | # Simplify expressions 96 | complex_expr = b((:a and true) or (false and :b)) 97 | simple_expr = Expression.simplify(complex_expr) # :a 98 | 99 | # Evaluate with variable assignments 100 | result = Expression.run(b(:a and :b), fn 101 | :a -> true 102 | :b -> false 103 | end) # false 104 | 105 | # Convert to Conjunctive Normal Form 106 | cnf_expr = Expression.to_cnf(b(:a or (:b and :c))) 107 | ``` 108 | 109 | ## Core Concepts 110 | 111 | ### Expressions 112 | 113 | Boolean expressions are the foundation of Crux. They support: 114 | - Variables (atoms like `:user`, `:admin`) 115 | - Constants (`true`, `false`) 116 | - Basic operators (`and`, `or`, `not`) 117 | - Advanced operators (`xor`, `nand`, `nor`, `implies`, `implied_by`, `xnor`) 118 | 119 | ### Formulas 120 | 121 | Formulas are expressions converted to Conjunctive Normal Form (CNF) for SAT solving: 122 | 123 | ```elixir 124 | formula = Formula.from_expression(Expression.b(:a and :b)) 125 | # %Formula{ 126 | # cnf: [[1], [2]], 127 | # bindings: %{1 => :a, 2 => :b}, 128 | # reverse_bindings: %{a: 1, b: 2} 129 | # } 130 | ``` 131 | 132 | ### SAT Solving 133 | 134 | Crux can determine if boolean formulas are satisfiable and find satisfying assignments: 135 | 136 | ```elixir 137 | # Check satisfiability 138 | Crux.satisfiable?(formula) # true/false 139 | 140 | # Find all satisfying scenarios 141 | Crux.satisfying_scenarios(formula) # [%{a: true, b: true}] 142 | 143 | # Build decision trees 144 | tree = Crux.decision_tree(formula) # {:a, false, {:b, false, true}} 145 | ``` 146 | 147 | ## API Overview 148 | 149 | ### Core Modules 150 | 151 | - **`Crux`** - Main SAT solving functions (`solve/1`, `satisfiable?/1`, `decision_tree/2`) 152 | - **`Crux.Expression`** - Boolean expression creation and manipulation 153 | - **`Crux.Formula`** - CNF formula representation and conversion 154 | 155 | ### Expression Functions 156 | 157 | ```elixir 158 | # Creation 159 | Expression.b(:a and :b) 160 | 161 | # Manipulation 162 | Expression.simplify/1 # Simplify expressions 163 | Expression.to_cnf/1 # Convert to CNF 164 | Expression.balance/1 # Normalize operand order 165 | 166 | # Evaluation 167 | Expression.run/2 # Evaluate with variable bindings 168 | Expression.expand/2 # Expand with custom callbacks 169 | 170 | # Traversal 171 | Expression.prewalk/2 # Pre-order traversal 172 | Expression.postwalk/2 # Post-order traversal 173 | 174 | # Constraint helpers 175 | Expression.at_most_one/1 # At most one variable true 176 | Expression.exactly_one/1 # Exactly one variable true 177 | Expression.all_or_none/1 # All variables same value 178 | ``` 179 | 180 | ## SAT Solver Backends 181 | 182 | Crux supports multiple SAT solver backends: 183 | 184 | ### PicoSAT (Recommended) 185 | 186 | ```elixir 187 | {:picosat_elixir, "~> 0.2"} 188 | ``` 189 | - Fast NIF-based solver 190 | - Production-ready and battle-tested 191 | - Best performance for large problems 192 | 193 | ### SimpleSAT 194 | 195 | ```elixir 196 | {:simple_sat, "~> 0.1"} 197 | ``` 198 | - Pure Elixir implementation 199 | - No NIF dependencies 200 | - Suitable for smaller problems or when avoiding NIFs 201 | 202 | ## Advanced Features 203 | 204 | ### Decision Trees 205 | 206 | Build binary decision trees to explore all satisfying assignments: 207 | 208 | ```elixir 209 | formula = Formula.from_expression(Expression.b(:a and :b)) 210 | tree = Crux.decision_tree(formula, sorter: &<=/2) 211 | # {:a, false, {:b, false, true}} 212 | ``` 213 | 214 | ### Constraint Patterns 215 | 216 | Crux provides helpers for common constraint satisfaction patterns: 217 | 218 | ```elixir 219 | import Crux.Expression 220 | 221 | # User can have at most one role 222 | roles = [:admin, :moderator, :user] 223 | at_most_one_role = at_most_one(roles) 224 | 225 | # Payment methods - exactly one must be selected 226 | payment_methods = [:cash, :card, :paypal] 227 | payment_constraint = exactly_one(payment_methods) 228 | 229 | # Feature flags - all related features synchronized 230 | related_features = [:dark_mode_ui, :dark_mode_api] 231 | sync_constraint = all_or_none(related_features) 232 | ``` 233 | 234 | ### Domain Knowledge Integration 235 | 236 | Provide custom conflict and implication rules for domain-specific validation: 237 | 238 | ```elixir 239 | opts = [ 240 | conflicts?: fn 241 | :admin, :guest -> true # admin and guest roles conflict 242 | _, _ -> false 243 | end, 244 | implies?: fn 245 | :admin, :can_delete -> true # admin implies delete permission 246 | _, _ -> false 247 | end 248 | ] 249 | 250 | scenarios = Crux.satisfying_scenarios(formula, opts) 251 | ``` 252 | 253 | ## Use Cases 254 | 255 | ### Authorization Policies 256 | 257 | Model complex authorization rules: 258 | 259 | ```elixir 260 | import Crux.Expression 261 | 262 | # User access policy 263 | policy = b( 264 | (:is_owner or :is_admin) and 265 | not :is_suspended and 266 | (:has_subscription or :is_trial_user) 267 | ) 268 | 269 | # Check if a specific user satisfies the policy 270 | user_context = %{ 271 | is_owner: false, 272 | is_admin: true, 273 | is_suspended: false, 274 | has_subscription: true, 275 | is_trial_user: false 276 | } 277 | 278 | result = Expression.run(policy, fn var -> Map.get(user_context, var, false) end) 279 | 280 | case result do 281 | true -> :access_granted 282 | false -> :access_denied 283 | end 284 | ``` 285 | 286 | ### Resource Scheduling 287 | 288 | Model resource allocation constraints: 289 | 290 | ```elixir 291 | # Meeting room scheduling 292 | rooms = [:room_a, :room_b, :room_c] 293 | time_slots = [:slot_1, :slot_2, :slot_3] 294 | 295 | constraints = for room <- rooms do 296 | # Each room can be booked at most once per time slot 297 | at_most_one(for slot <- time_slots, do: :"#{room}_#{slot}") 298 | end 299 | ``` 300 | 301 | 302 | ## License 303 | 304 | This project is licensed under the MIT License - see the [LICENSES](LICENSES) 305 | directory for details. 306 | -------------------------------------------------------------------------------- /lib/crux/formula.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Formula do 6 | @moduledoc """ 7 | A module for representing and manipulating satisfiability formulas in 8 | Conjunctive Normal Form (CNF). 9 | """ 10 | 11 | import Crux.Expression, only: [b: 1] 12 | 13 | alias Crux.Expression 14 | 15 | @typedoc """ 16 | A satisfiability formula in Conjunctive Normal Form (CNF) along with 17 | bindings that map the integers used in the CNF back to their original values. 18 | """ 19 | @type t(variable) :: %__MODULE__{ 20 | cnf: cnf(), 21 | bindings: bindings(variable), 22 | reverse_bindings: reverse_bindings(variable) 23 | } 24 | 25 | @typedoc """ 26 | See `t/1`. 27 | """ 28 | @type t() :: t(term()) 29 | 30 | @typedoc """ 31 | A formula in Conjunctive Normal Form (CNF) is a conjunction of clauses, 32 | where each `clause()` is a disjunction of `literal()`s. 33 | 34 | All `clause()`s of a CNF formula must be satisfied for the formula to be satisfied. 35 | """ 36 | @type cnf() :: [clause()] 37 | 38 | @typedoc """ 39 | A clause is a disjunction of `literal()`s. 40 | 41 | A clause is satisfied if at least one of its `literal()`s is satisfied. 42 | """ 43 | @type clause() :: nonempty_list(literal()) 44 | 45 | @typedoc """ 46 | A `literal()` is either an `affirmed_literal()` (a positive integer) or 47 | a `negated_literal()` (a negative integer). 48 | """ 49 | @type literal() :: affirmed_literal() | negated_literal() 50 | 51 | @typedoc """ 52 | An `affirmed_literal()` is a positive integer representing a variable that is 53 | asserted to be true. 54 | """ 55 | @type affirmed_literal() :: pos_integer() 56 | 57 | @typedoc """ 58 | A `negated_literal()` is a negative integer representing a variable that is 59 | asserted to be false. 60 | """ 61 | @type negated_literal() :: neg_integer() 62 | 63 | @typedoc """ 64 | A `binding()` maps a positive integer (the variable) to its original value. 65 | """ 66 | @type bindings(variable) :: %{pos_integer() => variable} 67 | 68 | @typedoc """ 69 | A reverse binding maps a variable to its positive integer representation. 70 | This provides O(log n) lookup for variable-to-integer mappings. 71 | """ 72 | @type reverse_bindings(variable) :: %{variable => pos_integer()} 73 | 74 | @typedoc """ 75 | See `bindings/1`. 76 | """ 77 | @type bindings() :: bindings(term()) 78 | 79 | @enforce_keys [:cnf, :bindings, :reverse_bindings] 80 | defstruct [:cnf, :bindings, :reverse_bindings] 81 | 82 | @simple_true %{__struct__: __MODULE__, cnf: [], bindings: %{}, reverse_bindings: %{}} 83 | @simple_false %{ 84 | __struct__: __MODULE__, 85 | cnf: [[1], [-1]], 86 | bindings: %{1 => false}, 87 | reverse_bindings: %{false => 1} 88 | } 89 | 90 | @doc """ 91 | Converts a boolean expression to a SAT formula in Conjunctive Normal Form (CNF). 92 | 93 | ## Examples 94 | 95 | iex> import Crux.Expression 96 | ...> expression = b(:a and :b) 97 | ...> Formula.from_expression(expression) 98 | %Formula{ 99 | cnf: [[1], [2]], 100 | bindings: %{1 => :a, 2 => :b}, 101 | reverse_bindings: %{a: 1, b: 2} 102 | } 103 | 104 | iex> expression = b(:x or not :y) 105 | ...> Formula.from_expression(expression) 106 | %Formula{ 107 | cnf: [[1, -2]], 108 | bindings: %{1 => :x, 2 => :y}, 109 | reverse_bindings: %{x: 1, y: 2} 110 | } 111 | 112 | """ 113 | @spec from_expression(Expression.t(variable)) :: t(variable) when variable: term() 114 | def from_expression(expression) do 115 | expression 116 | |> Expression.balance() 117 | |> Expression.simplify() 118 | |> case do 119 | true -> 120 | @simple_true 121 | 122 | false -> 123 | @simple_false 124 | 125 | expression -> 126 | {bindings, reverse_bindings, expression} = 127 | expression 128 | |> Expression.to_cnf() 129 | |> extract_bindings() 130 | 131 | %__MODULE__{ 132 | cnf: expression |> lift_clauses() |> Enum.uniq(), 133 | bindings: bindings, 134 | reverse_bindings: reverse_bindings 135 | } 136 | end 137 | end 138 | 139 | @doc """ 140 | Converts a SAT formula back to a boolean expression. 141 | 142 | ## Examples 143 | 144 | iex> formula = %Formula{ 145 | ...> cnf: [[1], [2]], 146 | ...> bindings: %{1 => :a, 2 => :b}, 147 | ...> reverse_bindings: %{a: 1, b: 2} 148 | ...> } 149 | ...> 150 | ...> Formula.to_expression(formula) 151 | b(:a and :b) 152 | 153 | iex> formula = %Formula{ 154 | ...> cnf: [[1, -2]], 155 | ...> bindings: %{1 => :x, 2 => :y}, 156 | ...> reverse_bindings: %{x: 1, y: 2} 157 | ...> } 158 | ...> 159 | ...> Formula.to_expression(formula) 160 | b(:x or not :y) 161 | 162 | """ 163 | @spec to_expression(formula :: t(variable)) :: Expression.cnf(variable) when variable: term() 164 | def to_expression(formula) 165 | def to_expression(@simple_true), do: true 166 | def to_expression(@simple_false), do: false 167 | 168 | def to_expression(%__MODULE__{cnf: cnf, bindings: bindings}) do 169 | cnf 170 | |> Enum.map(&clause_to_expression(&1, bindings)) 171 | |> Enum.reduce(&b(&2 and &1)) 172 | end 173 | 174 | @doc """ 175 | Formats a CNF formula to PicoSAT DIMACS format. 176 | 177 | Takes a formula struct and returns a string in the DIMACS CNF format 178 | that can be consumed by SAT solvers like PicoSAT. 179 | 180 | ## Examples 181 | 182 | iex> alias Crux.{Expression, Formula} 183 | ...> formula = Formula.from_expression(Expression.b(:a and :b)) 184 | ...> Formula.to_picosat(formula) 185 | "p cnf 2 2\\n1 0\\n2 0" 186 | 187 | """ 188 | @spec to_picosat(t()) :: String.t() 189 | def to_picosat(%__MODULE__{cnf: clauses, bindings: bindings}) do 190 | variable_count = map_size(bindings) 191 | clause_count = length(clauses) 192 | 193 | formatted_input = 194 | Enum.map_join(clauses, "\n", fn clause -> 195 | Enum.join(clause, " ") <> " 0" 196 | end) 197 | 198 | "p cnf #{variable_count} #{clause_count}\n" <> formatted_input 199 | end 200 | 201 | @doc false 202 | @spec simple_true() :: t() 203 | def simple_true, do: @simple_true 204 | 205 | @doc false 206 | @spec simple_false() :: t() 207 | def simple_false, do: @simple_false 208 | 209 | @spec clause_to_expression(clause(), bindings(variable)) :: Expression.t(variable) 210 | when variable: term() 211 | defp clause_to_expression(clause, bindings) do 212 | clause 213 | |> Enum.map(&literal_to_expression(&1, bindings)) 214 | |> Enum.reduce(&b(&2 or &1)) 215 | end 216 | 217 | @spec literal_to_expression(literal(), bindings(variable)) :: Expression.t(variable) 218 | when variable: term() 219 | defp literal_to_expression(literal, bindings) 220 | 221 | defp literal_to_expression(literal, bindings) when literal > 0, 222 | do: Map.fetch!(bindings, literal) 223 | 224 | defp literal_to_expression(literal, bindings) when literal < 0, 225 | do: b(not Map.fetch!(bindings, -literal)) 226 | 227 | @spec extract_bindings(expression :: Expression.t(variable)) :: 228 | {bindings(variable), reverse_bindings(variable), Expression.t(literal())} 229 | when variable: term() 230 | defp extract_bindings(expression) do 231 | {expression, bindings} = Expression.postwalk(expression, %{current: 1}, &bind_variable/2) 232 | 233 | reverse_bindings = Map.delete(bindings, :current) 234 | forward_bindings = Map.new(reverse_bindings, &{elem(&1, 1), elem(&1, 0)}) 235 | 236 | {forward_bindings, reverse_bindings, expression} 237 | end 238 | 239 | @spec bind_variable(Expression.t(variable), bindings_map) :: 240 | {pos_integer() | Expression.t(variable), bindings_map} 241 | when variable: term(), 242 | bindings_map: %{required(:current) => pos_integer(), variable => pos_integer()} 243 | defp bind_variable(expr, bindings) 244 | 245 | defp bind_variable(value, %{current: current} = bindings) when Expression.is_variable(value) do 246 | case Map.fetch(bindings, value) do 247 | :error -> 248 | {current, bindings |> Map.update!(:current, &(&1 + 1)) |> Map.put(value, current)} 249 | 250 | {:ok, binding} -> 251 | {binding, bindings} 252 | end 253 | end 254 | 255 | defp bind_variable(expr, bindings) when not is_boolean(expr) do 256 | {expr, bindings} 257 | end 258 | 259 | @spec lift_clauses(expression :: Expression.cnf_conjunction(literal())) :: cnf() 260 | defp lift_clauses(expression) 261 | defp lift_clauses(b(left and right)), do: lift_clauses(left) ++ lift_clauses(right) 262 | 263 | defp lift_clauses(b(left or right)), 264 | do: [flatten_or_literals(left) ++ flatten_or_literals(right)] 265 | 266 | defp lift_clauses(b(not value)), do: [[-value]] 267 | defp lift_clauses(value), do: [[value]] 268 | 269 | @spec flatten_or_literals(expression :: Expression.cnf_clause(literal())) :: [literal()] 270 | defp flatten_or_literals(b(left or right)), 271 | do: flatten_or_literals(left) ++ flatten_or_literals(right) 272 | 273 | defp flatten_or_literals(b(not value)), do: [-value] 274 | defp flatten_or_literals(value), do: [value] 275 | end 276 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRuleTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule 13 | alias Crux.Expression.RewriteRule.DeMorgansLaw 14 | alias Crux.Expression.RewriteRule.DistributiveLaw 15 | alias Crux.Expression.RewriteRule.NegationLaw 16 | 17 | doctest RewriteRule, import: true 18 | 19 | describe inspect(&RewriteRule.apply/2) do 20 | test "applies single rule" do 21 | expr = b(not not :a) 22 | {result, _acc_map} = RewriteRule.apply(expr, [NegationLaw]) 23 | assert result == :a 24 | end 25 | 26 | test "applies multiple rules in order" do 27 | # First apply De Morgan's law, then double negation elimination 28 | expr = b(not (:a and :b)) 29 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw, NegationLaw]) 30 | assert result == b(not :a or not :b) 31 | end 32 | 33 | test "handles empty rule list" do 34 | expr = b(:a and :b) 35 | {result, _acc_map} = RewriteRule.apply(expr, []) 36 | assert result == expr 37 | end 38 | 39 | test "preserves rule order with different types" do 40 | # Create a test case where order matters 41 | expr = b(not (not (:a and :b))) 42 | 43 | # Apply double negation first, then De Morgan's 44 | {result1, _acc_map1} = RewriteRule.apply(expr, [NegationLaw, DeMorgansLaw]) 45 | # Double negation eliminates to (:a and :b) 46 | assert result1 == b(:a and :b) 47 | 48 | # Apply De Morgan's first - it won't match the pattern, then double negation 49 | {result2, _acc_map2} = RewriteRule.apply(expr, [DeMorgansLaw, NegationLaw]) 50 | # De Morgan's doesn't apply, then double negation eliminates 51 | assert result2 == b(:a and :b) 52 | end 53 | 54 | test "handles reapplication correctly" do 55 | # De Morgan's law needs reapplication - it applies recursively 56 | expr = b(not (not (:a and :b) and not (:c or :d))) 57 | {result, _acc_map} = RewriteRule.apply(expr, [DeMorgansLaw]) 58 | # The actual result based on the test output shows it applies De Morgan's thoroughly 59 | expected = b((not not :a and not not :b) or (not not :c or not not :d)) 60 | assert result == expected 61 | end 62 | 63 | test "chunks rules by type and exclusivity" do 64 | # This is more of an integration test - we can't directly observe chunking, 65 | # but we can test that the result is correct 66 | expr = b(not not (not (:a and :b))) 67 | {result, _acc_map} = RewriteRule.apply(expr, [NegationLaw, DeMorgansLaw, NegationLaw]) 68 | # Should eliminate double negations, apply De Morgan's, then eliminate again 69 | assert result == b(not :a or not :b) 70 | end 71 | end 72 | 73 | # Test helper modules for specific scenarios 74 | defmodule TestExclusiveRule do 75 | @moduledoc false 76 | use RewriteRule 77 | 78 | @impl RewriteRule 79 | def exclusive?, do: true 80 | 81 | @impl RewriteRule 82 | def walk(b(:exclusive_test)), do: :exclusive_applied 83 | def walk(other), do: other 84 | end 85 | 86 | defmodule TestPrewalkRule do 87 | @moduledoc false 88 | use RewriteRule 89 | 90 | @impl RewriteRule 91 | def type, do: :prewalk 92 | 93 | @impl RewriteRule 94 | def walk(b(:prewalk_test)), do: :prewalk_applied 95 | def walk(other), do: other 96 | end 97 | 98 | defmodule TestReapplicationRule do 99 | @moduledoc false 100 | use RewriteRule 101 | 102 | @impl RewriteRule 103 | def needs_reapplication?, do: true 104 | 105 | @impl RewriteRule 106 | def walk(b(:reapply_test)), do: :reapply_applied 107 | def walk(other), do: other 108 | end 109 | 110 | describe "rule chunking behavior" do 111 | test "exclusive rules get their own chunk" do 112 | expr = b(:exclusive_test and :a) 113 | {result, _acc_map} = RewriteRule.apply(expr, [TestExclusiveRule, NegationLaw]) 114 | assert result == b(:exclusive_applied and :a) 115 | end 116 | 117 | test "different types get separate chunks" do 118 | expr = b(:prewalk_test and not not :b) 119 | {result, _acc_map} = RewriteRule.apply(expr, [TestPrewalkRule, NegationLaw]) 120 | assert result == b(:prewalk_applied and :b) 121 | end 122 | 123 | test "same type non-exclusive rules are chunked together" do 124 | # Both are postwalk non-exclusive, should be in same chunk 125 | expr = b(not not :a) 126 | {result, _acc_map} = RewriteRule.apply(expr, [NegationLaw, NegationLaw]) 127 | assert result == :a 128 | end 129 | end 130 | 131 | describe "reapplication behavior" do 132 | test "reapplies rules that need it" do 133 | expr = b(:reapply_test) 134 | {result, _acc_map} = RewriteRule.apply(expr, [TestReapplicationRule]) 135 | assert result == :reapply_applied 136 | end 137 | 138 | test "stops when no changes occur" do 139 | # Rule that transforms once then stops 140 | expr = :already_transformed 141 | {result, _acc_map} = RewriteRule.apply(expr, [TestReapplicationRule]) 142 | assert result == :already_transformed 143 | end 144 | end 145 | 146 | describe "complex scenarios" do 147 | test "mixed rule types and exclusivity" do 148 | # Test a complex scenario with mixed rule types 149 | expr = b(not not (not (:a and :b))) 150 | 151 | rules = [ 152 | # prewalk, non-exclusive 153 | TestPrewalkRule, 154 | # postwalk, non-exclusive 155 | NegationLaw, 156 | # postwalk, exclusive 157 | TestExclusiveRule, 158 | # postwalk, non-exclusive, needs reapplication 159 | DeMorgansLaw 160 | ] 161 | 162 | # Should handle all these correctly in separate chunks 163 | {result, _acc_map} = RewriteRule.apply(expr, rules) 164 | # The exact result depends on what transformations apply, 165 | # but it should not crash and should be deterministic 166 | assert is_tuple(result) or is_atom(result) 167 | end 168 | 169 | test "to_cnf equivalent using rules" do 170 | # Test that we can achieve CNF using our rules 171 | expr = b(:a or (:b and :c)) 172 | {result, _acc_map} = RewriteRule.apply(expr, [DistributiveLaw]) 173 | assert result == b((:a or :b) and (:a or :c)) 174 | end 175 | end 176 | 177 | describe "accumulator functionality" do 178 | test "function-based rule with accumulator" do 179 | counter_rule = fn expr, count -> 180 | case expr do 181 | :a -> {:transformed_a, count + 1} 182 | other -> {other, count} 183 | end 184 | end 185 | 186 | expr = b(:a and :b and :a) 187 | {result, acc_map} = RewriteRule.apply(expr, [{counter_rule, 0}]) 188 | 189 | assert result == b(:transformed_a and :b and :transformed_a) 190 | assert Map.get(acc_map, counter_rule) == 2 191 | end 192 | 193 | test "function-based rule with options" do 194 | exclusive_counter = fn expr, count -> 195 | case expr do 196 | :special -> {:special_transformed, count + 1} 197 | other -> {other, count} 198 | end 199 | end 200 | 201 | expr = b(:special and :a) 202 | {result, acc_map} = RewriteRule.apply(expr, [{exclusive_counter, 0, [exclusive?: true]}]) 203 | 204 | assert result == b(:special_transformed and :a) 205 | assert Map.get(acc_map, exclusive_counter) == 1 206 | end 207 | 208 | test "accumulator persistence across reapplications" do 209 | reapply_counter = fn expr, count -> 210 | case expr do 211 | :count_me -> {:counted, count + 1} 212 | other -> {other, count} 213 | end 214 | end 215 | 216 | expr = :count_me 217 | 218 | {result, acc_map} = 219 | RewriteRule.apply(expr, [{reapply_counter, 0, [needs_reapplication?: true]}]) 220 | 221 | assert result == :counted 222 | assert Map.get(acc_map, reapply_counter) == 1 223 | end 224 | 225 | test "multiple rules with isolated accumulators" do 226 | counter1 = fn expr, count -> 227 | case expr do 228 | :a -> {:a1, count + 1} 229 | other -> {other, count} 230 | end 231 | end 232 | 233 | counter2 = fn expr, count -> 234 | case expr do 235 | :b -> {:b2, count + 10} 236 | other -> {other, count} 237 | end 238 | end 239 | 240 | expr = b(:a and :b) 241 | {result, acc_map} = RewriteRule.apply(expr, [{counter1, 0}, {counter2, 0}]) 242 | 243 | assert result == b(:a1 and :b2) 244 | assert Map.get(acc_map, counter1) == 1 245 | assert Map.get(acc_map, counter2) == 10 246 | end 247 | 248 | test "mixed module and function rules with accumulators" do 249 | collector = fn expr, visited -> 250 | case expr do 251 | atom when is_atom(atom) and atom not in [:and, :or, :not, true, false] -> 252 | {atom, [atom | visited]} 253 | 254 | other -> 255 | {other, visited} 256 | end 257 | end 258 | 259 | expr = b(not not :x) 260 | {result, acc_map} = RewriteRule.apply(expr, [NegationLaw, {collector, []}]) 261 | 262 | assert result == :x 263 | assert :x in Map.get(acc_map, collector) 264 | assert Map.get(acc_map, NegationLaw) == nil 265 | end 266 | end 267 | 268 | property "applying empty rules returns original expression" do 269 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 270 | {result, _acc_map} = RewriteRule.apply(expr, []) 271 | assert result == expr 272 | end 273 | end 274 | end 275 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This file contains the configuration for Credo and you are probably reading 6 | # this after creating it with `mix credo.gen.config`. 7 | # 8 | # If you find anything wrong or unclear in this file, please report an 9 | # issue on GitHub: https://github.com/rrrene/credo/issues 10 | # 11 | %{ 12 | # 13 | # You can have as many configs as you like in the `configs:` field. 14 | configs: [ 15 | %{ 16 | # 17 | # Run any config using `mix credo -C `. If no config name is given 18 | # "default" is used. 19 | # 20 | name: "default", 21 | # 22 | # These are the files included in the analysis: 23 | files: %{ 24 | # 25 | # You can give explicit globs or simply directories. 26 | # In the latter case `**/*.{ex,exs}` will be used. 27 | # 28 | included: [ 29 | "lib/", 30 | "src/", 31 | "test/", 32 | "web/", 33 | "apps/*/lib/", 34 | "apps/*/src/", 35 | "apps/*/test/", 36 | "apps/*/web/" 37 | ], 38 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 39 | }, 40 | # 41 | # Load and configure plugins here: 42 | # 43 | plugins: [], 44 | # 45 | # If you create your own checks, you must specify the source files for 46 | # them here, so they can be loaded by Credo before running the analysis. 47 | # 48 | requires: [], 49 | # 50 | # If you want to enforce a style guide and need a more traditional linting 51 | # experience, you can change `strict` to `true` below: 52 | # 53 | strict: false, 54 | # 55 | # To modify the timeout for parsing files, change this value: 56 | # 57 | parse_timeout: 5000, 58 | # 59 | # If you want to use uncolored output by default, you can change `color` 60 | # to `false` below: 61 | # 62 | color: true, 63 | # 64 | # You can customize the parameters of any check by adding a second element 65 | # to the tuple. 66 | # 67 | # To disable a check put `false` as second element: 68 | # 69 | # {Credo.Check.Design.DuplicatedCode, false} 70 | # 71 | checks: %{ 72 | enabled: [ 73 | # 74 | ## Consistency Checks 75 | # 76 | {Credo.Check.Consistency.ExceptionNames, []}, 77 | {Credo.Check.Consistency.LineEndings, []}, 78 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 79 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 80 | {Credo.Check.Consistency.SpaceInParentheses, []}, 81 | {Credo.Check.Consistency.TabsOrSpaces, []}, 82 | 83 | # 84 | ## Design Checks 85 | # 86 | # You can customize the priority of any check 87 | # Priority values are: `low, normal, high, higher` 88 | # 89 | {Credo.Check.Design.AliasUsage, 90 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 91 | {Credo.Check.Design.TagFIXME, []}, 92 | # You can also customize the exit_status of each check. 93 | # If you don't want TODO comments to cause `mix credo` to fail, just 94 | # set this value to 0 (zero). 95 | # 96 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 97 | 98 | # 99 | ## Readability Checks 100 | # 101 | {Credo.Check.Readability.AliasOrder, []}, 102 | {Credo.Check.Readability.FunctionNames, []}, 103 | {Credo.Check.Readability.LargeNumbers, []}, 104 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 105 | {Credo.Check.Readability.ModuleAttributeNames, []}, 106 | {Credo.Check.Readability.ModuleDoc, []}, 107 | {Credo.Check.Readability.ModuleNames, []}, 108 | {Credo.Check.Readability.ParenthesesInCondition, []}, 109 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 110 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 111 | {Credo.Check.Readability.PredicateFunctionNames, []}, 112 | {Credo.Check.Readability.PreferImplicitTry, []}, 113 | {Credo.Check.Readability.RedundantBlankLines, []}, 114 | {Credo.Check.Readability.Semicolons, []}, 115 | {Credo.Check.Readability.SpaceAfterCommas, []}, 116 | {Credo.Check.Readability.StringSigils, []}, 117 | {Credo.Check.Readability.TrailingBlankLine, []}, 118 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 119 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 120 | {Credo.Check.Readability.VariableNames, []}, 121 | {Credo.Check.Readability.WithSingleClause, []}, 122 | 123 | # 124 | ## Refactoring Opportunities 125 | # 126 | {Credo.Check.Refactor.Apply, []}, 127 | {Credo.Check.Refactor.CondStatements, []}, 128 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 129 | {Credo.Check.Refactor.FilterCount, []}, 130 | {Credo.Check.Refactor.FilterFilter, []}, 131 | {Credo.Check.Refactor.FunctionArity, []}, 132 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 133 | {Credo.Check.Refactor.MapJoin, []}, 134 | {Credo.Check.Refactor.MatchInCondition, []}, 135 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 136 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 137 | {Credo.Check.Refactor.Nesting, [max_nesting: 10]}, 138 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 139 | {Credo.Check.Refactor.RejectReject, []}, 140 | {Credo.Check.Refactor.UnlessWithElse, []}, 141 | {Credo.Check.Refactor.WithClauses, []}, 142 | 143 | # 144 | ## Warnings 145 | # 146 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 147 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 148 | {Credo.Check.Warning.Dbg, []}, 149 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 150 | {Credo.Check.Warning.IExPry, []}, 151 | {Credo.Check.Warning.IoInspect, []}, 152 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 153 | {Credo.Check.Warning.OperationOnSameValues, []}, 154 | {Credo.Check.Warning.OperationWithConstantResult, []}, 155 | {Credo.Check.Warning.RaiseInsideRescue, []}, 156 | {Credo.Check.Warning.SpecWithStruct, []}, 157 | {Credo.Check.Warning.UnsafeExec, []}, 158 | {Credo.Check.Warning.UnusedEnumOperation, []}, 159 | {Credo.Check.Warning.UnusedFileOperation, []}, 160 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 161 | {Credo.Check.Warning.UnusedListOperation, []}, 162 | {Credo.Check.Warning.UnusedPathOperation, []}, 163 | {Credo.Check.Warning.UnusedRegexOperation, []}, 164 | {Credo.Check.Warning.UnusedStringOperation, []}, 165 | {Credo.Check.Warning.UnusedTupleOperation, []}, 166 | {Credo.Check.Warning.WrongTestFileExtension, []} 167 | ], 168 | disabled: [ 169 | # 170 | # Checks scheduled for next check update (opt-in for now) 171 | {Credo.Check.Refactor.UtcNowTruncate, []}, 172 | 173 | # 174 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 175 | # and be sure to use `mix credo --strict` to see low priority checks) 176 | # 177 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 178 | {Credo.Check.Consistency.UnusedVariableNames, []}, 179 | {Credo.Check.Design.DuplicatedCode, []}, 180 | {Credo.Check.Design.SkipTestWithoutComment, []}, 181 | {Credo.Check.Readability.AliasAs, []}, 182 | {Credo.Check.Readability.BlockPipe, []}, 183 | {Credo.Check.Readability.ImplTrue, []}, 184 | {Credo.Check.Readability.MultiAlias, []}, 185 | {Credo.Check.Readability.NestedFunctionCalls, []}, 186 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 187 | {Credo.Check.Readability.OnePipePerLine, []}, 188 | {Credo.Check.Readability.SeparateAliasRequire, []}, 189 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 190 | {Credo.Check.Readability.SinglePipe, []}, 191 | {Credo.Check.Readability.Specs, []}, 192 | {Credo.Check.Readability.StrictModuleLayout, []}, 193 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 194 | {Credo.Check.Refactor.ABCSize, []}, 195 | {Credo.Check.Refactor.AppendSingleItem, []}, 196 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 197 | {Credo.Check.Refactor.FilterReject, []}, 198 | {Credo.Check.Refactor.IoPuts, []}, 199 | {Credo.Check.Refactor.MapMap, []}, 200 | {Credo.Check.Refactor.ModuleDependencies, []}, 201 | {Credo.Check.Refactor.NegatedIsNil, []}, 202 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 203 | {Credo.Check.Refactor.PipeChainStart, []}, 204 | {Credo.Check.Refactor.RejectFilter, []}, 205 | {Credo.Check.Refactor.VariableRebinding, []}, 206 | {Credo.Check.Warning.LazyLogging, []}, 207 | {Credo.Check.Warning.LeakyEnvironment, []}, 208 | {Credo.Check.Warning.MapGetUnsafePass, []}, 209 | {Credo.Check.Warning.MixEnv, []}, 210 | {Credo.Check.Warning.UnsafeToAtom, []} 211 | 212 | # {Credo.Check.Refactor.MapInto, []}, 213 | 214 | # 215 | # Custom checks can be created using `mix credo.gen.check`. 216 | # 217 | ] 218 | } 219 | } 220 | ] 221 | } 222 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/distributivity_based_simplification_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.DistributivityBasedSimplificationLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.DistributivityBasedSimplificationLaw 13 | 14 | doctest DistributivityBasedSimplificationLaw, import: true 15 | 16 | describe inspect(&DistributivityBasedSimplificationLaw.walk/1) do 17 | test "applies A AND (NOT A OR B)" do 18 | assert Expression.postwalk( 19 | b(:a and (not :a or :b)), 20 | &DistributivityBasedSimplificationLaw.walk/1 21 | ) == 22 | b(:a and :b) 23 | end 24 | 25 | test "applies A OR (NOT A AND B)" do 26 | assert Expression.postwalk( 27 | b(:a or (not :a and :b)), 28 | &DistributivityBasedSimplificationLaw.walk/1 29 | ) == 30 | b(:a or :b) 31 | end 32 | 33 | test "applies NOT A AND (A OR B)" do 34 | assert Expression.postwalk( 35 | b(not :a and (:a or :b)), 36 | &DistributivityBasedSimplificationLaw.walk/1 37 | ) == 38 | b(not :a and :b) 39 | end 40 | 41 | test "applies NOT A OR (A AND B)" do 42 | assert Expression.postwalk( 43 | b(not :a or (:a and :b)), 44 | &DistributivityBasedSimplificationLaw.walk/1 45 | ) == 46 | b(not :a or :b) 47 | end 48 | 49 | test "works with complex sub-expressions" do 50 | expr = b(:x and :y and (not (:x and :y) or :z)) 51 | 52 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == 53 | b(:x and :y and :z) 54 | end 55 | 56 | test "works with deeply nested expressions" do 57 | expr = b((not :a and :b) or (not (not :a and :b) and (:c or :d))) 58 | expected = b((not :a and :b) or (:c or :d)) 59 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expected 60 | end 61 | 62 | test "handles multiple variables in patterns" do 63 | expr = b((:x or :y) and (not (:x or :y) or (:z and :w))) 64 | 65 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == 66 | b((:x or :y) and (:z and :w)) 67 | end 68 | 69 | test "works with boolean constants" do 70 | expr = b(true and (not true or false)) 71 | 72 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == 73 | b(true and false) 74 | end 75 | 76 | test "handles mixed boolean constants and variables" do 77 | expr = b(false or (not false and :a)) 78 | 79 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == 80 | b(false or :a) 81 | end 82 | 83 | test "works with complex nested boolean structures" do 84 | expr = b((not (:a or :b) and :c) or (not (not (:a or :b) and :c) and (:d and :e))) 85 | expected = b((not (:a or :b) and :c) or (:d and :e)) 86 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expected 87 | end 88 | 89 | test "leaves non-matching patterns unchanged" do 90 | assert Expression.postwalk(b(:a and :b), &DistributivityBasedSimplificationLaw.walk/1) == 91 | b(:a and :b) 92 | end 93 | 94 | test "leaves different complement patterns unchanged" do 95 | assert Expression.postwalk( 96 | b(:a and (not :b or :c)), 97 | &DistributivityBasedSimplificationLaw.walk/1 98 | ) == 99 | b(:a and (not :b or :c)) 100 | end 101 | 102 | test "leaves single variables unchanged" do 103 | assert Expression.postwalk(:a, &DistributivityBasedSimplificationLaw.walk/1) == :a 104 | end 105 | 106 | test "leaves simple negations unchanged" do 107 | assert Expression.postwalk(b(not :a), &DistributivityBasedSimplificationLaw.walk/1) == 108 | b(not :a) 109 | end 110 | 111 | test "leaves boolean constants unchanged" do 112 | assert Expression.postwalk(true, &DistributivityBasedSimplificationLaw.walk/1) 113 | refute Expression.postwalk(false, &DistributivityBasedSimplificationLaw.walk/1) 114 | end 115 | 116 | test "handles partial distributivity opportunities" do 117 | expr = b((:a and (not :a or :b)) or (:c and :d)) 118 | result = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 119 | assert result == b((:a and :b) or (:c and :d)) 120 | end 121 | 122 | test "works with symmetric patterns" do 123 | expr = b((:a or (not :a and :b)) and (:c and (not :c or :d))) 124 | result = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 125 | assert result == b((:a or :b) and (:c and :d)) 126 | end 127 | 128 | test "handles multiple distributivity opportunities" do 129 | expr = b((:a and (not :a or :b)) or (:c or (not :c and :d))) 130 | result = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 131 | assert result == b((:a and :b) or (:c or :d)) 132 | end 133 | 134 | test "preserves expressions without distributivity patterns" do 135 | expr = b((:a or :b) and (:c or :d)) 136 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expr 137 | end 138 | 139 | test "works with triple nested patterns" do 140 | expr = b(:x and (not :x or (:y and (not :y or :z)))) 141 | result = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 142 | assert result == b(:x and (:y and :z)) 143 | end 144 | 145 | test "handles mixed AND/OR distributivity patterns" do 146 | expr = b(:a and (not :a or :b) and (:c or (not :c and :d))) 147 | result = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 148 | assert result == b(:a and :b and (:c or :d)) 149 | end 150 | 151 | test "works with very complex identical sub-expressions" do 152 | complex_expr = b(not (:a or :b) and (:c or not :d)) 153 | expr = b(complex_expr or (not complex_expr and :simple)) 154 | 155 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == 156 | b(complex_expr or :simple) 157 | end 158 | 159 | test "preserves non-distributive complex patterns" do 160 | expr = b((:a and :b) or (:c and (not :a or :b))) 161 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expr 162 | end 163 | 164 | test "handles reverse complement patterns" do 165 | expr = b((not :a or :b) and :a) 166 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expr 167 | end 168 | end 169 | 170 | describe "edge cases" do 171 | test "handles all four patterns in sequence" do 172 | exprs_and_expected = [ 173 | {b(:x and (not :x or :y)), b(:x and :y)}, 174 | {b(:x or (not :x and :y)), b(:x or :y)}, 175 | {b(not :x and (:x or :y)), b(not :x and :y)}, 176 | {b(not :x or (:x and :y)), b(not :x or :y)} 177 | ] 178 | 179 | for {expr, expected} <- exprs_and_expected do 180 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expected 181 | end 182 | end 183 | 184 | test "works with very complex complement sub-expressions" do 185 | complex_expr = b(not (:a and :b) or (:c and not :d)) 186 | expr = b(complex_expr and (not complex_expr or :simple)) 187 | 188 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == 189 | b(complex_expr and :simple) 190 | end 191 | 192 | test "preserves non-distributive complex patterns" do 193 | expr = b(:a and :b and (:c or (not :d and :e))) 194 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expr 195 | end 196 | 197 | test "handles deeply nested distributive patterns" do 198 | expr = b(:a and (not :a or (:b and (not :b or :c)))) 199 | expected = b(:a and (:b and :c)) 200 | assert Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) == expected 201 | end 202 | 203 | test "works with multiple levels of distributivity" do 204 | expr = b(:a or (not :a and (:b or (not :b and :c)))) 205 | result = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 206 | assert result == b(:a or (:b or :c)) 207 | end 208 | end 209 | 210 | property "applying distributivity-based simplification law is idempotent" do 211 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 212 | result1 = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 213 | result2 = Expression.postwalk(result1, &DistributivityBasedSimplificationLaw.walk/1) 214 | assert result1 == result2 215 | end 216 | end 217 | 218 | property "distributivity-based simplification law preserves logical equivalence" do 219 | check all( 220 | assignments <- 221 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 222 | variable_names = Map.keys(assignments), 223 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 224 | ) do 225 | result = Expression.postwalk(expr, &DistributivityBasedSimplificationLaw.walk/1) 226 | eval_fn = &Map.fetch!(assignments, &1) 227 | 228 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 229 | """ 230 | Distributivity-based simplification law changed the logical outcome! 231 | Original: #{inspect(expr, pretty: true)} 232 | Transformed: #{inspect(result, pretty: true)} 233 | Assignments: #{inspect(assignments, pretty: true)} 234 | """ 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/unit_resolution_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.UnitResolutionTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.UnitResolution 13 | 14 | doctest UnitResolution, import: true 15 | 16 | describe inspect(&UnitResolution.walk/1) do 17 | test "applies unit resolution: A AND (NOT A OR B)" do 18 | expr = b(:a and (not :a or :b)) 19 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 20 | assert result == b(:a and :b) 21 | end 22 | 23 | test "applies unit resolution: (NOT A OR B) AND A" do 24 | expr = b((not :a or :b) and :a) 25 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 26 | assert result == b(:b and :a) 27 | end 28 | 29 | test "applies unit resolution: (NOT A) AND (A OR B)" do 30 | expr = b(not :a and (:a or :b)) 31 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 32 | assert result == b(not :a and :b) 33 | end 34 | 35 | test "applies unit resolution: (A OR B) AND (NOT A)" do 36 | expr = b((:a or :b) and not :a) 37 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 38 | assert result == b(:b and not :a) 39 | end 40 | 41 | test "works with complex sub-expressions as units" do 42 | # Unit: (X AND Y), Clause: NOT (X AND Y) OR Z 43 | unit = b(:x and :y) 44 | expr = b(unit and (not unit or :z)) 45 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 46 | assert result == b(unit and :z) 47 | end 48 | 49 | test "works with negated complex sub-expressions as units" do 50 | # Unit: NOT (P OR Q), Clause: (P OR Q) OR R 51 | complex_expr = b(:p or :q) 52 | expr = b(not complex_expr and (complex_expr or :r)) 53 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 54 | assert result == b(not complex_expr and :r) 55 | end 56 | 57 | test "handles multiple unit resolution opportunities" do 58 | # A AND (NOT A OR B) AND C AND (NOT C OR D) 59 | expr = b(:a and (not :a or :b) and :c and (not :c or :d)) 60 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 61 | # Should resolve first opportunity: A AND B AND C AND (NOT C OR D) 62 | assert result == b(:a and :b and :c and (not :c or :d)) 63 | end 64 | 65 | test "works with deeply nested expressions" do 66 | # Unit resolution within a larger expression 67 | expr = b((:m or :n) and (:a and (not :a or :b))) 68 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 69 | assert result == b((:m or :n) and (:a and :b)) 70 | end 71 | 72 | test "handles sequential unit propagation" do 73 | # A AND (NOT A OR B) AND (NOT B OR C) should resolve first step 74 | expr = b(:a and (not :a or :b) and (not :b or :c)) 75 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 76 | # First pass: A AND B AND (NOT B OR C) 77 | assert result == b(:a and :b and (not :b or :c)) 78 | end 79 | 80 | test "works with boolean constants as units" do 81 | # true AND (NOT true OR B) = true AND B 82 | expr = b(true and (not true or :b)) 83 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 84 | assert result == b(true and :b) 85 | end 86 | 87 | test "handles unit resolution with multiple literals in clause" do 88 | # A AND (NOT A OR B OR C) - our pattern doesn't handle this complex case 89 | expr = b(:a and (not :a or :b or :c)) 90 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 91 | # Should remain unchanged as our patterns only handle binary OR clauses 92 | assert result == expr 93 | end 94 | 95 | test "works with very complex identical sub-expressions" do 96 | complex_expr = b((:p and :q) or (:r and not :s)) 97 | expr = b(complex_expr and (not complex_expr or :t)) 98 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 99 | assert result == b(complex_expr and :t) 100 | end 101 | 102 | test "leaves non-unit patterns unchanged" do 103 | # No unit clauses 104 | expr = b((:a or :b) and (:c or :d)) 105 | assert Expression.postwalk(expr, &UnitResolution.walk/1) == expr 106 | end 107 | 108 | test "leaves non-matching unit patterns unchanged" do 109 | # Unit doesn't contradict clause 110 | expr = b(:a and (:a or :b)) 111 | assert Expression.postwalk(expr, &UnitResolution.walk/1) == expr 112 | end 113 | 114 | test "leaves single variables unchanged" do 115 | assert Expression.postwalk(:a, &UnitResolution.walk/1) == :a 116 | end 117 | 118 | test "leaves simple expressions unchanged" do 119 | assert Expression.postwalk(b(:a and :b), &UnitResolution.walk/1) == b(:a and :b) 120 | assert Expression.postwalk(b(:a or :b), &UnitResolution.walk/1) == b(:a or :b) 121 | end 122 | 123 | test "leaves boolean constants unchanged" do 124 | assert Expression.postwalk(true, &UnitResolution.walk/1) 125 | refute Expression.postwalk(false, &UnitResolution.walk/1) 126 | end 127 | 128 | test "handles partial unit resolution patterns" do 129 | # Has unit but no contradicting clause 130 | expr = b(:a and (:b or :c)) 131 | assert Expression.postwalk(expr, &UnitResolution.walk/1) == expr 132 | end 133 | 134 | test "preserves expressions without unit patterns" do 135 | expr = b((:a or :b) and (:c and :d) and (:e or :f)) 136 | assert Expression.postwalk(expr, &UnitResolution.walk/1) == expr 137 | end 138 | end 139 | 140 | describe "edge cases" do 141 | test "handles all unit resolution ordering patterns" do 142 | # Test all possible orderings systematically 143 | unit_patterns = [ 144 | b(:x and (not :x or :y)), 145 | b((not :x or :y) and :x), 146 | b(not :x and (:x or :y)), 147 | b((:x or :y) and not :x) 148 | ] 149 | 150 | expected_results = [ 151 | b(:x and :y), 152 | b(:y and :x), 153 | b(not :x and :y), 154 | b(:y and not :x) 155 | ] 156 | 157 | for {expr, expected} <- Enum.zip(unit_patterns, expected_results) do 158 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 159 | assert result == expected 160 | end 161 | end 162 | 163 | test "handles unit resolution with negated units" do 164 | # Both positive and negative units 165 | patterns = [ 166 | {b(:a and (not :a or :b)), b(:a and :b)}, 167 | {b(not :a and (:a or :b)), b(not :a and :b)} 168 | ] 169 | 170 | for {expr, expected} <- patterns do 171 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 172 | assert result == expected 173 | end 174 | end 175 | 176 | test "handles reapplication correctly" do 177 | # Test that needs_reapplication? works properly using RewriteRule.apply 178 | # A AND (NOT A OR B) AND (NOT B OR C) should progressively resolve 179 | expr = b(:a and (not :a or :b) and (not :b or :c)) 180 | 181 | # Use RewriteRule.apply which handles reapplication automatically 182 | {result, _acc_map} = Expression.RewriteRule.apply(expr, [UnitResolution]) 183 | 184 | # Should resolve first step: A AND B AND (NOT B OR C) 185 | # The reapplication should eventually resolve to A AND B AND C 186 | # But since we're only testing UnitResolution in isolation, check intermediate step 187 | assert result == b(:a and :b and :c) or result == b(:a and :b and (not :b or :c)) 188 | end 189 | 190 | test "preserves non-unit resolution patterns" do 191 | # Multiple units but no contradictions 192 | expr = b(:a and :b and (:c or :d)) 193 | assert Expression.postwalk(expr, &UnitResolution.walk/1) == expr 194 | end 195 | 196 | test "handles mixed unit and non-unit clauses" do 197 | # Some resolvable, some not 198 | expr = b(:a and (not :a or :b) and (:c or :d)) 199 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 200 | assert result == b(:a and :b and (:c or :d)) 201 | end 202 | 203 | test "works with symmetric unit patterns" do 204 | # Multiple variables in symmetric positions 205 | expr = b(:x and (not :x or :y) and :z and (not :z or :w)) 206 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 207 | # Should resolve first opportunity: X AND Y AND Z AND (NOT Z OR W) 208 | assert result == b(:x and :y and :z and (not :z or :w)) 209 | end 210 | 211 | test "handles contradictory units gracefully" do 212 | # A AND NOT A - should remain as is (contradiction) 213 | expr = b(:a and not :a) 214 | result = Expression.postwalk(expr, &UnitResolution.walk/1) 215 | assert result == expr 216 | end 217 | end 218 | 219 | property "applying unit resolution is idempotent after stabilization" do 220 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 221 | # Use RewriteRule.apply which handles reapplication automatically 222 | {result1, _acc_map1} = Expression.RewriteRule.apply(expr, [UnitResolution]) 223 | {result2, _acc_map2} = Expression.RewriteRule.apply(result1, [UnitResolution]) 224 | 225 | # Should be idempotent after stabilization 226 | assert result1 == result2 227 | end 228 | end 229 | 230 | property "unit resolution preserves logical equivalence" do 231 | check all( 232 | assignments <- 233 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 234 | variable_names = Map.keys(assignments), 235 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 236 | ) do 237 | {result, _acc_map} = Expression.RewriteRule.apply(expr, [UnitResolution]) 238 | eval_fn = &Map.fetch!(assignments, &1) 239 | 240 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 241 | """ 242 | Unit resolution changed the logical outcome! 243 | Original: #{inspect(expr, pretty: true)} 244 | Transformed: #{inspect(result, pretty: true)} 245 | Assignments: #{inspect(assignments, pretty: true)} 246 | """ 247 | end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /test/crux/expression/rewrite_rule/tautology_law_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule.TautologyLawTest do 6 | use ExUnit.Case, async: true 7 | use ExUnitProperties 8 | 9 | import Crux.Expression, only: [b: 1] 10 | 11 | alias Crux.Expression 12 | alias Crux.Expression.RewriteRule.TautologyLaw 13 | 14 | doctest TautologyLaw, import: true 15 | 16 | describe inspect(&TautologyLaw.walk/1) do 17 | test "applies tautology: A OR (NOT A OR B)" do 18 | expr = b(:a or (not :a or :b)) 19 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 20 | assert result 21 | end 22 | 23 | test "applies tautology: A OR (B OR NOT A)" do 24 | expr = b(:a or (:b or not :a)) 25 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 26 | assert result 27 | end 28 | 29 | test "applies tautology: (NOT A OR B) OR A" do 30 | expr = b(not :a or :b or :a) 31 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 32 | assert result 33 | end 34 | 35 | test "applies tautology: (B OR NOT A) OR A" do 36 | expr = b(:b or not :a or :a) 37 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 38 | assert result 39 | end 40 | 41 | test "applies tautology: (A OR B) OR NOT A" do 42 | expr = b(:a or :b or not :a) 43 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 44 | assert result 45 | end 46 | 47 | test "applies tautology: NOT A OR (A OR B)" do 48 | expr = b(not :a or (:a or :b)) 49 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 50 | assert result 51 | end 52 | 53 | test "applies tautology: NOT A OR (B OR A)" do 54 | expr = b(not :a or (:b or :a)) 55 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 56 | assert result 57 | end 58 | 59 | test "works with complex sub-expressions" do 60 | # (X AND Y) OR (NOT (X AND Y) OR Z) = true 61 | complex_expr = b(:x and :y) 62 | expr = b(complex_expr or (not complex_expr or :z)) 63 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 64 | assert result 65 | end 66 | 67 | test "works with negated complex sub-expressions" do 68 | # NOT (P OR Q) OR ((P OR Q) OR R) = true 69 | complex_expr = b(:p or :q) 70 | expr = b(not complex_expr or (complex_expr or :r)) 71 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 72 | assert result 73 | end 74 | 75 | test "handles multiple tautology opportunities" do 76 | # (A OR (NOT A OR B)) AND (C OR (NOT C OR D)) = true AND true = true 77 | expr = b((:a or (not :a or :b)) and (:c or (not :c or :d))) 78 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 79 | assert result 80 | end 81 | 82 | test "works with deeply nested expressions" do 83 | # Tautology within a larger expression 84 | expr = b((:m and :n) or (:a or (not :a or :b))) 85 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 86 | assert result == b((:m and :n) or true) 87 | end 88 | 89 | test "handles tautologies with boolean constants" do 90 | # true OR (NOT true OR B) = true 91 | expr = b(true or (not true or :b)) 92 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 93 | assert result 94 | end 95 | 96 | test "works with multiple variables in tautology" do 97 | # Different variable combinations 98 | expr = b(:x or (not :x or (:y and :z))) 99 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 100 | assert result 101 | end 102 | 103 | test "handles very complex identical sub-expressions" do 104 | complex_expr = b((:p and :q) or (:r and not :s)) 105 | expr = b(complex_expr or (not complex_expr or :t)) 106 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 107 | assert result 108 | end 109 | 110 | test "leaves non-tautology patterns unchanged" do 111 | # Missing the complement 112 | expr = b(:a or (:b or :c)) 113 | assert Expression.postwalk(expr, &TautologyLaw.walk/1) == expr 114 | end 115 | 116 | test "leaves different variable patterns unchanged" do 117 | # Variables don't form tautology 118 | expr = b(:a or (not :b or :c)) 119 | assert Expression.postwalk(expr, &TautologyLaw.walk/1) == expr 120 | end 121 | 122 | test "leaves AND operations unchanged" do 123 | # Tautology patterns only work with OR 124 | expr = b(:a and (not :a and :b)) 125 | assert Expression.postwalk(expr, &TautologyLaw.walk/1) == expr 126 | end 127 | 128 | test "leaves single variables unchanged" do 129 | assert Expression.postwalk(:a, &TautologyLaw.walk/1) == :a 130 | end 131 | 132 | test "leaves simple expressions unchanged" do 133 | assert Expression.postwalk(b(:a and :b), &TautologyLaw.walk/1) == b(:a and :b) 134 | assert Expression.postwalk(b(:a or :b), &TautologyLaw.walk/1) == b(:a or :b) 135 | end 136 | 137 | test "leaves boolean constants unchanged" do 138 | assert Expression.postwalk(true, &TautologyLaw.walk/1) 139 | refute Expression.postwalk(false, &TautologyLaw.walk/1) 140 | end 141 | 142 | test "handles partial tautology patterns" do 143 | # Has complement but wrong structure 144 | expr = b(:a and (:b or not :a)) 145 | assert Expression.postwalk(expr, &TautologyLaw.walk/1) == expr 146 | end 147 | 148 | test "preserves expressions without tautology patterns" do 149 | expr = b((:a and :b) or (:c and :d)) 150 | assert Expression.postwalk(expr, &TautologyLaw.walk/1) == expr 151 | end 152 | 153 | test "works with symmetric tautology patterns" do 154 | # Multiple variables in symmetric positions 155 | expr = b(:x or (:y or not :x)) 156 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 157 | assert result 158 | end 159 | 160 | test "handles nested tautologies" do 161 | # Tautology containing another tautology 162 | inner_tautology = b(:p or (not :p or :q)) 163 | expr = b(:a or (not :a or inner_tautology)) 164 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 165 | assert result 166 | end 167 | 168 | test "works with mixed tautology types" do 169 | # Different tautology patterns in one expression 170 | expr = b((:a or (not :a or :b)) and (:c or (:d or not :c))) 171 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 172 | assert result 173 | end 174 | end 175 | 176 | describe "edge cases" do 177 | test "handles all tautology ordering patterns" do 178 | # Test all 7 implemented patterns systematically 179 | tautology_patterns = [ 180 | # A OR (NOT A OR B) 181 | b(:x or (not :x or :y)), 182 | # A OR (B OR NOT A) 183 | b(:x or (:y or not :x)), 184 | # (NOT A OR B) OR A 185 | b(not :x or :y or :x), 186 | # (B OR NOT A) OR A 187 | b(:y or not :x or :x), 188 | # (A OR B) OR NOT A 189 | b(:x or :y or not :x), 190 | # NOT A OR (A OR B) 191 | b(not :x or (:x or :y)), 192 | # NOT A OR (B OR A) 193 | b(not :x or (:y or :x)) 194 | ] 195 | 196 | for expr <- tautology_patterns do 197 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 198 | assert result, "Pattern #{inspect(expr)} should be recognized as tautology" 199 | end 200 | end 201 | 202 | test "preserves non-tautology disjunctions" do 203 | # Valid OR patterns that are not tautologies 204 | non_tautologies = [ 205 | b(:a or :b), 206 | b(:a or (:b or :c)), 207 | b(:a or :b or :c), 208 | b(:a or (not :b or :c)) 209 | ] 210 | 211 | for expr <- non_tautologies do 212 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 213 | assert result == expr, "Pattern #{inspect(expr)} should remain unchanged" 214 | end 215 | end 216 | 217 | test "handles tautologies with boolean constants correctly" do 218 | # Patterns involving true/false 219 | patterns_and_expected = [ 220 | {b(true or (not true or :x)), true}, 221 | {b(false or (not false or :x)), true}, 222 | {b(:x or (not :x or true)), true}, 223 | {b(:x or (not :x or false)), true} 224 | ] 225 | 226 | for {expr, expected} <- patterns_and_expected do 227 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 228 | assert result == expected 229 | end 230 | end 231 | 232 | test "preserves complex non-tautology patterns" do 233 | # Complex expressions that shouldn't match 234 | expr = b((:a and :b) or (not (:c or :d) or :e)) 235 | assert Expression.postwalk(expr, &TautologyLaw.walk/1) == expr 236 | end 237 | 238 | test "handles deeply nested tautology detection" do 239 | # Very nested structure with tautology 240 | expr = b((:m and :n) or ((:p or :q) and (:a or (not :a or :b)))) 241 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 242 | assert result == b((:m and :n) or ((:p or :q) and true)) 243 | end 244 | 245 | test "works with repeated variables in different roles" do 246 | # Same variable used multiple times 247 | expr = b(:a or (not :a or (:a and :b))) 248 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 249 | assert result 250 | end 251 | 252 | test "preserves AND operations with complement patterns" do 253 | # Should not apply to AND operations 254 | and_patterns = [ 255 | b(:a and (not :a and :b)), 256 | b(not :a and :b and :a), 257 | b(:a and :b and not :a) 258 | ] 259 | 260 | for expr <- and_patterns do 261 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 262 | assert result == expr 263 | end 264 | end 265 | end 266 | 267 | property "applying tautology law is idempotent" do 268 | check all(expr <- Expression.generate_expression(StreamData.atom(:alphanumeric))) do 269 | result1 = Expression.postwalk(expr, &TautologyLaw.walk/1) 270 | result2 = Expression.postwalk(result1, &TautologyLaw.walk/1) 271 | assert result1 == result2 272 | end 273 | end 274 | 275 | property "tautology law preserves logical equivalence" do 276 | check all( 277 | assignments <- 278 | StreamData.map_of(StreamData.atom(:alphanumeric), StreamData.boolean(), min_length: 1), 279 | variable_names = Map.keys(assignments), 280 | expr <- Expression.generate_expression(StreamData.member_of(variable_names)) 281 | ) do 282 | result = Expression.postwalk(expr, &TautologyLaw.walk/1) 283 | eval_fn = &Map.fetch!(assignments, &1) 284 | 285 | assert Expression.run(expr, eval_fn) == Expression.run(result, eval_fn), 286 | """ 287 | Tautology law changed the logical outcome! 288 | Original: #{inspect(expr, pretty: true)} 289 | Transformed: #{inspect(result, pretty: true)} 290 | Assignments: #{inspect(assignments, pretty: true)} 291 | """ 292 | end 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, 4 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 6 | "doctest_formatter": {:hex, :doctest_formatter, "0.4.1", "c69bf93853d1ec5785cbd22dcf0c2bd4dd357cc53f2e89d05850eed7e985462a", [:mix], [], "hexpm", "c1b07495a524126de133be4e077b28c4a2d8e1a14c9eeca962482e2067b5b068"}, 7 | "doctor": {:hex, :doctor, "0.22.0", "223e1cace1f16a38eda4113a5c435fa9b10d804aa72d3d9f9a71c471cc958fe7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "96e22cf8c0df2e9777dc55ebaa5798329b9028889c4023fed3305688d902cd5b"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 9 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 10 | "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 11 | "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, 12 | "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, 13 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 14 | "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 15 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 16 | "git_ops": {:hex, :git_ops, "2.9.0", "b74f6040084f523055b720cc7ef718da47f2cbe726a5f30c2871118635cb91c1", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "7fdf84be3490e5692c5dc1f8a1084eed47a221c1063e41938c73312f0bfea259"}, 17 | "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, 18 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 19 | "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, 20 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 21 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 22 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 23 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 24 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 25 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 26 | "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, 27 | "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, 28 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 29 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 30 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 31 | "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, 32 | "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, 33 | "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, 34 | "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, 35 | "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"}, 36 | "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, 37 | "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, 38 | "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, 39 | "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, 40 | "styler": {:hex, :styler, "1.9.1", "e30f0e909c02c686c75e47c07a76986483525eeb23c4d136f00dfa1c25fc6499", [:mix], [], "hexpm", "f583bedd92515245801f9ad504766255a27ecd5714fc4f1fd607de0eb951e1cf"}, 41 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 42 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 43 | "usage_rules": {:hex, :usage_rules, "0.1.26", "19d38c8b9b5c35434eae44f7e4554caeb5f08037a1d45a6b059a9782543ac22e", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9f0d203aa288e1b48318929066778ec26fc423fd51f08518c5b47f58ad5caca9"}, 44 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 45 | "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, 46 | } 47 | -------------------------------------------------------------------------------- /lib/crux/expression/rewrite_rule.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 crux contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Crux.Expression.RewriteRule do 6 | @moduledoc """ 7 | Behaviour for defining expression rewrite rules. 8 | 9 | A rewrite rule defines how to transform boolean expressions during traversal. 10 | Rules can be composed together or applied individually depending on their 11 | exclusivity requirements. 12 | """ 13 | 14 | alias Crux.Expression 15 | 16 | @type t() :: module() 17 | @type rule_options() :: [ 18 | exclusive?: boolean(), 19 | needs_reapplication?: boolean(), 20 | type: :prewalk | :postwalk 21 | ] 22 | @type acc_id(variable, acc) :: 23 | t() | Expression.walker(variable, acc) 24 | @type acc_map(variable, acc) :: %{acc_id(variable, acc) => acc} 25 | @type rule_spec(variable, acc) :: 26 | t() 27 | | Expression.walker_stateless(variable) 28 | | {Expression.walker_stateful(variable, acc), acc} 29 | | {Expression.walker_stateful(variable, acc), acc, rule_options()} 30 | 31 | @doc """ 32 | Returns whether this rule is exclusive. 33 | 34 | An exclusive rule cannot be composed with other rules and must be applied 35 | in isolation. Non-exclusive rules can be composed together for efficiency. 36 | """ 37 | @callback exclusive?() :: boolean() 38 | 39 | @doc """ 40 | Returns the traversal type for this rule. 41 | 42 | Must return either `:prewalk` or `:postwalk` to indicate when during 43 | tree traversal this rule should be applied. 44 | """ 45 | @callback type() :: :prewalk | :postwalk 46 | 47 | @doc """ 48 | Transforms an expression with an accumulator. 49 | 50 | This callback is optional - if not implemented, the default implementation 51 | will call `walk/1` and return `{result, acc}`. 52 | """ 53 | @callback walk_stateful(Expression.t(variable), acc) :: {Expression.t(variable), acc} 54 | when variable: term(), acc: term() 55 | 56 | @doc """ 57 | Transforms an expression without an accumulator. 58 | 59 | This is the core transformation logic for the rule. 60 | """ 61 | @callback walk(Expression.t(variable)) :: Expression.t(variable) when variable: term() 62 | 63 | @doc """ 64 | Returns whether this rule may need reapplication. 65 | 66 | Rules that return `true` may be applied multiple times until a fixpoint 67 | is reached (no further changes occur). Rules that return `false` are 68 | applied only once. 69 | """ 70 | @callback needs_reapplication?() :: boolean() 71 | 72 | @optional_callbacks [walk: 1] 73 | 74 | @doc """ 75 | Applies a list of rewrite rules to an expression. 76 | 77 | Rules are processed in chunks based on their exclusivity and type while 78 | preserving the order they appear in the list. Adjacent rules with the same 79 | exclusivity and type are grouped together for efficiency. 80 | 81 | ## Rule Processing 82 | 83 | - Rules are chunked by adjacency of exclusivity and type 84 | - Exclusive rules always get their own chunk 85 | - Each chunk is applied sequentially to the result of the previous chunk 86 | - If any rule in a chunk has `needs_reapplication?() == true`, the chunk 87 | is reapplied until no changes occur (fixpoint) 88 | 89 | ## Examples 90 | 91 | iex> import Crux.Expression, only: [b: 1] 92 | ...> expr = b(not not :a) 93 | ...> 94 | ...> {result, _acc_map} = 95 | ...> RewriteRule.apply(expr, [ 96 | ...> Crux.Expression.RewriteRule.NegationLaw 97 | ...> ]) 98 | ...> 99 | ...> result 100 | :a 101 | 102 | """ 103 | @spec apply(Expression.t(variable), [rule_spec(variable, acc)]) :: 104 | {Expression.t(variable), acc_map(variable, acc)} 105 | when variable: term(), acc: term() 106 | def apply(expression, rules) do 107 | acc_map = initialize_accumulator_map(rules) 108 | 109 | rules 110 | |> chunk_rules() 111 | |> Enum.reduce({expression, acc_map}, &apply_chunk_with_reapplication/2) 112 | end 113 | 114 | @doc false 115 | @spec default_walk_stateful(module(), Expression.t(variable), acc) :: 116 | {Expression.t(variable), acc} 117 | when variable: term(), acc: term() 118 | def default_walk_stateful(module, expression, acc) do 119 | {module.walk(expression), acc} 120 | end 121 | 122 | @spec initialize_accumulator_map([rule_spec(variable, acc)]) :: acc_map(variable, acc) 123 | when variable: term(), acc: term() 124 | defp initialize_accumulator_map(rules) do 125 | Enum.reduce(rules, %{}, fn rule_spec, acc_map -> 126 | {identifier, initial_acc} = get_rule_identifier_and_initial_acc(rule_spec) 127 | Map.put_new(acc_map, identifier, initial_acc) 128 | end) 129 | end 130 | 131 | @spec get_rule_identifier_and_initial_acc(rule_spec(variable, acc)) :: 132 | {acc_id(variable, acc), acc} 133 | when variable: term(), acc: term() 134 | defp get_rule_identifier_and_initial_acc(module) when is_atom(module) do 135 | {module, nil} 136 | end 137 | 138 | defp get_rule_identifier_and_initial_acc(fun) when is_function(fun) do 139 | {fun, nil} 140 | end 141 | 142 | defp get_rule_identifier_and_initial_acc({fun, initial_acc}) when is_function(fun) do 143 | {fun, initial_acc} 144 | end 145 | 146 | defp get_rule_identifier_and_initial_acc({fun, initial_acc, _options}) when is_function(fun) do 147 | {fun, initial_acc} 148 | end 149 | 150 | @spec get_rule_properties(rule_spec(variable, acc)) :: %{ 151 | exclusive?: boolean(), 152 | type: :prewalk | :postwalk, 153 | needs_reapplication?: boolean() 154 | } 155 | when variable: term(), acc: term() 156 | defp get_rule_properties(module) when is_atom(module) do 157 | %{ 158 | exclusive?: module.exclusive?(), 159 | type: module.type(), 160 | needs_reapplication?: module.needs_reapplication?() 161 | } 162 | end 163 | 164 | defp get_rule_properties(fun) when is_function(fun) do 165 | %{ 166 | exclusive?: false, 167 | type: :postwalk, 168 | needs_reapplication?: false 169 | } 170 | end 171 | 172 | defp get_rule_properties({fun, _initial_acc}) when is_function(fun) do 173 | %{ 174 | exclusive?: false, 175 | type: :postwalk, 176 | needs_reapplication?: false 177 | } 178 | end 179 | 180 | defp get_rule_properties({fun, _initial_acc, options}) when is_function(fun) do 181 | %{ 182 | exclusive?: Keyword.get(options, :exclusive?, false), 183 | type: Keyword.get(options, :type, :postwalk), 184 | needs_reapplication?: Keyword.get(options, :needs_reapplication?, false) 185 | } 186 | end 187 | 188 | @spec get_rule_identifier(rule_spec(variable, acc)) :: acc_id(variable, acc) 189 | when variable: term(), acc: term() 190 | defp get_rule_identifier(module) when is_atom(module), do: module 191 | defp get_rule_identifier(fun) when is_function(fun), do: fun 192 | defp get_rule_identifier({fun, _initial_acc}) when is_function(fun), do: fun 193 | defp get_rule_identifier({fun, _initial_acc, _options}) when is_function(fun), do: fun 194 | 195 | @spec create_walker_function(rule_spec(variable, acc), acc_id(variable, acc)) :: 196 | (Expression.t(variable), 197 | acc_map( 198 | variable, 199 | acc 200 | ) -> 201 | {Expression.t(variable), 202 | acc_map( 203 | variable, 204 | acc 205 | )}) 206 | when variable: term(), acc: term() 207 | defp create_walker_function(module, identifier) when is_atom(module) do 208 | fn expr, acc_map -> 209 | current_acc = Map.get(acc_map, identifier) 210 | {new_expr, new_acc} = module.walk_stateful(expr, current_acc) 211 | {new_expr, Map.put(acc_map, identifier, new_acc)} 212 | end 213 | end 214 | 215 | defp create_walker_function(fun, _identifier) when is_function(fun) do 216 | fn expr, acc_map -> 217 | # Function with no accumulator 218 | new_expr = fun.(expr) 219 | {new_expr, acc_map} 220 | end 221 | end 222 | 223 | defp create_walker_function({fun, _initial_acc}, identifier) when is_function(fun) do 224 | fn expr, acc_map -> 225 | current_acc = Map.get(acc_map, identifier) 226 | {new_expr, new_acc} = fun.(expr, current_acc) 227 | {new_expr, Map.put(acc_map, identifier, new_acc)} 228 | end 229 | end 230 | 231 | defp create_walker_function({fun, _initial_acc, _options}, identifier) when is_function(fun) do 232 | fn expr, acc_map -> 233 | current_acc = Map.get(acc_map, identifier) 234 | {new_expr, new_acc} = fun.(expr, current_acc) 235 | {new_expr, Map.put(acc_map, identifier, new_acc)} 236 | end 237 | end 238 | 239 | @spec chunk_rules([rule_spec(variable, acc)]) :: [[rule_spec(variable, acc)]] 240 | when variable: term(), acc: term() 241 | defp chunk_rules(rules) do 242 | rules 243 | |> Enum.reduce([], fn 244 | rule, [] -> 245 | [[rule]] 246 | 247 | rule, [current_chunk | rest_chunks] = chunks -> 248 | if can_chunk_with?(rule, hd(current_chunk)) do 249 | [[rule | current_chunk] | rest_chunks] 250 | else 251 | [[rule] | chunks] 252 | end 253 | end) 254 | |> Enum.map(&Enum.reverse/1) 255 | |> Enum.reverse() 256 | end 257 | 258 | @spec can_chunk_with?(rule_spec(variable, acc), rule_spec(variable, acc)) :: boolean() 259 | when variable: term(), acc: term() 260 | defp can_chunk_with?(rule1, rule2) do 261 | props1 = get_rule_properties(rule1) 262 | props2 = get_rule_properties(rule2) 263 | !props1.exclusive? and !props2.exclusive? and props1.type == props2.type 264 | end 265 | 266 | @spec apply_chunk_with_reapplication( 267 | [rule_spec(variable, acc)], 268 | {Expression.t(variable), acc_map(variable, acc)} 269 | ) :: {Expression.t(variable), acc_map(variable, acc)} 270 | when variable: term(), acc: term() 271 | defp apply_chunk_with_reapplication(chunk, {expression, acc_map}) do 272 | {new_expression, new_acc_map} = apply_chunk(chunk, {expression, acc_map}) 273 | 274 | if new_expression != expression and chunk_needs_reapplication?(chunk) do 275 | apply_chunk_with_reapplication(chunk, {new_expression, new_acc_map}) 276 | else 277 | {new_expression, new_acc_map} 278 | end 279 | end 280 | 281 | @spec apply_chunk([rule_spec(variable, acc)], {Expression.t(variable), acc_map(variable, acc)}) :: 282 | {Expression.t(variable), acc_map(variable, acc)} 283 | when variable: term(), acc: term() 284 | defp apply_chunk([rule], {expression, acc_map}) do 285 | # Single rule - apply directly 286 | apply_single_rule(rule, {expression, acc_map}) 287 | end 288 | 289 | defp apply_chunk(rules, {expression, acc_map}) do 290 | # Multiple rules - compose them 291 | walker_specs = Enum.map(rules, &rule_to_walker_spec/1) 292 | composed = compose_walker(walker_specs) 293 | type = get_rule_properties(hd(rules)).type 294 | 295 | case type do 296 | :prewalk -> apply_walker(expression, acc_map, composed, &Expression.prewalk/3) 297 | :postwalk -> apply_walker(expression, acc_map, composed, &Expression.postwalk/3) 298 | end 299 | end 300 | 301 | @spec apply_single_rule( 302 | rule_spec(variable, acc), 303 | {Expression.t(variable), acc_map(variable, acc)} 304 | ) :: 305 | {Expression.t(variable), acc_map(variable, acc)} 306 | when variable: term(), acc: term() 307 | defp apply_single_rule(rule, {expression, acc_map}) do 308 | identifier = get_rule_identifier(rule) 309 | walker_fun = create_walker_function(rule, identifier) 310 | type = get_rule_properties(rule).type 311 | 312 | case type do 313 | :prewalk -> Expression.prewalk(expression, acc_map, walker_fun) 314 | :postwalk -> Expression.postwalk(expression, acc_map, walker_fun) 315 | end 316 | end 317 | 318 | @spec rule_to_walker_spec(rule_spec(variable, acc)) :: 319 | (Expression.t(variable), acc_map(variable, acc) -> 320 | {Expression.t(variable), acc_map(variable, acc)}) 321 | when variable: term(), acc: term() 322 | defp rule_to_walker_spec(rule) do 323 | identifier = get_rule_identifier(rule) 324 | create_walker_function(rule, identifier) 325 | end 326 | 327 | @spec apply_walker(Expression.t(variable), acc_map(variable, acc), walker_fun, walk_fun) :: 328 | {Expression.t(variable), acc_map(variable, acc)} 329 | when variable: term(), 330 | acc: term(), 331 | walker_fun: (Expression.t(variable), acc_map(variable, acc) -> 332 | {Expression.t(variable), acc_map(variable, acc)}), 333 | walk_fun: (Expression.t(variable), acc_map(variable, acc), walker_fun -> 334 | {Expression.t(variable), acc_map(variable, acc)}) 335 | defp apply_walker(expression, acc_map, walker_fun, walk_fun) do 336 | walk_fun.(expression, acc_map, walker_fun) 337 | end 338 | 339 | @spec chunk_needs_reapplication?([rule_spec(variable, acc)]) :: boolean() 340 | when variable: term(), acc: term() 341 | defp chunk_needs_reapplication?(chunk) do 342 | Enum.any?(chunk, &get_rule_properties(&1).needs_reapplication?) 343 | end 344 | 345 | @spec compose_walker([walker_fun]) :: 346 | (Expression.t(variable), acc_map(variable, acc) -> 347 | {Expression.t(variable), acc_map(variable, acc)}) 348 | when variable: term(), 349 | acc: term(), 350 | walker_fun: (Expression.t(variable), acc_map(variable, acc) -> 351 | {Expression.t(variable), acc_map(variable, acc)}) 352 | defp compose_walker(walker_specs) do 353 | fn expression, acc_map -> 354 | Enum.reduce(walker_specs, {expression, acc_map}, fn walker_func, {expr, current_acc_map} -> 355 | walker_func.(expr, current_acc_map) 356 | end) 357 | end 358 | end 359 | 360 | defmacro __using__(_opts) do 361 | quote do 362 | @behaviour unquote(__MODULE__) 363 | 364 | @impl unquote(__MODULE__) 365 | def exclusive?, do: false 366 | 367 | @impl unquote(__MODULE__) 368 | def type, do: :postwalk 369 | 370 | @impl unquote(__MODULE__) 371 | def walk_stateful(expression, acc) do 372 | unquote(__MODULE__).default_walk_stateful(__MODULE__, expression, acc) 373 | end 374 | 375 | @impl unquote(__MODULE__) 376 | def needs_reapplication?, do: false 377 | 378 | defoverridable exclusive?: 0, type: 0, walk_stateful: 2, needs_reapplication?: 0 379 | end 380 | end 381 | end 382 | --------------------------------------------------------------------------------