| """ |
| Spec DSL: data classes for pre/postconditions, parser, and validator. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import re |
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import Optional |
|
|
|
|
| class SpecStatus(Enum): |
| PENDING = "pending" |
| GENERATED = "generated" |
| VERIFIED = "verified" |
| FAILED = "failed" |
| REFINED = "refined" |
|
|
|
|
| @dataclass |
| class Spec: |
| """ |
| Formal specification for a single C function. |
| |
| Attributes: |
| function_name: Name of the function being specified. |
| precondition: Natural language or DSL string for the precondition. |
| postcondition: Natural language or DSL string for the postcondition. |
| callee_specs: Expected specs for callees (used for compositional checking). |
| loop_invariants: Loop invariants expressed as strings. |
| status: Current status of the spec in the verification pipeline. |
| """ |
|
|
| function_name: str |
| precondition: str |
| postcondition: str |
| callee_specs: dict[str, "Spec"] = field(default_factory=dict) |
| loop_invariants: list[str] = field(default_factory=list) |
| status: SpecStatus = SpecStatus.PENDING |
| spec_disagreement: bool = False |
| |
| |
| |
| |
| pre_validity: str = "" |
| pre_protocol: str = "" |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| evidence: dict[str, list[str]] = field(default_factory=dict) |
|
|
| def to_dict(self, _seen: frozenset | None = None) -> dict: |
| |
| if _seen is None: |
| _seen = frozenset() |
| callee_dicts = {} |
| for k, v in self.callee_specs.items(): |
| vid = id(v) |
| if vid not in _seen: |
| callee_dicts[k] = v.to_dict(_seen | {vid}) |
| return { |
| "function_name": self.function_name, |
| "precondition": self.precondition, |
| "postcondition": self.postcondition, |
| "callee_specs": callee_dicts, |
| "loop_invariants": self.loop_invariants, |
| "status": self.status.value, |
| "spec_disagreement": self.spec_disagreement, |
| "pre_validity": self.pre_validity, |
| "pre_protocol": self.pre_protocol, |
| "evidence": self.evidence, |
| } |
|
|
| @classmethod |
| def from_dict(cls, d: dict) -> "Spec": |
| callee_specs = { |
| k: cls.from_dict(v) for k, v in d.get("callee_specs", {}).items() |
| } |
| return cls( |
| function_name=d["function_name"], |
| precondition=d["precondition"], |
| postcondition=d["postcondition"], |
| callee_specs=callee_specs, |
| loop_invariants=d.get("loop_invariants", []), |
| status=SpecStatus(d.get("status", SpecStatus.PENDING.value)), |
| spec_disagreement=d.get("spec_disagreement", False), |
| pre_validity=d.get("pre_validity", ""), |
| pre_protocol=d.get("pre_protocol", ""), |
| evidence=d.get("evidence", {}), |
| ) |
|
|
| def evidence_for(self, clause: str) -> list[str]: |
| """Return the evidence tags for ``clause``, or [] if untagged. |
| |
| Lookup is exact-text; callers should pass clause strings as |
| they appear in pre_validity / pre_protocol / postcondition. |
| """ |
| return self.evidence.get(clause, []) |
|
|
| def clause_trust_score(self, clause: str) -> int: |
| """Rough trust score (higher = more trusted). |
| |
| Used by feedback_loop to decide which clause to drop first when |
| a spec-derived constraint produces a spurious counterexample. |
| Scoring: |
| |
| +3 canonical_contract (hand-curated, authoritative) |
| +2 caller_site_* (independent evidence) |
| +2 header_comment (author-stated intent) |
| +1 signature_pattern (universal pattern, no LLM) |
| +0 body:* (impl-only, contamination risk) |
| -1 (no evidence tags) (unsupported guess) |
| |
| When dropping clauses, drop lowest-scored first. |
| """ |
| tags = self.evidence_for(clause) |
| if not tags: |
| return -1 |
| score = 0 |
| for tag in tags: |
| if tag == "canonical_contract": |
| score = max(score, 3) |
| elif tag.startswith("caller_site_") or tag == "header_comment": |
| score = max(score, 2) |
| elif tag == "signature_pattern": |
| score = max(score, 1) |
| return score |
|
|
| def split_precondition(self) -> tuple[str, str]: |
| """Return ``(pre_validity, pre_protocol)``. |
| |
| Uses the structured fields when both are populated (or when |
| either is non-empty — indicating the LLM/parser supplied a |
| split). Falls back to ``classify_precondition`` on the flat |
| ``precondition`` otherwise. An empty ``precondition`` yields |
| ``("", "")``. |
| """ |
| if self.pre_validity or self.pre_protocol: |
| return self.pre_validity, self.pre_protocol |
| if not self.precondition.strip(): |
| return "", "" |
| return classify_precondition(self.precondition) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| _VALIDITY_HEAD_PATTERNS = ( |
| r"\bvalid\s*\(", |
| r"\bvalid_range\s*\(", |
| r"\bvalid_string\s*\(", |
| r"\bvalid_user_pointer\s*\(", |
| r"\bin_bounds\s*\(", |
| r"\bno_overflow\s*\(", |
| r"\bowns\s*\(", |
| r"\bnull\s*\(", |
| r"\b__CPROVER_r_ok\s*\(", |
| r"\b__CPROVER_w_ok\s*\(", |
| r"\b__CPROVER_rw_ok\s*\(", |
| r"\bsizeof\s*\(", |
| ) |
|
|
| _PROTOCOL_HEAD_PATTERNS = ( |
| r"\blocked\s*\(", |
| r"\bnpid_is_attached\s*\(", |
| r"\biminor\s*\(", |
| ) |
|
|
| |
| |
| |
| |
| _PROTOCOL_NAME_HINTS = ( |
| "initialized", |
| "initialised", |
| "state", |
| "ready", |
| "ref_count", |
| "refcount", |
| "open", |
| "closed", |
| "active", |
| "attached", |
| "registered", |
| "mounted", |
| "kind", |
| ) |
|
|
| _VALIDITY_HEAD_RE = re.compile("|".join(_VALIDITY_HEAD_PATTERNS)) |
| _PROTOCOL_HEAD_RE = re.compile("|".join(_PROTOCOL_HEAD_PATTERNS)) |
|
|
|
|
| def _split_top_level_and(text: str) -> list[str]: |
| """Split *text* on top-level ``&&`` / ``AND`` / ``and`` connectors, |
| respecting parens and brackets so we don't tear apart sub-expressions |
| like ``f(a, b && c)``. |
| """ |
| parts: list[str] = [] |
| depth = 0 |
| i = 0 |
| last = 0 |
| n = len(text) |
| while i < n: |
| ch = text[i] |
| if ch in "([{": |
| depth += 1 |
| i += 1 |
| continue |
| if ch in ")]}": |
| depth -= 1 |
| i += 1 |
| continue |
| if depth == 0: |
| |
| if ch == "&" and i + 1 < n and text[i + 1] == "&": |
| parts.append(text[last:i].strip()) |
| i += 2 |
| last = i |
| continue |
| |
| if ch in (" ", "\t", "\n"): |
| rest = text[i:i + 5].lower() |
| if rest.startswith(" and "): |
| parts.append(text[last:i].strip()) |
| i += 5 |
| last = i |
| continue |
| i += 1 |
| tail = text[last:].strip() |
| if tail: |
| parts.append(tail) |
| |
| cleaned: list[str] = [] |
| for p in parts: |
| p = p.strip().rstrip(",").strip() |
| while p.startswith("(") and p.endswith(")"): |
| inner = p[1:-1] |
| depth = 0 |
| balanced = True |
| for ch in inner: |
| if ch == "(": |
| depth += 1 |
| elif ch == ")": |
| depth -= 1 |
| if depth < 0: |
| balanced = False |
| break |
| if balanced and depth == 0: |
| p = inner.strip() |
| else: |
| break |
| if p: |
| cleaned.append(p) |
| return cleaned |
|
|
|
|
| def _classify_clause(clause: str) -> str: |
| """Return ``"validity"`` or ``"protocol"`` for a single clause. |
| |
| Conservative default: ``"validity"`` when unsure. Asserting a |
| spurious clause as caller obligation surfaces as a new (debuggable) |
| FP; assuming a clause that should be discharged hides bugs. |
| """ |
| c = clause.strip() |
| if not c: |
| return "validity" |
| |
| if c.startswith("!"): |
| return _classify_clause(c[1:].lstrip()) |
| |
| if _PROTOCOL_HEAD_RE.search(c): |
| return "protocol" |
| |
| if _VALIDITY_HEAD_RE.search(c): |
| return "validity" |
| |
| |
| |
| low = c.lower() |
| for hint in _PROTOCOL_NAME_HINTS: |
| |
| |
| if re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", low): |
| return "protocol" |
| |
| return "validity" |
|
|
|
|
| def drop_clauses(precondition: str, drop: list[str]) -> str: |
| """Return *precondition* with each clause in *drop* removed. |
| |
| Used by harness_generator to apply persisted |
| ``callee_relaxations`` (from the feedback loop) before |
| translating a callee's PRE into asserts/assumes. |
| |
| Matching is whitespace-insensitive on the full clause string; |
| we don't try to canonicalise C-expression syntax beyond |
| that. A drop entry that doesn't match any clause is a no-op |
| rather than an error — over time the relaxation list can |
| accumulate entries that no longer apply because the LLM |
| re-generated a structurally different spec. |
| """ |
| if not precondition or not drop: |
| return precondition |
|
|
| def _norm(s: str) -> str: |
| |
| |
| |
| |
| |
| s = s.strip() |
| while s.startswith("(") and s.endswith(")"): |
| inner = s[1:-1] |
| depth = 0 |
| balanced = True |
| for ch in inner: |
| if ch == "(": |
| depth += 1 |
| elif ch == ")": |
| depth -= 1 |
| if depth < 0: |
| balanced = False |
| break |
| if balanced and depth == 0: |
| s = inner.strip() |
| else: |
| break |
| return re.sub(r"\s+", "", s) |
|
|
| drop_norms = {_norm(c) for c in drop if c.strip()} |
| if not drop_norms: |
| return precondition |
| |
| prefix_m = re.match( |
| r"^(requires?|precondition\s*:?|pre\s*:?)\s+", |
| precondition.strip(), |
| flags=re.IGNORECASE, |
| ) |
| body = precondition.strip() |
| prefix = "" |
| if prefix_m: |
| prefix = prefix_m.group(0) |
| body = body[prefix_m.end():] |
| clauses = _split_top_level_and(body) |
| kept = [c for c in clauses if _norm(c) not in drop_norms] |
| if not kept: |
| return "" |
| return prefix + " && ".join( |
| f"({c})" if (" && " in c or " || " in c) else c for c in kept |
| ) |
|
|
|
|
| def classify_precondition(precondition: str) -> tuple[str, str]: |
| """Split *precondition* into ``(validity_text, protocol_text)``. |
| |
| Both returned strings use ``&&`` as the connector so they parse back |
| through ``precond_to_assume`` / ``precond_to_assert`` unchanged. |
| Returns ``("", "")`` for an empty / trivially-true precondition. |
| """ |
| s = (precondition or "").strip() |
| if not s or s.lower() in ("true", "1"): |
| return "", "" |
| |
| |
| s = re.sub( |
| r"^(requires?|precondition\s*:?|pre\s*:?)\s+", |
| "", |
| s, |
| flags=re.IGNORECASE, |
| ) |
| clauses = _split_top_level_and(s) |
| if not clauses: |
| return "", "" |
| validity: list[str] = [] |
| protocol: list[str] = [] |
| for c in clauses: |
| target = _classify_clause(c) |
| (validity if target == "validity" else protocol).append(c) |
| join = lambda parts: " && ".join(f"({p})" if (" && " in p or " || " in p) else p for p in parts) |
| return join(validity), join(protocol) |
|
|
|
|
| |
| |
| |
|
|
| _PRECOND_RE = re.compile( |
| r"(?:precondition|pre|requires?)[:\s]+(.+?)(?=postcondition|post|ensures?|$)", |
| re.IGNORECASE | re.DOTALL, |
| ) |
| _POSTCOND_RE = re.compile( |
| r"(?:postcondition|post|ensures?)[:\s]+(.+?)(?=precondition|pre|requires?|loop[_ ]invariant|$)", |
| re.IGNORECASE | re.DOTALL, |
| ) |
| _INVARIANT_RE = re.compile( |
| r"(?:loop[_ ]invariant)[:\s]+(.+?)(?=precondition|postcondition|loop[_ ]invariant|$)", |
| re.IGNORECASE | re.DOTALL, |
| ) |
|
|
|
|
| def parse_spec(text: str) -> Optional[Spec]: |
| """ |
| Parse LLM output into a Spec object. |
| |
| Tries JSON first; falls back to a simple regex-based heuristic parser. |
| Returns None if parsing fails. |
| """ |
| text = text.strip() |
| if not text: |
| return None |
|
|
| |
| try: |
| data = json.loads(text) |
| if isinstance(data, dict) and "function_name" in data: |
| return Spec.from_dict(data) |
| except (json.JSONDecodeError, KeyError): |
| pass |
|
|
| |
| pre_match = _PRECOND_RE.search(text) |
| post_match = _POSTCOND_RE.search(text) |
|
|
| if not pre_match or not post_match: |
| return None |
|
|
| |
| fn_name = "" |
| fn_match = re.search(r"function[:\s]+(\w+)", text, re.IGNORECASE) |
| if fn_match: |
| fn_name = fn_match.group(1) |
|
|
| invariants: list[str] = [] |
| for m in _INVARIANT_RE.finditer(text): |
| inv = m.group(1).strip() |
| if inv: |
| invariants.append(inv) |
|
|
| return Spec( |
| function_name=fn_name, |
| precondition=pre_match.group(1).strip(), |
| postcondition=post_match.group(1).strip(), |
| loop_invariants=invariants, |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def validate_spec(spec: Spec) -> bool: |
| """ |
| Basic validation: spec must have a non-empty function name, |
| non-empty precondition, and non-empty postcondition. |
| """ |
| if not spec.function_name or not spec.function_name.strip(): |
| return False |
| if not spec.precondition or not spec.precondition.strip(): |
| return False |
| if not spec.postcondition or not spec.postcondition.strip(): |
| return False |
| return True |
|
|
|
|
| |
| |
| |
|
|
|
|
| def merge_specs(specs: list[Spec]) -> Spec: |
| """ |
| Merge a list of specs (e.g. caller-expected specs for the same function). |
| |
| Strategy: |
| - Precondition: disjunction (any of the callers' preconditions is enough) |
| - Postcondition: conjunction (all callers' postconditions must hold) |
| - Loop invariants: union (deduplicated) |
| - function_name: taken from the first spec |
| - callee_specs: union (last writer wins per callee name) |
| |
| Raises ValueError if the list is empty. |
| """ |
| if not specs: |
| raise ValueError("merge_specs requires at least one Spec") |
| if len(specs) == 1: |
| return specs[0] |
|
|
| fn_name = specs[0].function_name |
| pres = [s.precondition for s in specs if s.precondition.strip()] |
| posts = [s.postcondition for s in specs if s.postcondition.strip()] |
|
|
| merged_pre = " OR ".join(f"({p})" for p in pres) if pres else "" |
| merged_post = " AND ".join(f"({p})" for p in posts) if posts else "" |
|
|
| invariants: list[str] = [] |
| seen: set[str] = set() |
| for s in specs: |
| for inv in s.loop_invariants: |
| if inv not in seen: |
| invariants.append(inv) |
| seen.add(inv) |
|
|
| merged_callee: dict[str, Spec] = {} |
| for s in specs: |
| merged_callee.update(s.callee_specs) |
|
|
| return Spec( |
| function_name=fn_name, |
| precondition=merged_pre, |
| postcondition=merged_post, |
| callee_specs=merged_callee, |
| loop_invariants=invariants, |
| status=SpecStatus.PENDING, |
| ) |
|
|