Files
llm-wiki/harness/runtime/semantic_audit.py
T

373 lines
19 KiB
Python

#!/usr/bin/env python3
"""Build semantic auditor requests and validate grounded verdict results.
This runtime deliberately does not infer assertions or verdicts. It binds the
auditor's work to deterministic surface/candidate bytes, revalidates proof
manifests, and applies the blocking policy from the ontology.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import sys
from typing import Any, Iterable, Mapping
import proof_manifest
import semantic_candidate_builder
import semantic_surface_extractor
ASSERTION_REQUEST_SCHEMA = "semantic-assertion-request/v1"
VERDICT_REQUEST_SCHEMA = "semantic-verdict-request/v1"
AUDIT_RESULT_SCHEMA = "semantic-audit-result/v1"
VALIDATED_RESULT_SCHEMA = "semantic-audit-validation-result/v1"
HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$")
class SemanticAuditError(ValueError):
"""Audit request/result bytes violate the deterministic contract."""
def canonical_json_bytes(value: Any) -> bytes:
return semantic_surface_extractor.canonical_json_bytes(value)
def _sha(value: Any) -> str:
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
def build_assertion_request(extraction: Mapping[str, Any], ontology: Mapping[str, Any]) -> dict[str, Any]:
coverage = extraction.get("coverage", {})
if extraction.get("findings") and any(item.get("severity") == "error" for item in extraction["findings"]):
raise SemanticAuditError("cannot request assertions for uncovered semantic surfaces")
if coverage.get("eligible_surface_blocks") != coverage.get("extracted_surface_blocks", 0) + coverage.get("explicitly_excluded_blocks", 0):
raise SemanticAuditError("surface coverage is incomplete")
return {
"schema_version": ASSERTION_REQUEST_SCHEMA,
"subject": extraction["path"],
"mode": extraction["mode"],
"document_sha256": extraction["document_sha256"],
"surface_manifest_sha256": _sha(extraction),
"ontology_sha256": _sha(ontology),
"predicate_ontology": list(ontology["predicates"]),
"surfaces": list(extraction["surfaces"]),
"explicitly_excluded": list(extraction["excluded"]),
"output_schema": semantic_candidate_builder.ASSERTION_SCHEMA,
}
def build_verdict_request(candidate_result: Mapping[str, Any], *, explicit_blocking: Iterable[Mapping[str, Any]] = ()) -> dict[str, Any]:
if candidate_result.get("schema_version") != semantic_candidate_builder.RESULT_SCHEMA or candidate_result.get("status") != "PASS":
raise SemanticAuditError("candidate result must be semantic-candidate-result/v1 PASS")
blocking: list[dict[str, str]] = []
for index, item in enumerate(explicit_blocking):
if not isinstance(item, Mapping) or set(item) != {"code", "message"}:
raise SemanticAuditError(f"explicit_blocking[{index}] must contain code and message")
code, message = item.get("code"), item.get("message")
if not isinstance(code, str) or not code or not isinstance(message, str) or not message:
raise SemanticAuditError(f"explicit_blocking[{index}] fields must be non-empty strings")
blocking.append({"code": code, "message": message})
return {
"schema_version": VERDICT_REQUEST_SCHEMA,
"subject": candidate_result["subject"],
"mode": candidate_result["mode"],
"document_sha256": candidate_result["document_sha256"],
"candidate_manifest_sha256": _sha(candidate_result),
"ontology_sha256": candidate_result["ontology_sha256"],
"coverage": dict(candidate_result["coverage"]),
"assertions": list(candidate_result["assertions"]),
"candidates": list(candidate_result["candidates"]),
"explicit_blocking": sorted(blocking, key=lambda item: (item["code"], item["message"])),
"output_schema": AUDIT_RESULT_SCHEMA,
}
def _resolve_reference(root: Path, reference: Mapping[str, Any], run_root: Path | None) -> tuple[Path, str]:
if set(reference) != {"namespace", "path", "sha256"}:
raise SemanticAuditError("proof_manifest must contain namespace, path, and sha256")
namespace, value, expected = reference.get("namespace"), reference.get("path"), reference.get("sha256")
if namespace not in {"repo", "run"}:
raise SemanticAuditError("proof manifest namespace must be repo or run")
base = root if namespace == "repo" else run_root
if base is None:
raise SemanticAuditError("run proof manifest requires run_root")
if not isinstance(value, str) or not value or "\\" in value or Path(value).is_absolute():
raise SemanticAuditError("proof manifest path must be relative POSIX")
path = (base / value).resolve()
try:
path.relative_to(base.resolve())
except ValueError as exc:
raise SemanticAuditError("proof manifest path escapes its namespace") from exc
if not path.is_file():
raise SemanticAuditError("proof manifest does not exist")
if not isinstance(expected, str) or not HEX_SHA256.fullmatch(expected):
raise SemanticAuditError("proof manifest sha256 is invalid")
observed = hashlib.sha256(path.read_bytes()).hexdigest()
if observed != expected:
raise SemanticAuditError("proof manifest hash mismatch")
return path, expected
def _verify_evidence(verdict: Mapping[str, Any], candidate: Mapping[str, Any], assertions: Mapping[str, Mapping[str, Any]]) -> None:
for key, assertion_key in (("evidence_a", "assertion_a"), ("evidence_b", "assertion_b")):
evidence = verdict.get(key)
assertion = assertions[candidate[assertion_key]]
if not isinstance(evidence, Mapping) or set(evidence) != {"quote", "line_start", "line_end"}:
raise SemanticAuditError(f"{key} must contain quote and exact line range")
if (
evidence.get("quote") != assertion["quote"]
or evidence.get("line_start") != assertion["line_start"]
or evidence.get("line_end") != assertion["line_end"]
):
raise SemanticAuditError(f"{key} does not match its grounded assertion")
def _verify_negative_proof(
root: Path,
run_root: Path | None,
profiles: set[str],
verdict: Mapping[str, Any],
candidate: Mapping[str, Any],
assertions: Mapping[str, Mapping[str, Any]],
) -> str:
reference = verdict.get("proof_manifest")
if not isinstance(reference, Mapping):
raise SemanticAuditError("negative semantic verdict requires proof_manifest")
path, digest = _resolve_reference(root, reference, run_root)
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
verified = proof_manifest.verify_manifest(manifest, root, profiles, run_root=run_root)
except (OSError, UnicodeError, json.JSONDecodeError, proof_manifest.ManifestValidationError) as exc:
raise SemanticAuditError(f"proof manifest revalidation failed: {exc}") from exc
if verified["verification"]["status"] != "PASS" or verified["verification"]["fail_count"] != 0:
raise SemanticAuditError("proof manifest is not PASS")
expected = {
(candidate["candidate_id"], "assertion_a", assertions[candidate["assertion_a"]]["quote"]),
(candidate["candidate_id"], "assertion_b", assertions[candidate["assertion_b"]]["quote"]),
}
observed = {
(item["finding"]["id"], item["finding"]["role"], item["source"]["quote_utf8"])
for item in verified["proofs"]
}
if not expected.issubset(observed):
raise SemanticAuditError("proof manifest does not bind both candidate assertions")
return digest
def validate_result(
root: Path,
request: Mapping[str, Any],
result: Any,
*,
ontology_path: Path = semantic_candidate_builder.DEFAULT_ONTOLOGY,
profiles_path: Path = proof_manifest.DEFAULT_PROFILES,
run_root: Path | None = None,
) -> dict[str, Any]:
root = root.resolve(strict=True)
ontology = semantic_candidate_builder.load_ontology(root, ontology_path)
request_fields = {
"schema_version", "subject", "mode", "document_sha256", "candidate_manifest_sha256",
"ontology_sha256", "coverage", "assertions", "candidates", "explicit_blocking", "output_schema",
}
if not isinstance(request, Mapping) or set(request) != request_fields or request.get("schema_version") != VERDICT_REQUEST_SCHEMA:
raise SemanticAuditError(f"expected {VERDICT_REQUEST_SCHEMA}")
subject = request.get("subject")
if not isinstance(subject, str) or not subject or Path(subject).is_absolute() or ".." in Path(subject).parts:
raise SemanticAuditError("verdict request subject must be a canonical repo-relative path")
document = Path(os.path.abspath(root / subject))
try:
document.relative_to(root)
document.resolve(strict=True).relative_to(root)
except (ValueError, FileNotFoundError) as exc:
raise SemanticAuditError("verdict request subject escapes repository") from exc
if not document.is_file():
raise SemanticAuditError("verdict request subject does not exist")
current_document_sha = hashlib.sha256(document.read_bytes()).hexdigest()
if request.get("document_sha256") != current_document_sha:
raise SemanticAuditError("verdict request is stale for current document bytes")
policy = semantic_surface_extractor.load_policy(root)
extraction = semantic_surface_extractor.extract_document(root, document, policy)
if extraction["mode"] != request.get("mode"):
raise SemanticAuditError("verdict request mode differs from current document policy")
assertion_result = {
"schema_version": semantic_candidate_builder.ASSERTION_SCHEMA,
"subject": subject,
"mode": request["mode"],
"surface_manifest_sha256": _sha(extraction),
"assertions": request.get("assertions"),
}
rebuilt = semantic_candidate_builder.build(root, extraction, assertion_result, ontology_path)
for field in ("ontology_sha256", "coverage", "assertions", "candidates"):
if request.get(field) != rebuilt[field]:
raise SemanticAuditError(f"verdict request {field} differs from deterministic reconstruction")
if request.get("candidate_manifest_sha256") != _sha(rebuilt):
raise SemanticAuditError("verdict request candidate manifest hash is stale or forged")
if request.get("output_schema") != AUDIT_RESULT_SCHEMA:
raise SemanticAuditError("verdict request output schema mismatch")
required = {"schema_version", "request_sha256", "subject", "mode", "auditor", "verdicts"}
if not isinstance(result, dict) or set(result) != required or result.get("schema_version") != AUDIT_RESULT_SCHEMA:
raise SemanticAuditError(f"audit result must contain exact {AUDIT_RESULT_SCHEMA} fields")
if result.get("request_sha256") != _sha(request):
raise SemanticAuditError("audit result is not bound to current verdict request")
if result.get("subject") != request.get("subject") or result.get("mode") != request.get("mode"):
raise SemanticAuditError("audit result subject/mode mismatch")
auditor = result.get("auditor")
if not isinstance(auditor, dict) or set(auditor) != {"contract_version", "model_id", "run_id"}:
raise SemanticAuditError("auditor must contain contract_version/model_id/run_id")
if auditor.get("contract_version") != ontology["auditor_contract_version"]:
raise SemanticAuditError("auditor contract version mismatch")
if any(not isinstance(auditor.get(key), str) or not auditor[key] for key in ("model_id", "run_id")):
raise SemanticAuditError("auditor model_id/run_id must be non-empty")
raw_verdicts = result.get("verdicts")
if not isinstance(raw_verdicts, list):
raise SemanticAuditError("verdicts must be an array")
candidates = {item["candidate_id"]: item for item in request["candidates"]}
assertions = {item["assertion_id"]: item for item in request["assertions"]}
seen: set[str] = set()
verified_findings: list[dict[str, Any]] = []
dropped: list[dict[str, Any]] = []
positive = {"CONSISTENT", "COMPLEMENTARY", "CONTEXTUAL_VARIANT"}
negative = {"AMBIGUOUS_AUTHORITY", "RESTATEMENT_DRIFT", "CONTRADICTION"}
profiles_source = profiles_path if profiles_path.is_absolute() else root / profiles_path
profiles = proof_manifest.load_allowed_profiles(profiles_source.resolve(strict=True))
for index, item in enumerate(raw_verdicts):
fields = {"candidate_id", "verdict", "rationale", "evidence_a", "evidence_b", "proof_manifest"}
if not isinstance(item, dict) or set(item) != fields:
raise SemanticAuditError(f"verdicts[{index}] has missing or unknown fields")
candidate_id = item.get("candidate_id")
verdict = item.get("verdict")
if candidate_id not in candidates or candidate_id in seen:
raise SemanticAuditError(f"verdicts[{index}] has unknown or duplicate candidate_id")
seen.add(candidate_id)
if verdict not in ontology["verdicts"]:
raise SemanticAuditError(f"verdicts[{index}] is outside exact verdict set")
if not isinstance(item.get("rationale"), str) or not item["rationale"].strip():
raise SemanticAuditError(f"verdicts[{index}].rationale must be non-empty")
candidate = candidates[candidate_id]
_verify_evidence(item, candidate, assertions)
if verdict in positive:
if item.get("proof_manifest") is not None:
raise SemanticAuditError("passing verdict must not claim a finding proof")
continue
try:
proof_sha = _verify_negative_proof(root, run_root, profiles, item, candidate, assertions)
except SemanticAuditError as exc:
dropped.append({"candidate_id": candidate_id, "verdict": verdict, "reason": str(exc)})
continue
verified_findings.append({
"candidate_id": candidate_id,
"verdict": verdict,
"rationale": item["rationale"],
"evidence_a": dict(item["evidence_a"]),
"evidence_b": dict(item["evidence_b"]),
"proof_manifest_sha256": proof_sha,
})
missing = sorted(set(candidates) - seen)
if missing:
raise SemanticAuditError(f"candidate verdict coverage is incomplete: {missing}")
mode = str(request["mode"])
explicit = list(request.get("explicit_blocking", []))
blocking = len(explicit) + sum(
item["verdict"] == "CONTRADICTION" or (mode == "hub" and item["verdict"] == "AMBIGUOUS_AUTHORITY")
for item in verified_findings
)
readiness = sum(item["verdict"] == "RESTATEMENT_DRIFT" for item in verified_findings)
# A negative judgment without replayable proof is not evidence of a
# contradiction, but it is also not a certifiable clean audit. Treat
# dropped candidates as fail-closed in every mode so a local certificate
# cannot hide an auditor-raised contradiction merely because its proof
# reference was omitted or stale.
status = "PASS" if blocking == 0 and readiness == 0 and not dropped else "FAIL"
return {
"schema_version": VALIDATED_RESULT_SCHEMA,
"status": status,
"subject": request["subject"],
"mode": mode,
"document_sha256": request["document_sha256"],
"request_sha256": _sha(request),
"ontology_sha256": request["ontology_sha256"],
"coverage": {
"eligible_surfaces": request["coverage"]["eligible_surfaces"],
"processed_surfaces": request["coverage"]["processed_surfaces"],
"candidate_pairs": len(candidates),
"processed_pairs": len(seen),
"dropped_pairs": len(dropped),
},
"findings": verified_findings,
"dropped": dropped,
"explicit_blocking": explicit,
"counts": {
"blocking": blocking,
"readiness_blocking": readiness,
"verified_findings": len(verified_findings),
"dropped_pairs": len(dropped),
},
"auditor": dict(auditor),
}
def _load(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _one_extraction(value: Any) -> Mapping[str, Any]:
if isinstance(value, dict) and {"path", "mode", "surfaces", "coverage"}.issubset(value):
return value
documents = value.get("documents") if isinstance(value, dict) else None
if not isinstance(documents, list) or len(documents) != 1 or not isinstance(documents[0], dict):
raise SemanticAuditError("assertion request input must contain exactly one extracted document")
return documents[0]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("phase", choices=("assertion-request", "verdict-request", "validate"))
parser.add_argument("input", type=Path)
parser.add_argument("result", type=Path, nargs="?")
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument("--run-root", type=Path)
parser.add_argument("--explicit-blocking", type=Path)
args = parser.parse_args(argv)
try:
root = args.root.resolve(strict=True)
if args.phase == "assertion-request":
extraction = _one_extraction(_load(args.input))
ontology = semantic_candidate_builder.load_ontology(root)
output = build_assertion_request(extraction, ontology)
elif args.phase == "verdict-request":
blocking: Iterable[Mapping[str, Any]] = ()
if args.explicit_blocking is not None:
value = _load(args.explicit_blocking)
if not isinstance(value, list):
raise SemanticAuditError("explicit blocking input must be an array")
blocking = value
output = build_verdict_request(_load(args.input), explicit_blocking=blocking)
else:
if args.result is None:
raise SemanticAuditError("validate requires request and result paths")
output = validate_result(root, _load(args.input), _load(args.result), run_root=args.run_root)
exit_code = 0 if output.get("status", "PASS") == "PASS" else 1
except (
SemanticAuditError,
semantic_candidate_builder.SemanticCandidateError,
semantic_surface_extractor.SemanticSurfaceError,
proof_manifest.ManifestValidationError,
OSError,
UnicodeError,
json.JSONDecodeError,
) as exc:
output = {"schema_version": VALIDATED_RESULT_SCHEMA, "status": "ERROR", "errors": [{"code": "SEMANTIC_AUDIT_ERROR", "message": str(exc)}]}
exit_code = 2
json.dump(output, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
sys.stdout.write("\n")
return exit_code
if __name__ == "__main__":
raise SystemExit(main())