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.
nl/nl.py ADDED
@@ -0,0 +1,449 @@
1
+ """
2
+ nl/nl.py — Natural Language layer for the boolean algebra engine.
3
+
4
+ Pipeline:
5
+ plain English → LLM (parse) → boolean expression
6
+ → core engine → truth table + minimal form
7
+ → LLM (explain) → plain English result
8
+
9
+ Supports any LLM via the Provider protocol. Built-in providers:
10
+ AnthropicProvider — Claude (default)
11
+ OpenAIProvider — GPT-4o, GPT-4, etc.
12
+ OllamaProvider — local models via Ollama (llama3, mistral, etc.)
13
+ OpenAICompatProvider — any OpenAI-compatible endpoint (Groq, Together, etc.)
14
+
15
+ Public API:
16
+ ask(sentence, provider) → NLResult
17
+ check_rules(rules, provider) → dict
18
+
19
+ Quick start:
20
+ from nl.nl import ask, AnthropicProvider
21
+ result = ask("lights on when door open or motion detected but not both",
22
+ provider=AnthropicProvider()) # uses ANTHROPIC_API_KEY env var
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ from abc import ABC, abstractmethod
29
+ from dataclasses import dataclass
30
+
31
+ from core.evaluator import evaluate
32
+ from core.synthesizer import synthesize
33
+ from core.parser import validate
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Prompts — shared across all providers
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _PARSE_SYSTEM = """\
41
+ You are a boolean algebra parser. Your job is to convert a natural language
42
+ description of a logical rule or condition into a boolean expression.
43
+
44
+ Rules:
45
+ - Variables must be single uppercase letters: A, B, C, D, ...
46
+ - Operators: ! (NOT), . (AND), ^ (XOR), + (OR)
47
+ - Parentheses for grouping
48
+ - Return ONLY valid JSON, no markdown, no explanation
49
+
50
+ Output format:
51
+ {
52
+ "expression": "<boolean expression>",
53
+ "variables": {
54
+ "A": "<what A represents>",
55
+ "B": "<what B represents>",
56
+ ...
57
+ },
58
+ "assumptions": "<any assumptions you made parsing ambiguous language>"
59
+ }
60
+
61
+ Examples:
62
+ Input: "lights on when door open or motion detected but not both"
63
+ Output: {"expression": "D^M", "variables": {"D": "door is open", "M": "motion detected"}, "assumptions": "interpreted 'but not both' as XOR"}
64
+
65
+ Input: "access granted if user is admin and authenticated, or if read-only and authenticated"
66
+ Output: {"expression": "A.(B+C)", "variables": {"A": "user is authenticated", "B": "user is admin", "C": "request is read-only"}, "assumptions": "factored out authentication as common condition"}
67
+ """
68
+
69
+ _EXPLAIN_SYSTEM = """\
70
+ You are a boolean logic explainer. You are given the result of evaluating a
71
+ boolean expression and must explain it in plain English clearly and concisely.
72
+
73
+ Be direct. Lead with the most important finding (contradiction, tautology, or
74
+ key insight). Then explain what the minimal form means. Keep it under 150 words.
75
+ """
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Provider protocol
80
+ # ---------------------------------------------------------------------------
81
+
82
+ class Provider(ABC):
83
+ """
84
+ Abstract base for LLM providers. Implement complete() to add a new model.
85
+
86
+ The NL layer calls complete() twice per request — once to parse the sentence
87
+ into a boolean expression, once to explain the result. Both are plain
88
+ text-in, text-out with a system prompt and a user message.
89
+ """
90
+
91
+ @abstractmethod
92
+ def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
93
+ """
94
+ Call the LLM and return the response text.
95
+
96
+ Args:
97
+ system: System prompt.
98
+ user: User message.
99
+ max_tokens: Maximum tokens in the response.
100
+
101
+ Returns:
102
+ Response text as a plain string.
103
+ """
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Built-in providers
108
+ # ---------------------------------------------------------------------------
109
+
110
+ class AnthropicProvider(Provider):
111
+ """
112
+ Claude via Anthropic SDK.
113
+
114
+ Args:
115
+ api_key: Anthropic API key. Falls back to ANTHROPIC_API_KEY env var.
116
+ model: Model ID. Defaults to claude-sonnet-4-6.
117
+
118
+ Install: pip install anthropic
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ api_key: str | None = None,
124
+ model: str = "claude-sonnet-4-6",
125
+ ):
126
+ try:
127
+ import anthropic as _anthropic
128
+ except ImportError:
129
+ raise ImportError("pip install anthropic")
130
+ self._client = _anthropic.Anthropic(
131
+ api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
132
+ )
133
+ self.model = model
134
+
135
+ def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
136
+ response = self._client.messages.create(
137
+ model=self.model,
138
+ max_tokens=max_tokens,
139
+ system=system,
140
+ messages=[{"role": "user", "content": user}],
141
+ )
142
+ return response.content[0].text.strip()
143
+
144
+
145
+ class OpenAIProvider(Provider):
146
+ """
147
+ GPT-4o, GPT-4, GPT-3.5, or any OpenAI model.
148
+
149
+ Args:
150
+ api_key: OpenAI API key. Falls back to OPENAI_API_KEY env var.
151
+ model: Model ID. Defaults to gpt-4o.
152
+
153
+ Install: pip install openai
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ api_key: str | None = None,
159
+ model: str = "gpt-4o",
160
+ ):
161
+ try:
162
+ from openai import OpenAI
163
+ except ImportError:
164
+ raise ImportError("pip install openai")
165
+ self._client = OpenAI(
166
+ api_key=api_key or os.environ.get("OPENAI_API_KEY")
167
+ )
168
+ self.model = model
169
+
170
+ def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
171
+ response = self._client.chat.completions.create(
172
+ model=self.model,
173
+ max_tokens=max_tokens,
174
+ messages=[
175
+ {"role": "system", "content": system},
176
+ {"role": "user", "content": user},
177
+ ],
178
+ )
179
+ return response.choices[0].message.content.strip()
180
+
181
+
182
+ class OllamaProvider(Provider):
183
+ """
184
+ Local models via Ollama (llama3, mistral, phi3, gemma, etc.).
185
+ Ollama must be running locally: https://ollama.com
186
+
187
+ Args:
188
+ model: Ollama model name. Defaults to llama3.
189
+ base_url: Ollama API base URL. Defaults to http://localhost:11434.
190
+
191
+ Install: pip install ollama (or use OllamaProvider via openai SDK)
192
+ No API key needed — runs fully locally.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ model: str = "llama3",
198
+ base_url: str = "http://localhost:11434",
199
+ ):
200
+ self.model = model
201
+ self.base_url = base_url.rstrip("/")
202
+
203
+ def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
204
+ import urllib.request
205
+ payload = json.dumps({
206
+ "model": self.model,
207
+ "messages": [
208
+ {"role": "system", "content": system},
209
+ {"role": "user", "content": user},
210
+ ],
211
+ "stream": False,
212
+ "options": {"num_predict": max_tokens},
213
+ }).encode()
214
+ req = urllib.request.Request(
215
+ f"{self.base_url}/api/chat",
216
+ data=payload,
217
+ headers={"Content-Type": "application/json"},
218
+ )
219
+ with urllib.request.urlopen(req) as resp:
220
+ data = json.loads(resp.read())
221
+ return data["message"]["content"].strip()
222
+
223
+
224
+ class OpenAICompatProvider(Provider):
225
+ """
226
+ Any OpenAI-compatible endpoint: Groq, Together AI, Fireworks, Mistral API,
227
+ LM Studio, vLLM, etc.
228
+
229
+ Args:
230
+ api_key: API key for the provider.
231
+ base_url: Base URL of the OpenAI-compatible API.
232
+ model: Model ID as accepted by the provider.
233
+
234
+ Install: pip install openai
235
+
236
+ Examples:
237
+ # Groq
238
+ OpenAICompatProvider(
239
+ api_key=os.environ["GROQ_API_KEY"],
240
+ base_url="https://api.groq.com/openai/v1",
241
+ model="llama3-8b-8192",
242
+ )
243
+ # Together AI
244
+ OpenAICompatProvider(
245
+ api_key=os.environ["TOGETHER_API_KEY"],
246
+ base_url="https://api.together.xyz/v1",
247
+ model="mistralai/Mixtral-8x7B-Instruct-v0.1",
248
+ )
249
+ # Local LM Studio
250
+ OpenAICompatProvider(
251
+ api_key="lm-studio",
252
+ base_url="http://localhost:1234/v1",
253
+ model="local-model",
254
+ )
255
+ """
256
+
257
+ def __init__(self, api_key: str, base_url: str, model: str):
258
+ try:
259
+ from openai import OpenAI
260
+ except ImportError:
261
+ raise ImportError("pip install openai")
262
+ self._client = OpenAI(api_key=api_key, base_url=base_url)
263
+ self.model = model
264
+
265
+ def complete(self, system: str, user: str, max_tokens: int = 512) -> str:
266
+ response = self._client.chat.completions.create(
267
+ model=self.model,
268
+ max_tokens=max_tokens,
269
+ messages=[
270
+ {"role": "system", "content": system},
271
+ {"role": "user", "content": user},
272
+ ],
273
+ )
274
+ return response.choices[0].message.content.strip()
275
+
276
+
277
+ def _default_provider() -> Provider:
278
+ """Return AnthropicProvider if key is set, else raise a clear error."""
279
+ if os.environ.get("ANTHROPIC_API_KEY"):
280
+ return AnthropicProvider()
281
+ raise ValueError(
282
+ "No provider specified and ANTHROPIC_API_KEY is not set.\n"
283
+ "Pass a provider explicitly:\n"
284
+ " ask(sentence, provider=AnthropicProvider(api_key='...'))\n"
285
+ " ask(sentence, provider=OpenAIProvider(api_key='...'))\n"
286
+ " ask(sentence, provider=OllamaProvider(model='llama3'))"
287
+ )
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Result type
292
+ # ---------------------------------------------------------------------------
293
+
294
+ @dataclass
295
+ class NLResult:
296
+ input_sentence: str
297
+ expression: str
298
+ variables: dict[str, str]
299
+ minimal: str
300
+ satisfiable: bool
301
+ tautology: bool
302
+ contradiction: bool
303
+ minterms: list[int]
304
+ maxterms: list[int]
305
+ explanation: str
306
+ rows: list[dict]
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Public API
311
+ # ---------------------------------------------------------------------------
312
+
313
+ def ask(sentence: str, provider: Provider | None = None) -> NLResult:
314
+ """
315
+ Convert a plain English logical statement into a verified boolean result.
316
+
317
+ Args:
318
+ sentence: Plain English description of a logical rule or condition.
319
+ provider: LLM provider to use. Defaults to AnthropicProvider() if
320
+ ANTHROPIC_API_KEY is set, otherwise raises ValueError.
321
+
322
+ Returns:
323
+ NLResult with expression, truth table, minimal form, and explanation.
324
+
325
+ Raises:
326
+ ValueError: If the LLM cannot parse the sentence into a valid expression.
327
+
328
+ Examples:
329
+ from nl.nl import ask, AnthropicProvider, OpenAIProvider, OllamaProvider
330
+
331
+ # Claude
332
+ result = ask("door opens when key valid and not locked",
333
+ provider=AnthropicProvider())
334
+
335
+ # GPT-4o
336
+ result = ask("...", provider=OpenAIProvider())
337
+
338
+ # Local llama3 via Ollama (no API key, free)
339
+ result = ask("...", provider=OllamaProvider(model="llama3"))
340
+
341
+ # Groq (fast, cheap)
342
+ result = ask("...", provider=OpenAICompatProvider(
343
+ api_key=os.environ["GROQ_API_KEY"],
344
+ base_url="https://api.groq.com/openai/v1",
345
+ model="llama3-8b-8192",
346
+ ))
347
+ """
348
+ llm = provider or _default_provider()
349
+
350
+ # Step 1: NL → expression
351
+ raw = llm.complete(_PARSE_SYSTEM, sentence, max_tokens=512)
352
+ try:
353
+ parsed = json.loads(raw)
354
+ except json.JSONDecodeError:
355
+ raise ValueError(f"LLM returned non-JSON: {raw}")
356
+
357
+ expression = parsed.get("expression", "")
358
+ variables = parsed.get("variables", {})
359
+ assumptions = parsed.get("assumptions", "")
360
+
361
+ error = validate(expression)
362
+ if error:
363
+ raise ValueError(
364
+ f"LLM produced invalid expression '{expression}': {error}\n"
365
+ f"Assumptions: {assumptions}"
366
+ )
367
+
368
+ # Step 2: evaluate + synthesize
369
+ table, _ = evaluate(expression)
370
+ minimal, _ = synthesize(table)
371
+
372
+ # Step 3: result → explanation
373
+ explain_prompt = (
374
+ f"Sentence: {sentence}\n"
375
+ f"Parsed as: {expression}\n"
376
+ f"Variables: {json.dumps(variables)}\n"
377
+ f"Minimal form: {minimal}\n"
378
+ f"Satisfiable: {table.satisfiable}\n"
379
+ f"Tautology: {table.tautology}\n"
380
+ f"Contradiction: {not table.satisfiable}\n"
381
+ f"Minterms (rows where true): {table.minterms}\n"
382
+ f"Total rows: {len(table.rows)}\n"
383
+ f"Assumptions made: {assumptions}"
384
+ )
385
+ explanation = llm.complete(_EXPLAIN_SYSTEM, explain_prompt, max_tokens=300)
386
+
387
+ return NLResult(
388
+ input_sentence=sentence,
389
+ expression=expression,
390
+ variables=variables,
391
+ minimal=minimal,
392
+ satisfiable=table.satisfiable,
393
+ tautology=table.tautology,
394
+ contradiction=not table.satisfiable,
395
+ minterms=table.minterms,
396
+ maxterms=table.maxterms,
397
+ explanation=explanation,
398
+ rows=[{**row.inputs, "output": row.output} for row in table.rows],
399
+ )
400
+
401
+
402
+ def check_rules(rules: list[str], provider: Provider | None = None) -> dict:
403
+ """
404
+ Parse and verify a list of plain English rules for contradictions,
405
+ tautologies, and pairwise conflicts.
406
+
407
+ Args:
408
+ rules: List of plain English rule descriptions.
409
+ provider: LLM provider to use. Defaults to AnthropicProvider().
410
+
411
+ Returns:
412
+ Dictionary with per-rule analysis and pairwise conflict/equivalence checks.
413
+ """
414
+ from mcp_server.server import check_prompt_logic as _check
415
+
416
+ llm = provider or _default_provider()
417
+ expressions = []
418
+ variable_maps = []
419
+ errors = []
420
+
421
+ for rule in rules:
422
+ try:
423
+ raw = llm.complete(_PARSE_SYSTEM, rule, max_tokens=512)
424
+ parsed = json.loads(raw)
425
+ expr = parsed.get("expression", "")
426
+ error = validate(expr)
427
+ if error:
428
+ errors.append({"rule": rule, "error": error})
429
+ else:
430
+ expressions.append(expr)
431
+ variable_maps.append({
432
+ "rule": rule,
433
+ "expression": expr,
434
+ "variables": parsed.get("variables", {}),
435
+ })
436
+ except Exception as e:
437
+ errors.append({"rule": rule, "error": str(e)})
438
+
439
+ engine_result = _check(expressions) if expressions else {
440
+ "rules": [], "pairwise": [], "summary": {}
441
+ }
442
+
443
+ for i, item in enumerate(engine_result.get("rules", [])):
444
+ if i < len(variable_maps):
445
+ item["original_rule"] = variable_maps[i]["rule"]
446
+ item["variables"] = variable_maps[i]["variables"]
447
+
448
+ engine_result["parse_errors"] = errors
449
+ return engine_result