boolean-algebra-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
core/synthesizer.py ADDED
@@ -0,0 +1,167 @@
1
+ """
2
+ core/synthesizer.py — minimal expression synthesis via Quine-McCluskey.
3
+
4
+ Public API:
5
+ synthesize(truth_table) → (str, PerformanceMetrics)
6
+
7
+ The inverse of evaluate(): given a TruthTable, returns the minimal sum-of-
8
+ products boolean expression that produces it. Uses the Quine-McCluskey
9
+ algorithm: iteratively merge minterms that differ by one bit to find prime
10
+ implicants, then select the minimum cover using essential prime implicants
11
+ first, greedy cover for the remainder.
12
+
13
+ Special cases:
14
+ No minterms (contradiction) → '0'
15
+ All minterms (tautology) → '1'
16
+ """
17
+ from __future__ import annotations
18
+ import time
19
+ import tracemalloc
20
+ from .models import TruthTable, PerformanceMetrics
21
+
22
+
23
+ def _can_merge(a: str, b: str) -> tuple[bool, str]:
24
+ """Return (True, merged) if two implicants differ in exactly one non-dash bit."""
25
+ diff_count = 0
26
+ diff_pos = -1
27
+ for i, (x, y) in enumerate(zip(a, b)):
28
+ if x != y:
29
+ if x == '-' or y == '-':
30
+ return False, ''
31
+ diff_count += 1
32
+ diff_pos = i
33
+ if diff_count > 1:
34
+ return False, ''
35
+ if diff_count == 1:
36
+ result = list(a)
37
+ result[diff_pos] = '-'
38
+ return True, ''.join(result)
39
+ return False, ''
40
+
41
+
42
+ def _covers(implicant: str, minterm: int, n: int) -> bool:
43
+ """Return True if an implicant pattern covers a given minterm index."""
44
+ bits = format(minterm, f'0{n}b')
45
+ return all(p == '-' or p == b for p, b in zip(implicant, bits))
46
+
47
+
48
+ def _minimum_cover(prime_implicants: list[str], minterms: list[int], n: int) -> list[str]:
49
+ """Select the minimum set of prime implicants that covers all minterms."""
50
+ coverage = {pi: {m for m in minterms if _covers(pi, m, n)} for pi in prime_implicants}
51
+ selected = []
52
+ covered = set()
53
+
54
+ for m in minterms:
55
+ covering = [pi for pi in prime_implicants if m in coverage[pi]]
56
+ if len(covering) == 1 and covering[0] not in selected:
57
+ selected.append(covering[0])
58
+ covered |= coverage[covering[0]]
59
+
60
+ remaining = set(minterms) - covered
61
+ while remaining:
62
+ best = max(
63
+ (pi for pi in prime_implicants if pi not in selected),
64
+ key=lambda pi: len(coverage[pi] & remaining),
65
+ default=None,
66
+ )
67
+ if best is None:
68
+ break
69
+ selected.append(best)
70
+ covered |= coverage[best]
71
+ remaining -= coverage[best]
72
+
73
+ return selected
74
+
75
+
76
+ def _pi_to_expr(pi: str, variables: list[str]) -> str:
77
+ """Convert a prime implicant bit pattern to a boolean product term string."""
78
+ terms = []
79
+ for bit, var in zip(pi, variables):
80
+ if bit == '1':
81
+ terms.append(var)
82
+ elif bit == '0':
83
+ terms.append(f'!{var}')
84
+ return '.'.join(terms) if terms else '1'
85
+
86
+
87
+ def synthesize(truth_table: TruthTable) -> tuple[str, PerformanceMetrics]:
88
+ """
89
+ Synthesize the minimal boolean expression for a given truth table.
90
+
91
+ Uses Quine-McCluskey to find all prime implicants, then selects the
92
+ minimum cover — essential prime implicants first, greedy for the rest.
93
+
94
+ Args:
95
+ truth_table: A TruthTable returned by evaluate().
96
+
97
+ Returns:
98
+ (expression, PerformanceMetrics) — minimal SOP expression string
99
+ and timing/memory data. Returns '0' for contradictions, '1' for
100
+ tautologies.
101
+
102
+ Example:
103
+ table, _ = evaluate('A.B+A.!B')
104
+ expr, metrics = synthesize(table)
105
+ print(expr) # 'A'
106
+ print(metrics.prime_implicant_count) # 1
107
+ """
108
+ minterms = truth_table.minterms
109
+ variables = truth_table.variables
110
+ n = len(variables)
111
+
112
+ tracemalloc.start()
113
+ t_start = time.perf_counter()
114
+
115
+ if not minterms:
116
+ synth_time_ms = (time.perf_counter() - t_start) * 1000
117
+ _, peak = tracemalloc.get_traced_memory()
118
+ tracemalloc.stop()
119
+ return '0', PerformanceMetrics(synth_time_ms=round(synth_time_ms, 4), peak_memory_bytes=peak, prime_implicant_count=0)
120
+
121
+ if len(minterms) == 2 ** n:
122
+ synth_time_ms = (time.perf_counter() - t_start) * 1000
123
+ _, peak = tracemalloc.get_traced_memory()
124
+ tracemalloc.stop()
125
+ return '1', PerformanceMetrics(synth_time_ms=round(synth_time_ms, 4), peak_memory_bytes=peak, prime_implicant_count=0)
126
+
127
+ current: dict[str, set[int]] = {format(m, f'0{n}b'): {m} for m in minterms}
128
+ prime_implicants: list[str] = []
129
+
130
+ while True:
131
+ next_round: dict[str, set[int]] = {}
132
+ used: set[str] = set()
133
+ items = list(current.items())
134
+
135
+ for i in range(len(items)):
136
+ for j in range(i + 1, len(items)):
137
+ a, a_cov = items[i]
138
+ b, b_cov = items[j]
139
+ ok, merged = _can_merge(a, b)
140
+ if ok:
141
+ if merged not in next_round:
142
+ next_round[merged] = set()
143
+ next_round[merged] |= a_cov | b_cov
144
+ used.add(a)
145
+ used.add(b)
146
+
147
+ for term in current:
148
+ if term not in used:
149
+ prime_implicants.append(term)
150
+
151
+ if not next_round:
152
+ break
153
+ current = next_round
154
+
155
+ selected = _minimum_cover(prime_implicants, minterms, n)
156
+ expr = '+'.join(_pi_to_expr(pi, variables) for pi in selected)
157
+
158
+ synth_time_ms = (time.perf_counter() - t_start) * 1000
159
+ _, peak = tracemalloc.get_traced_memory()
160
+ tracemalloc.stop()
161
+
162
+ metrics = PerformanceMetrics(
163
+ synth_time_ms=round(synth_time_ms, 4),
164
+ peak_memory_bytes=peak,
165
+ prime_implicant_count=len(prime_implicants),
166
+ )
167
+ return expr, metrics
mcp_server/__init__.py ADDED
File without changes
mcp_server/server.py ADDED
@@ -0,0 +1,247 @@
1
+ """
2
+ mcp_server/server.py — MCP server wrapping the boolean algebra engine.
3
+
4
+ Exposes five tools Claude can call mid-conversation:
5
+
6
+ evaluate expression → full truth table + metrics
7
+ simplify expression → minimal equivalent expression
8
+ equivalent expr1, expr2 → bool (same truth table?)
9
+ satisfiable expression → bool (any row outputs 1?)
10
+ check_contradiction expression → bool (no row outputs 1?)
11
+
12
+ Run with:
13
+ python3.11 -m mcp_server.server
14
+ or via the MCP CLI:
15
+ mcp dev mcp_server/server.py
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ import os
21
+
22
+ # Allow running from project root: `python3.11 -m mcp_server.server`
23
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
24
+
25
+ from mcp.server.fastmcp import FastMCP
26
+ from core.evaluator import evaluate as _evaluate
27
+ from core.synthesizer import synthesize as _synthesize
28
+
29
+ mcp = FastMCP(
30
+ "boolean-algebra-engine",
31
+ instructions=(
32
+ "Tools for evaluating boolean algebra expressions. "
33
+ "Use these to verify logic exactly — do not predict boolean results yourself. "
34
+ "Variables must be uppercase letters A-Z. "
35
+ "Operators: ! (NOT), . (AND), ^ (XOR), + (OR). "
36
+ "Parentheses override precedence."
37
+ ),
38
+ )
39
+
40
+
41
+ @mcp.tool()
42
+ def evaluate(expression: str) -> dict:
43
+ """
44
+ Evaluate a boolean expression and return its full truth table.
45
+
46
+ Args:
47
+ expression: Boolean expression using variables A-Z and operators ! . ^ +
48
+ Example: "A.(B+C)", "!(A.B)", "A^B"
49
+
50
+ Returns:
51
+ Dictionary with keys: expression, variables, rows (list of input/output dicts),
52
+ satisfiable, tautology, minterms, maxterms, eval_time_ms, rows_evaluated.
53
+ """
54
+ table, metrics = _evaluate(expression)
55
+ return {
56
+ "expression": table.expression,
57
+ "variables": table.variables,
58
+ "rows": [{**row.inputs, "output": row.output} for row in table.rows],
59
+ "satisfiable": table.satisfiable,
60
+ "tautology": table.tautology,
61
+ "minterms": table.minterms,
62
+ "maxterms": table.maxterms,
63
+ "eval_time_ms": metrics.eval_time_ms,
64
+ "rows_evaluated": metrics.rows_evaluated,
65
+ }
66
+
67
+
68
+ @mcp.tool()
69
+ def simplify(expression: str) -> dict:
70
+ """
71
+ Simplify a boolean expression to its minimal sum-of-products form.
72
+
73
+ Useful for finding redundant conditions, dead branches, or the canonical
74
+ form of a complex expression.
75
+
76
+ Args:
77
+ expression: Boolean expression using variables A-Z and operators ! . ^ +
78
+ Example: "A.B+A.!B" simplifies to "A"
79
+
80
+ Returns:
81
+ Dictionary with keys: original, minimal, changed (bool),
82
+ prime_implicant_count, synth_time_ms.
83
+ """
84
+ table, _ = _evaluate(expression)
85
+ minimal, metrics = _synthesize(table)
86
+ return {
87
+ "original": expression,
88
+ "minimal": minimal,
89
+ "changed": minimal != expression,
90
+ "prime_implicant_count": metrics.prime_implicant_count,
91
+ "synth_time_ms": metrics.synth_time_ms,
92
+ }
93
+
94
+
95
+ @mcp.tool()
96
+ def equivalent(expression1: str, expression2: str) -> dict:
97
+ """
98
+ Check whether two boolean expressions are logically equivalent.
99
+
100
+ Two expressions are equivalent if they produce identical output columns
101
+ for all possible input combinations.
102
+
103
+ Args:
104
+ expression1: First boolean expression.
105
+ expression2: Second boolean expression.
106
+
107
+ Returns:
108
+ Dictionary with keys: equivalent (bool), expression1, expression2,
109
+ and on mismatch: differing_rows (list of input combos where they differ).
110
+ """
111
+ t1, _ = _evaluate(expression1)
112
+ t2, _ = _evaluate(expression2)
113
+
114
+ vars1 = set(t1.variables)
115
+ vars2 = set(t2.variables)
116
+ all_vars = sorted(vars1 | vars2)
117
+
118
+ # Re-evaluate both over the union variable set for fair comparison
119
+ from core.parser import get_variables
120
+ combined_expr1 = expression1
121
+ combined_expr2 = expression2
122
+
123
+ # Evaluate over shared variable space
124
+ n = len(all_vars)
125
+ differing = []
126
+ for i in range(2 ** n):
127
+ values = {var: (i >> (n - 1 - j)) & 1 for j, var in enumerate(all_vars)}
128
+ # Filter to each expression's variables
129
+ from core.evaluator import _evaluate_prefix
130
+ from core.parser import infix_to_prefix
131
+ p1 = infix_to_prefix(expression1)
132
+ p2 = infix_to_prefix(expression2)
133
+ v1 = {k: v for k, v in values.items() if k in vars1}
134
+ v2 = {k: v for k, v in values.items() if k in vars2}
135
+ out1 = _evaluate_prefix(p1, v1)
136
+ out2 = _evaluate_prefix(p2, v2)
137
+ if out1 != out2:
138
+ differing.append({**values, expression1: out1, expression2: out2})
139
+
140
+ result: dict = {
141
+ "equivalent": len(differing) == 0,
142
+ "expression1": expression1,
143
+ "expression2": expression2,
144
+ }
145
+ if differing:
146
+ result["differing_rows"] = differing[:10] # cap at 10 for readability
147
+ result["total_differing"] = len(differing)
148
+ return result
149
+
150
+
151
+ @mcp.tool()
152
+ def satisfiable(expression: str) -> dict:
153
+ """
154
+ Check whether a boolean expression is satisfiable.
155
+
156
+ A satisfiable expression has at least one input combination that outputs 1.
157
+ An unsatisfiable expression is a contradiction (always 0).
158
+
159
+ Args:
160
+ expression: Boolean expression. Example: "A.!A" is unsatisfiable.
161
+
162
+ Returns:
163
+ Dictionary with keys: satisfiable (bool), expression,
164
+ and if satisfiable: example (first input combo that satisfies it).
165
+ """
166
+ table, _ = _evaluate(expression)
167
+ result: dict = {
168
+ "satisfiable": table.satisfiable,
169
+ "expression": expression,
170
+ }
171
+ if table.satisfiable:
172
+ first = table.rows[table.minterms[0]]
173
+ result["example"] = {**first.inputs, "output": first.output}
174
+ return result
175
+
176
+
177
+ @mcp.tool()
178
+ def check_prompt_logic(rules: list[str]) -> dict:
179
+ """
180
+ Check a set of boolean rules (e.g. from a system prompt) for contradictions,
181
+ tautologies, and pairwise equivalences.
182
+
183
+ Each rule should be a boolean expression using A-Z variables. Use consistent
184
+ variable naming across rules (e.g. A=user_authenticated, B=is_admin).
185
+
186
+ Args:
187
+ rules: List of boolean expressions representing logical conditions.
188
+ Example: ["A.B", "!A+!B", "A^B"]
189
+
190
+ Returns:
191
+ Dictionary with per-rule analysis (satisfiable, tautology, minimal form)
192
+ and pairwise checks (equivalent pairs, contradictory pairs).
193
+ """
194
+ analysis = []
195
+ for rule in rules:
196
+ try:
197
+ table, _ = _evaluate(rule)
198
+ minimal, _ = _synthesize(table)
199
+ analysis.append({
200
+ "rule": rule,
201
+ "satisfiable": table.satisfiable,
202
+ "tautology": table.tautology,
203
+ "contradiction": not table.satisfiable,
204
+ "minimal": minimal,
205
+ "simplified": minimal != rule,
206
+ })
207
+ except ValueError as e:
208
+ analysis.append({"rule": rule, "error": str(e)})
209
+
210
+ # Pairwise equivalence and contradiction checks
211
+ pairs = []
212
+ valid = [a for a in analysis if "error" not in a]
213
+ for i in range(len(valid)):
214
+ for j in range(i + 1, len(valid)):
215
+ r1, r2 = valid[i]["rule"], valid[j]["rule"]
216
+ try:
217
+ eq = equivalent(r1, r2)
218
+ t1, _ = _evaluate(r1)
219
+ t2, _ = _evaluate(r2)
220
+ # Contradiction: rules can never both be true simultaneously
221
+ combined = f"({r1}).({r2})"
222
+ combined_table, _ = _evaluate(combined)
223
+ pairs.append({
224
+ "rule1": r1,
225
+ "rule2": r2,
226
+ "equivalent": eq["equivalent"],
227
+ "can_both_be_true": combined_table.satisfiable,
228
+ "always_conflict": not combined_table.satisfiable,
229
+ })
230
+ except ValueError:
231
+ pass
232
+
233
+ return {
234
+ "rules": analysis,
235
+ "pairwise": pairs,
236
+ "summary": {
237
+ "total": len(rules),
238
+ "contradictions": sum(1 for a in analysis if a.get("contradiction")),
239
+ "tautologies": sum(1 for a in analysis if a.get("tautology")),
240
+ "equivalent_pairs": sum(1 for p in pairs if p.get("equivalent")),
241
+ "conflicting_pairs": sum(1 for p in pairs if p.get("always_conflict")),
242
+ },
243
+ }
244
+
245
+
246
+ if __name__ == "__main__":
247
+ mcp.run(transport="stdio")
nl/__init__.py ADDED
File without changes