Spaces:
Running on Zero
Running on Zero
File size: 14,025 Bytes
c8fbdf1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | #!/usr/bin/env python3
"""Grounding — the verifying half of the mind.
Codette already CREATES thoughts (cocoon_synthesizer forges cross-domain
patterns; perspective_web spawns new nodes). This module VERIFIES them: it takes
a claim and returns a verdict grounded in a real solver, not the LLM's own
assertion. Intuition proposes; rigor disposes.
Design invariants (these are the point, not decoration):
1. NEVER asserts truth it did not check. A claim it cannot formalize returns
UNVERIFIABLE — never VERIFIED-by-default. Same omit-never-fabricate rule as
LiveCognitionState.
2. Pure and side-effect-free. verify() computes; it does not log, mutate, or
gate anything. Shadow logging is a separate opt-in wrapper (log_shadow()).
3. Degrades honestly. With sympy absent, every arithmetic claim is UNVERIFIABLE
(not silently VERIFIED). A missing solver removes capability, never honesty.
Phase A1 backs arithmetic and algebraic (in)equalities with sympy. Logical
claims (z3) are Phase C3. See docs/NEUROSYMBOLIC_GROUNDING.md.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field, asdict
from enum import Enum
from pathlib import Path
from typing import List, Optional
try:
import sympy
from sympy import Eq, simplify, sympify
from sympy.core.sympify import SympifyError
_HAS_SYMPY = True
except Exception: # pragma: no cover - environment without sympy
_HAS_SYMPY = False
try:
import z3
_HAS_Z3 = True
except Exception: # pragma: no cover - environment without z3
_HAS_Z3 = False
class Verdict(str, Enum):
VERIFIED = "verified" # formalized and confirmed true
REFUTED = "refuted" # formalized and confirmed false
UNVERIFIABLE = "unverifiable" # could not be formalized — NOT a truth claim
# Comparators we can formalize, longest-token-first so ">=" wins over ">".
_COMPARATORS = [
("==", "eq"), ("!=", "ne"),
(">=", "ge"), ("<=", "le"),
("=", "eq"), (">", "gt"), ("<", "lt"),
]
@dataclass
class GroundingResult:
"""The outcome of grounding one claim. Carries WHY, always."""
claim: str
verdict: Verdict
detail: str # human-readable reason
method: str = "none" # which backend produced the verdict
normalized: Optional[str] = None # the formalized form, when we got one
ts: float = field(default_factory=time.time)
def to_dict(self) -> dict:
d = asdict(self)
d["verdict"] = self.verdict.value
return d
def _split_comparison(claim: str) -> Optional[tuple]:
"""Split a claim into (lhs, op, rhs) on the first top-level comparator.
Returns None if no comparator is present. Skips '==' inside '===' etc. by
working on the normalized single form.
"""
text = claim.strip()
# Normalize a lone '=' that is not part of ==, >=, <=, != by handling via the
# ordered comparator scan below (== is matched before =).
for token, name in _COMPARATORS:
idx = text.find(token)
# require the comparator to have content on both sides
if idx > 0 and idx + len(token) < len(text):
lhs = text[:idx].strip()
rhs = text[idx + len(token):].strip()
# guard against matching the '=' inside '>=', '<=', '!=', '=='
if token == "=" and (text[idx - 1] in "<>=!" or text[idx + 1:idx + 2] == "="):
continue
if lhs and rhs:
return lhs, name, rhs
return None
def verify(claim: str) -> GroundingResult:
"""Verify a single claim. Pure: no logging, no side effects.
Only arithmetic/algebraic (in)equalities are formalizable in Phase A1.
Anything else returns UNVERIFIABLE with a reason — never a default VERIFIED.
"""
claim = (claim or "").strip()
if not claim:
return GroundingResult(claim, Verdict.UNVERIFIABLE, "empty claim")
parsed = _split_comparison(claim)
if parsed is None:
return GroundingResult(
claim, Verdict.UNVERIFIABLE,
"no comparator found — not an (in)equality this backend can check",
)
if not _HAS_SYMPY:
return GroundingResult(
claim, Verdict.UNVERIFIABLE,
"sympy not available — arithmetic grounding disabled (honest UNVERIFIABLE, not assumed true)",
)
lhs_s, op, rhs_s = parsed
try:
lhs = sympify(lhs_s)
rhs = sympify(rhs_s)
except (SympifyError, SyntaxError, TypeError, ValueError) as e:
return GroundingResult(
claim, Verdict.UNVERIFIABLE,
f"could not parse into symbolic form: {e}",
)
normalized = f"{lhs} {op} {rhs}"
try:
if op == "eq":
truth = simplify(lhs - rhs) == 0
elif op == "ne":
truth = simplify(lhs - rhs) != 0
else:
diff = simplify(lhs - rhs)
# Concrete number: decide directly. Non-constant: hand to z3, which can
# decide UNIVERSAL validity ("x**2 >= 0" is always true) — something the
# sympy path cannot. If z3 is absent or the claim is merely contingent
# ("x > 0"), we return UNVERIFIABLE, never a guess.
if not diff.is_number:
z3_verdict = _z3_ordering(lhs, rhs, op)
if z3_verdict is not None:
return GroundingResult(
claim, z3_verdict,
f"z3 decided '{normalized}' over the reals: {z3_verdict.value}",
method="z3", normalized=normalized,
)
return GroundingResult(
claim, Verdict.UNVERIFIABLE,
f"ordering of '{diff}' is contingent (depends on variable values) "
f"or not decidable by the available backend",
method="sympy", normalized=normalized,
)
val = float(diff)
truth = {
"gt": val > 0, "lt": val < 0,
"ge": val >= 0, "le": val <= 0,
}[op]
except (TypeError, ValueError) as e:
return GroundingResult(
claim, Verdict.UNVERIFIABLE,
f"symbolic evaluation could not decide: {e}",
method="sympy", normalized=normalized,
)
verdict = Verdict.VERIFIED if truth else Verdict.REFUTED
return GroundingResult(
claim, verdict,
f"sympy evaluated '{normalized}' as {truth}",
method="sympy", normalized=normalized,
)
# ── z3 layer: universal validity + cross-claim contradiction detection ──────
def _sympy_to_z3(expr, smap):
"""Translate a sympy arithmetic expression into a z3 real expression.
Handles the subset our claims produce (numbers, symbols, +, *, integer
powers, division). Raises ValueError on anything outside it — the caller
treats that as 'cannot formalize' (honest UNVERIFIABLE), never as a guess.
"""
if expr.is_number:
return z3.RealVal(str(sympy.nsimplify(expr))) if expr.is_rational else z3.RealVal(float(expr))
if expr.is_Symbol:
name = str(expr)
if name not in smap:
smap[name] = z3.Real(name)
return smap[name]
if expr.is_Add:
terms = [_sympy_to_z3(a, smap) for a in expr.args]
out = terms[0]
for t in terms[1:]:
out = out + t
return out
if expr.is_Mul:
out = None
for a in expr.args:
t = _sympy_to_z3(a, smap)
out = t if out is None else out * t
return out
if expr.is_Pow:
base, exp = expr.args
if exp.is_Integer:
e = int(exp)
b = _sympy_to_z3(base, smap)
if e == 0:
return z3.RealVal(1)
if e > 0:
out = b
for _ in range(e - 1):
out = out * b
return out
out = b
for _ in range(-e - 1):
out = out * b
return z3.RealVal(1) / out
raise ValueError(f"non-integer power {exp}")
raise ValueError(f"untranslatable expression {expr}")
def _z3_rel(lhs_sym, rhs_sym, op, smap):
"""Build a z3 relational constraint from sympy lhs/rhs and an op name."""
L = _sympy_to_z3(lhs_sym, smap)
R = _sympy_to_z3(rhs_sym, smap)
return {
"eq": L == R, "ne": L != R,
"gt": L > R, "lt": L < R, "ge": L >= R, "le": L <= R,
}[op]
def _z3_ordering(lhs_sym, rhs_sym, op):
"""Decide a single relational claim over the reals with z3.
Returns VERIFIED (universally valid), REFUTED (universally false / unsat),
or None when the claim is contingent, z3 is unavailable, or it cannot be
translated. None => caller reports UNVERIFIABLE. Never guesses.
"""
if not _HAS_Z3:
return None
try:
smap = {}
rel = _z3_rel(lhs_sym, rhs_sym, op, smap)
s = z3.Solver()
s.add(z3.Not(rel))
if s.check() == z3.unsat: # negation impossible => always true
return Verdict.VERIFIED
s2 = z3.Solver()
s2.add(rel)
if s2.check() == z3.unsat: # claim impossible => always false
return Verdict.REFUTED
return None # contingent — honestly undecided
except Exception:
return None
def verify_consistency(claims: List[str]) -> GroundingResult:
"""Check whether a SET of relational claims is jointly satisfiable (z3).
This catches contradictions no single-claim check can see: a forged thought
asserting a circular ordering (a > b, b > c, c > a) has three individually
fine claims but is jointly impossible.
VERIFIED = jointly consistent (satisfiable)
REFUTED = contradictory (unsatisfiable) — FLAG the thought
UNVERIFIABLE = fewer than 2 formalizable claims, z3 missing, or untranslatable
"""
formal = [c for c in claims if _split_comparison(c) is not None]
joined = " ; ".join(formal)
if not _HAS_Z3 or not _HAS_SYMPY:
return GroundingResult(joined, Verdict.UNVERIFIABLE, "z3/sympy unavailable")
if len(formal) < 2:
return GroundingResult(joined, Verdict.UNVERIFIABLE, "need >=2 formalizable claims to check consistency")
try:
smap = {}
constraints = []
for c in formal:
lhs_s, op, rhs_s = _split_comparison(c)
constraints.append(_z3_rel(sympify(lhs_s), sympify(rhs_s), op, smap))
s = z3.Solver()
s.add(*constraints)
result = s.check()
except Exception as e:
return GroundingResult(joined, Verdict.UNVERIFIABLE, f"could not formalize claim set: {e}")
if result == z3.unsat:
return GroundingResult(joined, Verdict.REFUTED, f"z3: the {len(formal)} claims are jointly CONTRADICTORY", method="z3")
if result == z3.sat:
return GroundingResult(joined, Verdict.VERIFIED, f"z3: the {len(formal)} claims are jointly consistent", method="z3")
return GroundingResult(joined, Verdict.UNVERIFIABLE, "z3 returned unknown")
# Conservative claim extraction. An "atom" is a number, a lone (word-bounded)
# variable letter, or an operator/paren — so English words never match: "holds"
# has no word-bounded single letter, but "x" does. An operand is a run of atoms.
# This grabs "2 + 2 = 4" out of "note that 2 + 2 = 4 holds" without swallowing the
# prose. Everything not matched is simply not extracted — never a fabricated claim.
_ATOM = r"(?:\d+\.?\d*|\b[A-Za-z]\b|[-+*/^()])"
_OPERAND = rf"{_ATOM}(?:\s*{_ATOM})*"
_COMPARATOR_ALT = r"==|!=|>=|<=|=|>|<"
_CLAIM_RE = re.compile(rf"{_OPERAND}\s*(?:{_COMPARATOR_ALT})\s*{_OPERAND}")
def extract_claims(text: str) -> List[str]:
"""Pull candidate checkable claims (arithmetic (in)equalities) from text.
Deliberately conservative: it under-extracts rather than over-extracts. A
claim it does not recognize is left alone (and will simply not be grounded),
never coerced into a false positive.
"""
if not text:
return []
out: List[str] = []
for m in _CLAIM_RE.finditer(text):
span = m.group(0).strip(" .,:;\n\t")
if not span:
continue
# Keep a span if it's an arithmetic claim (has a digit) OR a comparison
# (uses an ordering/equality operator, not a bare '='). This admits
# variable orderings like "a > b" — needed for contradiction detection —
# while still leaving prose assignments like "set x = y" out of scope.
has_digit = any(ch.isdigit() for ch in span)
has_comparison = any(op in span for op in ("==", "!=", ">=", "<=", ">", "<"))
if has_digit or has_comparison:
out.append(span)
return out
def log_shadow(result: GroundingResult, path: str | Path = None) -> None:
"""Append one verdict to the shadow log. SHADOW ONLY — applied is always false.
Separate from verify() on purpose: verification is pure; persistence is a
deliberate, opt-in act. Nothing in the runtime calls this until Phase B, and
even then it only observes. See docs/NEUROSYMBOLIC_GROUNDING.md Phase D.
"""
path = Path(path) if path else Path(__file__).resolve().parent.parent / "data" / "grounding_shadow.jsonl"
rec = result.to_dict()
rec["mode"] = "shadow"
rec["applied"] = False
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass # logging must never break a turn
|