255 lines
14 KiB
Python
255 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate auditor assertions and deterministically construct semantic candidate pairs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import defaultdict
|
|
from itertools import combinations
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
import semantic_surface_extractor
|
|
|
|
|
|
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_ONTOLOGY = Path("harness/source/semantic-ontology.json")
|
|
RESULT_SCHEMA = "semantic-candidate-result/v1"
|
|
ASSERTION_SCHEMA = "semantic-assertion-result/v1"
|
|
ONTOLOGY_SCHEMA = "semantic-ontology/v1"
|
|
STABLE_REF_RE = re.compile(r"\b(?:ART|DELEG|FLOW|[A-Z][A-Z0-9]*)(?:-[A-Z0-9]+)+-\d{3}(?:@[1-9]\d*)?\b")
|
|
SHARED_LITERAL_RE = re.compile(r"`([^`\n]+)`|(?<!\w)(--[a-z0-9][a-z0-9-]*)|(?:https?://[^\s)]+)|(?:\b[a-zA-Z0-9_.-]+/[a-zA-Z0-9_./{}-]+)")
|
|
|
|
|
|
class SemanticCandidateError(ValueError):
|
|
"""Assertions or ontology are malformed or cannot be grounded."""
|
|
|
|
|
|
def _canonical(value: Any) -> bytes:
|
|
return semantic_surface_extractor.canonical_json_bytes(value)
|
|
|
|
|
|
def load_ontology(root: Path, ontology_path: Path = DEFAULT_ONTOLOGY) -> Mapping[str, Any]:
|
|
source = ontology_path if ontology_path.is_absolute() else root / ontology_path
|
|
try:
|
|
data = json.loads(source.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
raise SemanticCandidateError(f"semantic ontology error: {exc}") from exc
|
|
required = {"schema_version", "auditor_contract_version", "predicates", "modalities", "verdicts", "blocking", "candidate_rules"}
|
|
if not isinstance(data, dict) or set(data) != required or data.get("schema_version") != ONTOLOGY_SCHEMA:
|
|
raise SemanticCandidateError(f"expected exact {ONTOLOGY_SCHEMA} schema")
|
|
expected_predicates = {
|
|
"owns", "produces", "consumes", "returns", "validates", "maps_to", "runs_before", "runs_after",
|
|
"uses", "requires", "forbids", "enforces", "delegates", "has_schema", "has_threshold",
|
|
"has_cardinality", "has_failure_behavior", "other",
|
|
}
|
|
expected_verdicts = {
|
|
"CONSISTENT", "COMPLEMENTARY", "CONTEXTUAL_VARIANT", "AMBIGUOUS_AUTHORITY",
|
|
"RESTATEMENT_DRIFT", "CONTRADICTION",
|
|
}
|
|
if set(data["predicates"]) != expected_predicates:
|
|
raise SemanticCandidateError("predicate ontology differs from the required exact set")
|
|
if set(data["verdicts"]) != expected_verdicts:
|
|
raise SemanticCandidateError("semantic verdict set differs from the required exact set")
|
|
rule_ids = [item.get("id") for item in data["candidate_rules"] if isinstance(item, dict)]
|
|
if rule_ids != [f"C{index}" for index in range(1, 8)]:
|
|
raise SemanticCandidateError("candidate rules must be exactly C1..C7 in order")
|
|
return data
|
|
|
|
|
|
def _assertion(value: Any, index: int, surfaces: Mapping[str, Mapping[str, Any]], ontology: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
|
fields = {
|
|
"assertion_id", "source_surface", "subject", "predicate", "object", "condition",
|
|
"modality", "scope", "quote", "line_start", "line_end",
|
|
}
|
|
if not isinstance(value, dict) or set(value) != fields:
|
|
raise SemanticCandidateError(f"assertions[{index}] must contain exactly {sorted(fields)}")
|
|
for field in fields - {"line_start", "line_end"}:
|
|
if not isinstance(value[field], str) or not value[field].strip():
|
|
raise SemanticCandidateError(f"assertions[{index}].{field} must be non-empty")
|
|
if value["predicate"] not in ontology["predicates"]:
|
|
raise SemanticCandidateError(f"assertions[{index}].predicate is outside ontology")
|
|
if value["modality"] not in ontology["modalities"]:
|
|
raise SemanticCandidateError(f"assertions[{index}].modality is outside ontology")
|
|
if not isinstance(value["line_start"], int) or not isinstance(value["line_end"], int) or value["line_start"] < 1 or value["line_end"] < value["line_start"]:
|
|
raise SemanticCandidateError(f"assertions[{index}] has invalid line range")
|
|
surface = surfaces.get(value["source_surface"])
|
|
if surface is None:
|
|
raise SemanticCandidateError(f"assertions[{index}] references unknown source surface")
|
|
if value["line_start"] < surface["line_start"] or value["line_end"] > surface["line_end"]:
|
|
raise SemanticCandidateError(f"assertions[{index}] quote range escapes its source surface")
|
|
document = root / str(surface["path"])
|
|
lines = document.read_text(encoding="utf-8").splitlines()
|
|
observed = "\n".join(lines[value["line_start"] - 1 : value["line_end"]])
|
|
if observed != value["quote"]:
|
|
raise SemanticCandidateError(f"assertions[{index}] exact UTF-8 quote/line verification failed")
|
|
return {field: value[field] for field in sorted(fields)}
|
|
|
|
|
|
def validate_assertions(root: Path, extraction: Mapping[str, Any], assertion_result: Any, ontology: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
if not isinstance(assertion_result, dict) or set(assertion_result) != {"schema_version", "subject", "mode", "surface_manifest_sha256", "assertions"}:
|
|
raise SemanticCandidateError("assertion result has missing or unknown fields")
|
|
if assertion_result.get("schema_version") != ASSERTION_SCHEMA:
|
|
raise SemanticCandidateError(f"expected {ASSERTION_SCHEMA}")
|
|
if assertion_result.get("subject") != extraction.get("path") or assertion_result.get("mode") != extraction.get("mode"):
|
|
raise SemanticCandidateError("assertion result subject/mode does not match extraction")
|
|
manifest_hash = hashlib.sha256(_canonical(extraction)).hexdigest()
|
|
if assertion_result.get("surface_manifest_sha256") != manifest_hash:
|
|
raise SemanticCandidateError("assertion result is not bound to current surface manifest")
|
|
raw_assertions = assertion_result.get("assertions")
|
|
if not isinstance(raw_assertions, list):
|
|
raise SemanticCandidateError("assertions must be an array")
|
|
surfaces = {item["surface_id"]: item for item in extraction.get("surfaces", [])}
|
|
assertions = [_assertion(item, index, surfaces, ontology, root) for index, item in enumerate(raw_assertions)]
|
|
identifiers = [item["assertion_id"] for item in assertions]
|
|
if len(identifiers) != len(set(identifiers)):
|
|
raise SemanticCandidateError("assertion_id values must be unique")
|
|
covered = {item["source_surface"] for item in assertions}
|
|
missing = sorted(set(surfaces) - covered)
|
|
if missing:
|
|
raise SemanticCandidateError(f"auditor silently dropped surfaces without assertions: {missing}")
|
|
return sorted(assertions, key=lambda item: item["assertion_id"])
|
|
|
|
|
|
def _norm(value: str) -> str:
|
|
return re.sub(r"\s+", " ", value.strip().casefold())
|
|
|
|
|
|
def _literals(assertion: Mapping[str, Any]) -> set[str]:
|
|
text = f"{assertion['object']} {assertion['quote']}"
|
|
result: set[str] = set()
|
|
for match in SHARED_LITERAL_RE.finditer(text):
|
|
captured = next((group for group in match.groups() if group is not None), match.group(0))
|
|
result.add(_norm(captured))
|
|
return {item for item in result if len(item) >= 3}
|
|
|
|
|
|
def _pairs(values: Iterable[Mapping[str, Any]]) -> Iterable[tuple[Mapping[str, Any], Mapping[str, Any]]]:
|
|
yield from combinations(sorted(values, key=lambda item: str(item["assertion_id"])), 2)
|
|
|
|
|
|
def build(root: Path, extraction: Mapping[str, Any], assertion_result: Any, ontology_path: Path = DEFAULT_ONTOLOGY) -> dict[str, Any]:
|
|
root = root.resolve(strict=True)
|
|
ontology = load_ontology(root, ontology_path)
|
|
assertions = validate_assertions(root, extraction, assertion_result, ontology)
|
|
reasons: dict[tuple[str, str], set[str]] = defaultdict(set)
|
|
by_base: dict[tuple[str, str, str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
for item in assertions:
|
|
by_base[(_norm(item["subject"]), item["predicate"], _norm(item["condition"]), _norm(item["scope"]))].append(item)
|
|
for values in by_base.values():
|
|
for left, right in _pairs(values):
|
|
reasons[(left["assertion_id"], right["assertion_id"])].add("BASE")
|
|
|
|
for left, right in _pairs(assertions):
|
|
predicates = {left["predicate"], right["predicate"]}
|
|
same_subject = _norm(left["subject"]) == _norm(right["subject"])
|
|
same_object = _norm(left["object"]) == _norm(right["object"])
|
|
same_context = _norm(left["condition"]) == _norm(right["condition"]) and _norm(left["scope"]) == _norm(right["scope"])
|
|
pair = (left["assertion_id"], right["assertion_id"])
|
|
if same_object and predicates <= {"produces", "consumes"} and predicates == {"produces", "consumes"}:
|
|
reasons[pair].add("C1")
|
|
if same_subject and "stage" in _norm(left["subject"]) and predicates <= {"owns", "consumes", "produces", "returns", "runs_before", "runs_after"}:
|
|
reasons[pair].add("C2")
|
|
if same_subject and ("gate" in _norm(left["subject"]) or "contract" in _norm(left["subject"])) and predicates <= {"requires", "enforces", "uses", "forbids"}:
|
|
reasons[pair].add("C3")
|
|
if same_subject and same_object and same_context and {left["modality"], right["modality"]} == {"must", "must_not"}:
|
|
reasons[pair].add("C4")
|
|
if same_subject and predicates <= {"returns", "validates", "maps_to"}:
|
|
reasons[pair].add("C5")
|
|
if _literals(left).intersection(_literals(right)):
|
|
reasons[pair].add("C6")
|
|
left_refs = set(STABLE_REF_RE.findall(f"{left['object']} {left['quote']}"))
|
|
right_refs = set(STABLE_REF_RE.findall(f"{right['object']} {right['quote']}"))
|
|
if left_refs.intersection(right_refs):
|
|
reasons[pair].add("C7")
|
|
|
|
by_id = {item["assertion_id"]: item for item in assertions}
|
|
candidates: list[dict[str, Any]] = []
|
|
for pair, rule_ids in sorted(reasons.items()):
|
|
left, right = (by_id[pair[0]], by_id[pair[1]])
|
|
digest = hashlib.sha256((pair[0] + "\0" + pair[1] + "\0" + ",".join(sorted(rule_ids))).encode("utf-8")).hexdigest()
|
|
candidates.append({
|
|
"candidate_id": "SEM-" + digest[:20].upper(),
|
|
"assertion_a": pair[0],
|
|
"assertion_b": pair[1],
|
|
"rule_ids": sorted(rule_ids, key=lambda item: (item != "BASE", item)),
|
|
"grouping_key": {
|
|
"subject": left["subject"] if _norm(left["subject"]) == _norm(right["subject"]) else "",
|
|
"predicate": left["predicate"] if left["predicate"] == right["predicate"] else "",
|
|
"condition": left["condition"] if _norm(left["condition"]) == _norm(right["condition"]) else "",
|
|
"scope": left["scope"] if _norm(left["scope"]) == _norm(right["scope"]) else "",
|
|
},
|
|
})
|
|
return {
|
|
"schema_version": RESULT_SCHEMA,
|
|
"status": "PASS",
|
|
"subject": extraction["path"],
|
|
"mode": extraction["mode"],
|
|
"document_sha256": extraction["document_sha256"],
|
|
"surface_manifest_sha256": hashlib.sha256(_canonical(extraction)).hexdigest(),
|
|
"ontology_sha256": hashlib.sha256(_canonical(ontology)).hexdigest(),
|
|
"assertions_sha256": hashlib.sha256(_canonical(assertions)).hexdigest(),
|
|
"coverage": {
|
|
"eligible_surfaces": extraction["coverage"]["eligible_surface_blocks"],
|
|
"processed_surfaces": len({item["source_surface"] for item in assertions}) + extraction["coverage"]["explicitly_excluded_blocks"],
|
|
"assertions": len(assertions),
|
|
"candidate_pairs": len(candidates),
|
|
},
|
|
"assertions": assertions,
|
|
"candidates": candidates,
|
|
"findings": [],
|
|
}
|
|
|
|
|
|
def structural_check(root: Path, ontology_path: Path = DEFAULT_ONTOLOGY) -> dict[str, Any]:
|
|
root = root.resolve(strict=True)
|
|
ontology = load_ontology(root, ontology_path)
|
|
surfaces = semantic_surface_extractor.check(root)
|
|
return {
|
|
"schema_version": RESULT_SCHEMA,
|
|
"status": surfaces["status"],
|
|
"check_kind": "STRUCTURE_ONLY",
|
|
"semantic_verdict": "NOT_EVALUATED",
|
|
"ontology_sha256": hashlib.sha256(_canonical(ontology)).hexdigest(),
|
|
"surface_document_count": surfaces["document_count"],
|
|
"coverage": surfaces["coverage"],
|
|
"findings": surfaces["findings"],
|
|
}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
|
parser.add_argument("--ontology", type=Path, default=DEFAULT_ONTOLOGY)
|
|
parser.add_argument("--document", type=Path)
|
|
parser.add_argument("--assertions", type=Path)
|
|
parser.add_argument("--check", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
root = args.root.resolve(strict=True)
|
|
if args.document is None and args.assertions is None:
|
|
result = structural_check(root, args.ontology)
|
|
elif args.document is not None and args.assertions is not None:
|
|
policy = semantic_surface_extractor.load_policy(root)
|
|
document = args.document if args.document.is_absolute() else root / args.document
|
|
extraction = semantic_surface_extractor.extract_document(root, document, policy)
|
|
assertion_result = json.loads(args.assertions.read_text(encoding="utf-8"))
|
|
result = build(root, extraction, assertion_result, args.ontology)
|
|
else:
|
|
raise SemanticCandidateError("--document and --assertions must be supplied together")
|
|
exit_code = 0 if result["status"] == "PASS" else 1
|
|
except (SemanticCandidateError, semantic_surface_extractor.SemanticSurfaceError, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
result = {"schema_version": RESULT_SCHEMA, "status": "ERROR", "errors": [{"code": "SEMANTIC_CANDIDATE_ERROR", "message": str(exc)}]}
|
|
exit_code = 2
|
|
json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
|
sys.stdout.write("\n")
|
|
return exit_code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|