| """ |
| Inference Engine — formal logical reasoning without an LLM. |
| |
| Handles: |
| · Modus Ponens: If P → Q and P, then Q |
| · Modus Tollens: If P → Q and ¬Q, then ¬P |
| · Hypothetical Syllogism: If P → Q and Q → R, then P → R |
| · Disjunctive Syllogism: If P ∨ Q and ¬P, then Q |
| · Categorical Syllogisms: All A are B; X is A; therefore X is B |
| · Propositional evaluation: "P and Q", "P or Q", "not P", "P implies Q" |
| · Contradiction detection: returns False when premises are contradictory |
| · Consistency checking: verifies a set of statements is mutually consistent |
| |
| All processing is pure Python — zero external dependencies. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| from dataclasses import dataclass, field |
| from typing import Optional |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class InferenceResult: |
| """Result from the inference engine.""" |
| conclusion: str = "" |
| valid: bool = False |
| rule_applied: str = "" |
| proof_steps: list[str] = field(default_factory=list) |
| confidence: str = "HIGH" |
| is_tautology: Optional[bool] = None |
| truth_value: Optional[bool] = None |
|
|
| def to_response(self) -> str: |
| lines = [f"Conclusion: {self.conclusion}"] |
| if self.rule_applied: |
| lines.append(f"Inference rule: {self.rule_applied}") |
| if self.proof_steps: |
| lines.append("Proof:") |
| for i, step in enumerate(self.proof_steps, 1): |
| lines.append(f" {i}. {step}") |
| if self.is_tautology is not None: |
| lines.append(f"Tautology: {'Yes' if self.is_tautology else 'No'}") |
| lines.append(f"Argument is: {'VALID' if self.valid else 'INVALID'}") |
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
|
|
| def _extract_if_then(text: str) -> Optional[tuple[str, str]]: |
| """Extract (antecedent, consequent) from 'if P then Q' patterns.""" |
| patterns = [ |
| r"if\s+(.+?)\s+then\s+(.+?)[\.,;]?$", |
| r"(.+?)\s+implies\s+(.+?)[\.,;]?$", |
| r"(.+?)\s+→\s+(.+?)[\.,;]?$", |
| r"when\s+(.+?)[,]\s+(.+?)[\.,;]?$", |
| r"whenever\s+(.+?)[,]\s+(.+?)[\.,;]?$", |
| ] |
| for pat in patterns: |
| m = re.search(pat, text.lower().strip()) |
| if m: |
| return m.group(1).strip(), m.group(2).strip() |
| return None |
|
|
|
|
| _ALL_VERBS = ( |
| "are", "have", "is", "require", "need", "must", "contain", |
| "use", "produce", "involve", "consist of", "depend on", |
| ) |
| _ALL_VERB_RE = re.compile( |
| r"all\s+(.+?)\s+" |
| r"(are|have|is|require|need|must|contain|use|produce|involve|consist\s+of|depend\s+on)" |
| r"\s+(.+?)[\.,;]?$" |
| ) |
|
|
| def _extract_all_are(text: str) -> Optional[tuple[str, str, str]]: |
| """ |
| Extract (category, property, predicate_verb) from |
| 'All A are/have/require/need/must/use B' patterns. |
| Returns (category, property, verb). |
| """ |
| t = text.lower().strip() |
| m = _ALL_VERB_RE.search(t) |
| if m: |
| return m.group(1).strip(), m.group(3).strip(), m.group(2).strip() |
| return None |
|
|
|
|
| def _extract_negation(text: str) -> Optional[str]: |
| """Extract the negated claim from negation patterns.""" |
| t = text.lower().strip(" .,;?!") |
|
|
| |
| for prefix in ["not ", "¬", "it is not the case that ", "it is false that "]: |
| if t.startswith(prefix): |
| return t[len(prefix):].strip() |
|
|
| |
| m = re.match(r"^(.+?)\s+did\s+not\s+(.+)$", t) |
| if m: |
| subject, verb_phrase = m.group(1).strip(), m.group(2).strip() |
| return f"{subject} will {verb_phrase}" |
|
|
| |
| m = re.match(r"^(.+?)\s+does\s+not\s+(.+)$", t) |
| if m: |
| subject, verb_phrase = m.group(1).strip(), m.group(2).strip() |
| return f"{subject} {verb_phrase}" |
|
|
| |
| m = re.match(r"^(.+?)\s+do\s+not\s+(.+)$", t) |
| if m: |
| subject, verb_phrase = m.group(1).strip(), m.group(2).strip() |
| return f"{subject} {verb_phrase}" |
|
|
| |
| m = re.match(r"^(.+?)\s+is\s+not\s+(.+)$", t) |
| if m: |
| subject, obj = m.group(1).strip(), m.group(2).strip() |
| return f"{subject} is {obj}" |
|
|
| |
| m = re.match(r"^(.+?)\s+are\s+not\s+(.+)$", t) |
| if m: |
| subject, obj = m.group(1).strip(), m.group(2).strip() |
| return f"{subject} are {obj}" |
|
|
| |
| m = re.match(r"^(.+?)\s+was\s+not\s+(.+)$", t) |
| if m: |
| subject, obj = m.group(1).strip(), m.group(2).strip() |
| return f"{subject} was {obj}" |
|
|
| |
| m = re.match(r"^(.+?)\s+has\s+not\s+(.+)$", t) |
| if m: |
| subject, obj = m.group(1).strip(), m.group(2).strip() |
| return f"{subject} has {obj}" |
|
|
| return None |
|
|
|
|
| def _normalise(text: str) -> str: |
| return text.lower().strip(" .,;?!") |
|
|
|
|
| def _negate_clause(clause: str) -> str: |
| """ |
| Produce a natural-language negation of a simple clause. |
| e.g. 'the temperature drops' → 'the temperature does not drop' |
| 'it rains' → 'it does not rain' |
| 'she is happy' → 'she is not happy' |
| """ |
| t = clause.lower().strip() |
|
|
| |
| for verb in ("is", "are", "was", "were"): |
| pat = rf"^(.*?)\b{verb}\b(.*)$" |
| m = re.match(pat, t) |
| if m: |
| return f"{m.group(1).strip()} {verb} not{m.group(2)}" |
|
|
| |
| m = re.match(r"^(.*?)\bwill\b(.*)$", t) |
| if m: |
| return f"{m.group(1).strip()} will not{m.group(2)}" |
|
|
| |
| m = re.match(r"^(.*?)\bcan\b(.*)$", t) |
| if m: |
| return f"{m.group(1).strip()} cannot{m.group(2)}" |
|
|
| |
| |
| words = t.split() |
| if len(words) == 1: |
| return f"not {clause}" |
| if len(words) <= 5: |
| |
| subject_part = " ".join(words[:-1]) |
| verb_part = words[-1] |
| return f"{subject_part} does not {verb_part}" |
| |
| subject_part = " ".join(words[:2]) |
| verb_part = " ".join(words[2:]) |
| return f"{subject_part} does not {verb_part}" |
|
|
|
|
| def _fuzzy_match(a: str, b: str) -> bool: |
| """True if a and b are close enough to be considered the same claim.""" |
| a, b = _normalise(a), _normalise(b) |
| if a == b: |
| return True |
| |
| def _stem(w: str) -> str: |
| for suf in ("ing", "ed", "s"): |
| if w.endswith(suf) and len(w) > len(suf) + 2: |
| return w[:-len(suf)] |
| return w |
|
|
| a_words = {_stem(w) for w in a.split()} |
| b_words = {_stem(w) for w in b.split()} |
| if not a_words or not b_words: |
| return False |
| overlap = a_words & b_words |
| union = a_words | b_words |
| |
| return len(overlap) / len(union) >= 0.5 |
|
|
|
|
| |
| |
| |
|
|
| def _eval_prop(expr: str, assignments: dict[str, bool]) -> Optional[bool]: |
| """ |
| Evaluate a simple propositional expression given variable assignments. |
| Supports: AND, OR, NOT, IMPLIES (→ / =>), IFF (<->) |
| Variables are single uppercase letters or short words. |
| Returns None if the expression cannot be parsed. |
| """ |
| expr = expr.strip() |
|
|
| |
| expr = re.sub(r"\bimplies\b", "=>", expr, flags=re.IGNORECASE) |
| expr = re.sub(r"→", "=>", expr) |
| expr = re.sub(r"<->|↔", "IFF", expr) |
| expr = re.sub(r"\band\b", "AND", expr, flags=re.IGNORECASE) |
| expr = re.sub(r"\bor\b", "OR", expr, flags=re.IGNORECASE) |
| expr = re.sub(r"\bnot\b|¬", "NOT ", expr, flags=re.IGNORECASE) |
|
|
| |
| for var, val in sorted(assignments.items(), key=lambda x: -len(x[0])): |
| expr = re.sub(r"\b" + re.escape(var) + r"\b", str(val), expr) |
|
|
| try: |
| expr_py = ( |
| expr |
| .replace("AND", " and ") |
| .replace("OR", " or ") |
| .replace("NOT ", " not ") |
| .replace("=>", " <= ") |
| ) |
| |
| |
| def _replace_implies(e: str) -> str: |
| parts = re.split(r"\s*<=\s*", e) |
| if len(parts) == 2: |
| return f"(not ({parts[0].strip()}) or ({parts[1].strip()}))" |
| return e |
|
|
| expr_py2 = _replace_implies(expr_py) |
| result = eval(expr_py2, {"__builtins__": {}}, {"True": True, "False": False}) |
| return bool(result) |
| except Exception: |
| return None |
|
|
|
|
| def _generate_truth_table(variables: list[str], formula: str) -> list[dict]: |
| """Generate truth table rows for a propositional formula.""" |
| n = len(variables) |
| rows = [] |
| for i in range(2 ** n): |
| assignment = {} |
| for j, var in enumerate(variables): |
| assignment[var] = bool((i >> (n - 1 - j)) & 1) |
| result = _eval_prop(formula, assignment) |
| rows.append({**assignment, "result": result}) |
| return rows |
|
|
|
|
| |
| |
| |
|
|
| def _modus_ponens(premise1: str, premise2: str) -> Optional[InferenceResult]: |
| """ |
| Modus Ponens: If P → Q and P, then Q. |
| premise1 should be the conditional; premise2 should be P. |
| """ |
| cond = _extract_if_then(premise1) |
| if not cond: |
| cond = _extract_if_then(premise2) |
| if cond: |
| premise1, premise2 = premise2, premise1 |
|
|
| if not cond: |
| return None |
|
|
| antecedent, consequent = cond |
| p2_norm = _normalise(premise2) |
|
|
| if _fuzzy_match(p2_norm, antecedent) or antecedent in p2_norm: |
| return InferenceResult( |
| conclusion=consequent.capitalize(), |
| valid=True, |
| rule_applied="Modus Ponens (P → Q, P ⊢ Q)", |
| proof_steps=[ |
| f"Premise 1: {premise1.strip()}", |
| f"Premise 2: {premise2.strip()}", |
| f"Premise 1 is a conditional: if '{antecedent}' then '{consequent}'", |
| f"Premise 2 affirms the antecedent: '{antecedent}'", |
| f"By Modus Ponens, the consequent follows: '{consequent}'", |
| ], |
| confidence="HIGH", |
| ) |
| return None |
|
|
|
|
| def _modus_tollens(premise1: str, premise2: str) -> Optional[InferenceResult]: |
| """ |
| Modus Tollens: If P → Q and ¬Q, then ¬P. |
| """ |
| cond = _extract_if_then(premise1) |
| if not cond: |
| cond = _extract_if_then(premise2) |
| if cond: |
| premise1, premise2 = premise2, premise1 |
|
|
| if not cond: |
| return None |
|
|
| antecedent, consequent = cond |
| neg_q = _extract_negation(premise2) |
| if neg_q and (_fuzzy_match(neg_q, consequent) or consequent in neg_q): |
| |
| neg_ant = _negate_clause(antecedent) |
| return InferenceResult( |
| conclusion=neg_ant.capitalize(), |
| valid=True, |
| rule_applied="Modus Tollens (P → Q, ¬Q ⊢ ¬P)", |
| proof_steps=[ |
| f"Premise 1: {premise1.strip()}", |
| f"Premise 2: {premise2.strip()}", |
| f"Premise 1 is a conditional: if '{antecedent}' then '{consequent}'", |
| f"Premise 2 denies the consequent: 'not {consequent}'", |
| f"By Modus Tollens, the antecedent is denied: '{neg_ant}'", |
| ], |
| confidence="HIGH", |
| ) |
| return None |
|
|
|
|
| def _hypothetical_syllogism(p1: str, p2: str) -> Optional[InferenceResult]: |
| """ |
| Hypothetical Syllogism: If P → Q and Q → R, then P → R. |
| """ |
| cond1 = _extract_if_then(p1) |
| cond2 = _extract_if_then(p2) |
| if not cond1 or not cond2: |
| return None |
|
|
| ant1, cons1 = cond1 |
| ant2, cons2 = cond2 |
|
|
| if _normalise(cons1) == _normalise(ant2) or cons1 in ant2: |
| return InferenceResult( |
| conclusion=f"If {ant1}, then {cons2}", |
| valid=True, |
| rule_applied="Hypothetical Syllogism (P → Q, Q → R ⊢ P → R)", |
| proof_steps=[ |
| f"Premise 1: if '{ant1}' then '{cons1}'", |
| f"Premise 2: if '{ant2}' then '{cons2}'", |
| f"The consequent of Premise 1 ('{cons1}') matches the antecedent of Premise 2 ('{ant2}')", |
| f"By Hypothetical Syllogism: if '{ant1}' then '{cons2}'", |
| ], |
| confidence="HIGH", |
| ) |
| return None |
|
|
|
|
| def _categorical_syllogism(p1: str, p2: str) -> Optional[InferenceResult]: |
| """ |
| Categorical Syllogism: All A are/have B; X is A; therefore X is/has B. |
| """ |
| all_match = _extract_all_are(p1) |
| if not all_match: |
| all_match = _extract_all_are(p2) |
| if all_match: |
| p1, p2 = p2, p1 |
|
|
| if not all_match: |
| return None |
|
|
| category, prop, verb = all_match |
| p2_norm = _normalise(p2) |
|
|
| is_patterns = [ |
| rf"\bis\s+a\s+{re.escape(category)}\b", |
| rf"\bis\s+an\s+{re.escape(category)}\b", |
| rf"\bis\s+{re.escape(category)}\b", |
| rf"\bare\s+{re.escape(category)}\b", |
| rf"\b{re.escape(category)}\b", |
| ] |
| def _clean_subject(raw: str) -> str: |
| """Strip articles and anything after 'is/are/was/were'.""" |
| |
| raw = re.split(r"\s+(?:is|are|was|were|has)\b", raw)[0].strip() |
| |
| raw = re.sub(r"^(?:a|an|the)\s+", "", raw).strip(" .,;?!") |
| return raw |
|
|
| subject = None |
| for pat in is_patterns: |
| m = re.search(pat, p2_norm) |
| if m: |
| raw_subj = p2_norm[:m.start()].strip(" .,;?!") |
| subject = _clean_subject(raw_subj) |
| if subject: |
| break |
|
|
| |
| if not subject: |
| words = p2_norm.split() |
| for i, word in enumerate(words): |
| if _fuzzy_match(word, category): |
| raw = " ".join(words[:i]).strip(" .,;?!") |
| subject = _clean_subject(raw) or words[0] |
| break |
|
|
| if subject: |
| |
| if verb == "have": |
| conclusion = f"{subject.capitalize()} has {prop}" |
| elif verb in ("are", "is"): |
| conclusion = f"{subject.capitalize()} is {prop}" |
| else: |
| |
| verb_s = verb.rstrip("e") + "s" if not verb.endswith("s") else verb |
| conclusion = f"{subject.capitalize()} {verb_s} {prop}" |
| return InferenceResult( |
| conclusion=conclusion, |
| valid=True, |
| rule_applied="Categorical Syllogism (All A are B; X is A ⊢ X is B)", |
| proof_steps=[ |
| f"Major premise: All {category} {verb} {prop}", |
| f"Minor premise: {p2.strip()}", |
| f"'{subject}' is identified as a member of '{category}'", |
| f"By categorical syllogism: '{subject}' {verb} '{prop}'", |
| ], |
| confidence="HIGH", |
| ) |
| return None |
|
|
|
|
| _EMBEDDED_EITHER_RE = re.compile( |
| r"\beither\s+(.+?)\s+or\s+(.+?)[\.,;]?$" |
| ) |
| _GENERAL_OR_RE = re.compile( |
| r"(?:either\s+)?(.+?)\s+or\s+(.+?)[\.,;]?$" |
| ) |
|
|
|
|
| def _extract_disjuncts(text: str) -> Optional[tuple[str, str]]: |
| """ |
| Extract (option_a, option_b) from a disjunction sentence. |
| Prefers the embedded 'either X or Y' pattern so that surrounding |
| context ('The prompt is routed to either X or Y') doesn't pollute option_a. |
| Falls back to the first 'X or Y' found. |
| """ |
| t = text.lower().strip() |
| |
| m = _EMBEDDED_EITHER_RE.search(t) |
| if m: |
| return m.group(1).strip(), m.group(2).strip() |
| |
| m = _GENERAL_OR_RE.search(t) |
| if m: |
| return m.group(1).strip(), m.group(2).strip() |
| return None |
|
|
|
|
| def _disjunctive_syllogism(p1: str, p2: str) -> Optional[InferenceResult]: |
| """ |
| Disjunctive Syllogism: P ∨ Q and ¬P ⊢ Q. |
| """ |
| disjuncts = _extract_disjuncts(p1) |
| if not disjuncts: |
| disjuncts = _extract_disjuncts(p2) |
| if disjuncts: |
| p1, p2 = p2, p1 |
|
|
| if not disjuncts: |
| return None |
|
|
| option_a, option_b = disjuncts |
| neg = _extract_negation(p2) |
| if neg: |
| neg_n = _normalise(neg) |
| if _fuzzy_match(neg, option_a) or option_a in neg_n: |
| return InferenceResult( |
| conclusion=option_b.capitalize(), |
| valid=True, |
| rule_applied="Disjunctive Syllogism (P ∨ Q, ¬P ⊢ Q)", |
| proof_steps=[ |
| f"Disjunction: '{option_a}' OR '{option_b}'", |
| f"Negation: NOT '{option_a}'", |
| f"By Disjunctive Syllogism: '{option_b}'", |
| ], |
| confidence="HIGH", |
| ) |
| if _fuzzy_match(neg, option_b) or option_b in neg_n: |
| return InferenceResult( |
| conclusion=option_a.capitalize(), |
| valid=True, |
| rule_applied="Disjunctive Syllogism (P ∨ Q, ¬Q ⊢ P)", |
| proof_steps=[ |
| f"Disjunction: '{option_a}' OR '{option_b}'", |
| f"Negation: NOT '{option_b}'", |
| f"By Disjunctive Syllogism: '{option_a}'", |
| ], |
| confidence="HIGH", |
| ) |
| return None |
|
|
|
|
| |
| |
| |
|
|
| class InferenceEngine: |
| """ |
| Pure logical inference engine. |
| |
| Usage: |
| ie = InferenceEngine() |
| result = ie.infer(user_input) |
| if result.valid: |
| print(result.to_response()) |
| """ |
|
|
| def infer(self, user_input: str) -> InferenceResult: |
| """ |
| Attempt to infer a conclusion from the user's input. |
| Tries all known inference rules in order. |
| Returns an InferenceResult with valid=False if nothing can be derived. |
| """ |
| |
| raw_sentences = re.split(r"[.;]\s+|\n|,\s+and\s+", user_input.strip()) |
| |
| _CONCLUSION_PROMPTS = re.compile( |
| r"^(therefore|what follows|what can we conclude|" |
| r"what\s+is\s+the\s+conclusion|so\s+what|" |
| r"can\s+we\s+conclude|what\s+do\s+we\s+know)[?.,\s]*$", |
| re.IGNORECASE, |
| ) |
| premises = [ |
| s.strip() for s in raw_sentences |
| if len(s.strip()) > 3 and not _CONCLUSION_PROMPTS.match(s.strip()) |
| ] |
|
|
| if len(premises) < 2: |
| return InferenceResult( |
| conclusion="", |
| valid=False, |
| rule_applied="", |
| proof_steps=["At least two premises are required for formal inference"], |
| ) |
|
|
| |
| for i in range(len(premises)): |
| for j in range(len(premises)): |
| if i == j: |
| continue |
| p1, p2 = premises[i], premises[j] |
|
|
| result = _modus_ponens(p1, p2) |
| if result and result.valid: |
| return result |
|
|
| result = _modus_tollens(p1, p2) |
| if result and result.valid: |
| return result |
|
|
| result = _hypothetical_syllogism(p1, p2) |
| if result and result.valid: |
| return result |
|
|
| result = _categorical_syllogism(p1, p2) |
| if result and result.valid: |
| return result |
|
|
| result = _disjunctive_syllogism(p1, p2) |
| if result and result.valid: |
| return result |
|
|
| return InferenceResult( |
| conclusion="", |
| valid=False, |
| rule_applied="No matching inference rule", |
| proof_steps=[ |
| "Premises identified: " + " | ".join(f"'{p}'" for p in premises), |
| "None of the standard inference rules (Modus Ponens, Modus Tollens, " |
| "Hypothetical Syllogism, Categorical Syllogism, Disjunctive Syllogism) " |
| "could be applied to derive a certain conclusion.", |
| "The argument may require domain knowledge or be invalid.", |
| ], |
| ) |
|
|
| def check_consistency(self, statements: list[str]) -> tuple[bool, str]: |
| """ |
| Check whether a list of statements is mutually consistent. |
| Returns (is_consistent, explanation). |
| """ |
| if len(statements) < 2: |
| return True, "Only one statement — trivially consistent." |
|
|
| for i, s1 in enumerate(statements): |
| for j, s2 in enumerate(statements): |
| if i >= j: |
| continue |
| neg_s1 = _extract_negation(s1) |
| neg_s2 = _extract_negation(s2) |
|
|
| if neg_s1 and _normalise(neg_s1) == _normalise(s2): |
| return False, ( |
| f"Contradiction: '{s1}' directly negates '{s2}'" |
| ) |
| if neg_s2 and _normalise(neg_s2) == _normalise(s1): |
| return False, ( |
| f"Contradiction: '{s2}' directly negates '{s1}'" |
| ) |
|
|
| return True, "No direct contradictions detected among the statements." |
|
|
| def evaluate_proposition(self, formula: str) -> InferenceResult: |
| """ |
| Evaluate a simple propositional formula with variable assignments. |
| Example: "P AND Q where P=True, Q=False" |
| """ |
| var_m = re.search(r"where\s+(.+)$", formula, re.IGNORECASE) |
| assignments: dict[str, bool] = {} |
|
|
| if var_m: |
| formula_part = formula[: var_m.start()].strip() |
| assign_text = var_m.group(1) |
| for pair in re.split(r",\s*", assign_text): |
| pair_m = re.match(r"([A-Za-z]\w*)\s*=\s*(true|false|1|0)", pair.strip(), re.IGNORECASE) |
| if pair_m: |
| var_name = pair_m.group(1) |
| val_str = pair_m.group(2).lower() |
| assignments[var_name] = val_str in ("true", "1") |
| else: |
| formula_part = formula |
|
|
| result = _eval_prop(formula_part, assignments) |
|
|
| if result is None: |
| return InferenceResult( |
| conclusion="Cannot evaluate: formula is not in a recognisable form", |
| valid=False, |
| ) |
|
|
| vars_in_formula = re.findall(r"\b([A-Z])\b", formula_part) |
| is_tautology = None |
| if vars_in_formula and not assignments: |
| rows = _generate_truth_table(list(dict.fromkeys(vars_in_formula)), formula_part) |
| if all(r["result"] for r in rows): |
| is_tautology = True |
| elif not any(r["result"] for r in rows): |
| is_tautology = False |
|
|
| return InferenceResult( |
| conclusion=f"The expression evaluates to: {result}", |
| valid=True, |
| rule_applied="Propositional evaluation", |
| truth_value=result, |
| is_tautology=is_tautology, |
| proof_steps=[ |
| f"Formula: {formula_part}", |
| f"Assignments: {assignments if assignments else 'none given'}", |
| f"Result: {result}", |
| ], |
| ) |
|
|