"""ADR-0117 — `SolutionTrace` verifier. Re-applies every step of a :class:`SolutionTrace` from the input graph's initial state and asserts byte-equal reproduction of ``answer_value``. Hardens ADR-0114a Obligation #3 (every correct answer ships with a replay-equal trace) at verifier fidelity. The verifier is **independent of the solver**. The solver could be buggy, malicious, or tampered with after the fact; the verifier re-derives the answer using only: - the input :class:`MathProblemGraph` - the operation semantics documented in ADR-0116 (add / subtract / transfer / multiply / divide) - the per-step ``actor`` / ``operand`` / ``target`` declared in each :class:`SolutionStep` It then cross-checks against the values the trace claims: - ``graph_canonical_hash`` matches a fresh hash of the graph - per-step ``before_value`` / ``after_value`` match the verifier's fresh computation - ``answer_value`` matches the verifier's resolved unknown - every step's ``pack_lemma_id`` resolves to a real lexicon entry in the loaded pack (ADR-0114a Obligation #10 re-checked at verify time) Any mismatch raises :class:`VerificationError` with the offending step index and a typed reason. Same input always produces the same verdict (determinism). """ from __future__ import annotations import hashlib import json from dataclasses import dataclass from typing import Any from generate.math_problem_graph import ( Comparison, MathProblemGraph, PartitionChunk, Quantity, Rate, Unknown, ) from generate.math_solver import ( REQUIRED_PACK_ID, SolutionStep, SolutionTrace, SolveError, _resolve_pack_lemmas, ) class VerificationError(ValueError): """Raised when a trace fails to verify against its graph.""" @dataclass(frozen=True, slots=True) class VerifierVerdict: """Typed outcome of a verification pass. ``passed`` is ``True`` only if every check held. ``reason`` is empty on pass and names the first failed check on fail. ``checks`` records every check the verifier ran (in order) along with the pass/fail status of each, so external readers can audit which invariants held. """ passed: bool reason: str checks: tuple[tuple[str, bool, str], ...] # (name, passed, detail) graph_canonical_hash: str trace_answer_value: float verifier_answer_value: float def as_json(self) -> dict[str, Any]: return { "passed": self.passed, "reason": self.reason, "checks": [ {"name": n, "passed": p, "detail": d} for n, p, d in self.checks ], "graph_canonical_hash": self.graph_canonical_hash, "trace_answer_value": self.trace_answer_value, "verifier_answer_value": self.verifier_answer_value, } def canonical_bytes(self) -> bytes: return json.dumps( self.as_json(), sort_keys=True, separators=(",", ":") ).encode("utf-8") def verify(graph: MathProblemGraph, trace: SolutionTrace) -> VerifierVerdict: """Run all verifier checks against ``trace`` for ``graph``. Pure function: same (graph, trace) -> byte-equal verdict. """ checks: list[tuple[str, bool, str]] = [] fresh_hash = hashlib.sha256(graph.canonical_bytes()).hexdigest() # Check 1 — graph hash matches hash_ok = trace.graph_canonical_hash == fresh_hash checks.append( ( "graph_canonical_hash_matches", hash_ok, ( "" if hash_ok else f"trace declares {trace.graph_canonical_hash!r} but graph hashes to {fresh_hash!r}" ), ) ) # Check 2 — pack id matches pack_ok = trace.pack_id == REQUIRED_PACK_ID checks.append( ( "pack_id_matches", pack_ok, ( "" if pack_ok else f"trace declares pack {trace.pack_id!r}, expected {REQUIRED_PACK_ID!r}" ), ) ) # Check 3 — pack lemma ids resolve try: pack_bindings = _resolve_pack_lemmas() lemmas_ok = True lemma_detail = "" except SolveError as exc: pack_bindings = {} lemmas_ok = False lemma_detail = f"pack resolution failed: {exc}" checks.append(("pack_lemmas_resolve", lemmas_ok, lemma_detail)) # Check 4 — every step's pack_lemma_id matches the resolved binding if lemmas_ok: step_binding_ok = True step_binding_detail = "" for step in trace.steps: expected = pack_bindings.get(step.operation_kind) if expected is None: step_binding_ok = False step_binding_detail = ( f"step {step.step_index} declares unknown operation kind " f"{step.operation_kind!r}" ) break if step.pack_lemma_id != expected: step_binding_ok = False step_binding_detail = ( f"step {step.step_index} declares pack_lemma_id " f"{step.pack_lemma_id!r}, expected {expected!r}" ) break checks.append( ( "step_pack_lemma_ids_match_bindings", step_binding_ok, step_binding_detail, ) ) else: checks.append( ( "step_pack_lemma_ids_match_bindings", False, "skipped: pack resolution failed", ) ) # Check 5 — replay every step from the graph's initial state state: dict[tuple[str, str], float] = {} for p in graph.initial_state: state[(p.entity, p.quantity.unit)] = float(p.quantity.value) replay_ok = True replay_detail = "" last_count_unit: dict[str, str] = {} for step in trace.steps: try: _verify_step(step, state, last_count_unit=last_count_unit) except VerificationError as exc: replay_ok = False replay_detail = str(exc) break checks.append(("step_replay_matches_before_after", replay_ok, replay_detail)) # Check 6 — verifier's resolved answer matches trace's answer verifier_answer = _resolve_answer( Unknown(entity=trace.answer_entity, unit=trace.answer_unit), state ) answer_ok = ( replay_ok and verifier_answer is not None and verifier_answer == trace.answer_value ) checks.append( ( "answer_value_reproduces", answer_ok, ( "" if answer_ok else ( f"verifier resolved {verifier_answer!r}, trace declared " f"{trace.answer_value!r}" ) ), ) ) all_passed = all(p for _, p, _ in checks) reason = "" if not all_passed: for name, p, detail in checks: if not p: reason = f"{name}: {detail}" if detail else name break return VerifierVerdict( passed=all_passed, reason=reason, checks=tuple(checks), graph_canonical_hash=fresh_hash, trace_answer_value=trace.answer_value, verifier_answer_value=( verifier_answer if verifier_answer is not None else float("nan") ), ) def _verify_step( step: SolutionStep, state: dict[tuple[str, str], float], *, last_count_unit: dict[str, str], ) -> None: # Kind-discriminated early returns for non-Quantity operands: # apply_rate (ADR-0122) uses Rate; compare_* (ADR-0123) uses Comparison. if step.operation_kind == "apply_rate": _verify_apply_rate_step(step, state) return if step.operation_kind == "compare_additive": _verify_compare_additive_step(step, state) return if step.operation_kind == "compare_multiplicative": _verify_compare_multiplicative_step(step, state) return if step.operation_kind == "unit_partition": _verify_unit_partition_step(step, state, last_count_unit=last_count_unit) return if step.operation_kind == "fraction_portion": _verify_fraction_portion_step(step, state, last_count_unit=last_count_unit) return if not isinstance(step.operand, Quantity): raise VerificationError( f"step {step.step_index} kind={step.operation_kind!r} " f"requires Quantity operand; got {type(step.operand).__name__}" ) key = (step.actor, step.operand.unit) fresh_before = state.get(key, 0.0) if fresh_before != step.before_value: raise VerificationError( f"step {step.step_index} declares before_value={step.before_value}, " f"verifier computed {fresh_before}" ) v = float(step.operand.value) if step.operation_kind == "add": fresh_after = fresh_before + v state[key] = fresh_after elif step.operation_kind == "subtract": fresh_after = fresh_before - v state[key] = fresh_after elif step.operation_kind == "transfer": if step.target is None: raise VerificationError( f"step {step.step_index} kind=transfer has no target" ) fresh_after = fresh_before - v state[key] = fresh_after tgt_key = (step.target, step.operand.unit) fresh_target_before = state.get(tgt_key, 0.0) if ( step.target_before is None or fresh_target_before != step.target_before ): raise VerificationError( f"step {step.step_index} declares target_before=" f"{step.target_before}, verifier computed {fresh_target_before}" ) fresh_target_after = fresh_target_before + v state[tgt_key] = fresh_target_after if ( step.target_after is None or fresh_target_after != step.target_after ): raise VerificationError( f"step {step.step_index} declares target_after=" f"{step.target_after}, verifier computed {fresh_target_after}" ) elif step.operation_kind == "multiply": fresh_after = fresh_before * v state[key] = fresh_after elif step.operation_kind == "divide": if v == 0: raise VerificationError( f"step {step.step_index} divides by zero" ) fresh_after = fresh_before / v state[key] = fresh_after else: raise VerificationError( f"step {step.step_index} declares unknown kind {step.operation_kind!r}" ) if fresh_after != step.after_value: raise VerificationError( f"step {step.step_index} declares after_value={step.after_value}, " f"verifier computed {fresh_after}" ) def _verify_apply_rate_step( step: SolutionStep, state: dict[tuple[str, str], float] ) -> None: """Verify an apply_rate step (ADR-0122). Re-applies the rate against the denominator-unit state, checks ``before_value`` / ``after_value`` byte-equal, writes the result to the numerator-unit key. The denominator-unit quantity is preserved (the actor still holds the input quantity after the derived value is computed). """ if not isinstance(step.operand, Rate): raise VerificationError( f"step {step.step_index} kind=apply_rate requires Rate " f"operand; got {type(step.operand).__name__}" ) rate = step.operand denom_key = (step.actor, rate.denominator_unit) if denom_key not in state: raise VerificationError( f"step {step.step_index} kind=apply_rate references " f"({step.actor!r}, {rate.denominator_unit!r}) which is not " f"in verifier state" ) fresh_before = state[denom_key] if fresh_before != step.before_value: raise VerificationError( f"step {step.step_index} declares before_value=" f"{step.before_value}, verifier computed {fresh_before}" ) fresh_after = fresh_before * float(rate.value) if fresh_after != step.after_value: raise VerificationError( f"step {step.step_index} declares after_value=" f"{step.after_value}, verifier computed {fresh_after}" ) if step.target is not None: raise VerificationError( f"step {step.step_index} kind=apply_rate must not declare " f"a target; got {step.target!r}" ) state[(step.actor, rate.numerator_unit)] = fresh_after def _verify_compare_additive_step( step: SolutionStep, state: dict[tuple[str, str], float] ) -> None: """Verify a compare_additive step (ADR-0123). Independent replay: re-derives actor's after_value from reference's state in delta.unit; refuses if before_value != 0, target is set, direction not in {more,fewer}, reference has no state in that unit, or actor already holds state there. """ if not isinstance(step.operand, Comparison): raise VerificationError( f"step {step.step_index} kind=compare_additive requires " f"Comparison operand; got {type(step.operand).__name__}" ) cmp = step.operand if cmp.delta is None: raise VerificationError( f"step {step.step_index} kind=compare_additive requires " f"Comparison.delta; got None" ) if cmp.direction not in ("more", "fewer"): raise VerificationError( f"step {step.step_index} kind=compare_additive requires " f"direction in {{'more','fewer'}}; got {cmp.direction!r}" ) if step.target is not None: raise VerificationError( f"step {step.step_index} kind=compare_additive must not " f"declare a target; got {step.target!r}" ) if step.before_value != 0.0: raise VerificationError( f"step {step.step_index} kind=compare_additive declares " f"before_value={step.before_value}, expected 0.0 " f"(comparison sets fresh state)" ) unit = cmp.delta.unit ref_key = (cmp.reference_actor, unit) if ref_key not in state: raise VerificationError( f"step {step.step_index} kind=compare_additive references " f"({cmp.reference_actor!r}, {unit!r}) which is not in " f"verifier state" ) ref_value = state[ref_key] delta_v = float(cmp.delta.value) if cmp.direction == "more": fresh_after = ref_value + delta_v else: fresh_after = ref_value - delta_v if fresh_after != step.after_value: raise VerificationError( f"step {step.step_index} declares after_value=" f"{step.after_value}, verifier computed {fresh_after}" ) actor_key = (step.actor, unit) if actor_key in state: raise VerificationError( f"step {step.step_index} kind=compare_additive would " f"overwrite existing state for ({step.actor!r}, {unit!r})" ) state[actor_key] = fresh_after def _verify_unit_partition_step( step: SolutionStep, state: dict[tuple[str, str], float], *, last_count_unit: dict[str, str], ) -> None: """Verify a unit_partition step (Gate A2a). Re-applies fixed chunk-size division against the dividend-unit state, requires an exact integer quotient, and writes the count under ``result_unit``. """ if not isinstance(step.operand, PartitionChunk): raise VerificationError( f"step {step.step_index} kind=unit_partition requires " f"PartitionChunk operand; got {type(step.operand).__name__}" ) chunk = step.operand dividend_key = (step.actor, chunk.unit) if dividend_key not in state: raise VerificationError( f"step {step.step_index} kind=unit_partition references " f"({step.actor!r}, {chunk.unit!r}) which is not in verifier state" ) fresh_before = state[dividend_key] if fresh_before != step.before_value: raise VerificationError( f"step {step.step_index} declares before_value=" f"{step.before_value}, verifier computed {fresh_before}" ) chunk_size = float(chunk.value) if chunk_size == 0: raise VerificationError( f"step {step.step_index} kind=unit_partition refuses zero chunk size" ) quotient = fresh_before / chunk_size if abs(quotient - round(quotient)) > 1e-9 or quotient <= 0: raise VerificationError( f"step {step.step_index} kind=unit_partition requires an exact " f"positive integer quotient; got {quotient!r}" ) fresh_after = float(int(round(quotient))) if fresh_after != step.after_value: raise VerificationError( f"step {step.step_index} declares after_value=" f"{step.after_value}, verifier computed {fresh_after}" ) if step.target is not None: raise VerificationError( f"step {step.step_index} kind=unit_partition must not declare " f"a target; got {step.target!r}" ) result_key = (step.actor, chunk.result_unit) if result_key in state: raise VerificationError( f"step {step.step_index} kind=unit_partition would overwrite " f"existing state for ({step.actor!r}, {chunk.result_unit!r})" ) state[result_key] = fresh_after last_count_unit[step.actor] = chunk.result_unit def _verify_fraction_portion_step( step: SolutionStep, state: dict[tuple[str, str], float], *, last_count_unit: dict[str, str], ) -> None: """Verify a fraction_portion step (Gate A2b).""" from generate.math_problem_graph import FractionPortion if not isinstance(step.operand, FractionPortion): raise VerificationError( f"step {step.step_index} kind=fraction_portion requires " f"FractionPortion operand; got {type(step.operand).__name__}" ) portion = step.operand unit = last_count_unit.get(step.actor) if unit is None: raise VerificationError( f"step {step.step_index} kind=fraction_portion requires a " f"prior partition-derived count unit for actor {step.actor!r}" ) key = (step.actor, unit) if key not in state: raise VerificationError( f"step {step.step_index} kind=fraction_portion references " f"({step.actor!r}, {unit!r}) which is not in verifier state" ) fresh_before = state[key] if fresh_before != step.before_value: raise VerificationError( f"step {step.step_index} declares before_value=" f"{step.before_value}, verifier computed {fresh_before}" ) amount = fresh_before * float(portion.numerator) / float(portion.denominator) if abs(amount - round(amount)) > 1e-9: raise VerificationError( f"step {step.step_index} kind=fraction_portion requires an exact " f"integer portion; got {amount!r}" ) fresh_after = fresh_before - float(int(round(amount))) if fresh_after != step.after_value: raise VerificationError( f"step {step.step_index} declares after_value=" f"{step.after_value}, verifier computed {fresh_after}" ) if step.target is not None: raise VerificationError( f"step {step.step_index} kind=fraction_portion must not declare " f"a target; got {step.target!r}" ) state[key] = fresh_after last_count_unit[step.actor] = unit def _verify_compare_multiplicative_step( step: SolutionStep, state: dict[tuple[str, str], float] ) -> None: """Verify a compare_multiplicative step (ADR-0123). Independent replay: scales reference's unique-unit state by factor. Refuses on before_value != 0, target set, direction not in {times,fraction}, missing or ambiguous reference, or overwrite. """ if not isinstance(step.operand, Comparison): raise VerificationError( f"step {step.step_index} kind=compare_multiplicative requires " f"Comparison operand; got {type(step.operand).__name__}" ) cmp = step.operand if cmp.factor is None: raise VerificationError( f"step {step.step_index} kind=compare_multiplicative requires " f"Comparison.factor; got None" ) if cmp.direction not in ("times", "fraction"): raise VerificationError( f"step {step.step_index} kind=compare_multiplicative requires " f"direction in {{'times','fraction'}}; got {cmp.direction!r}" ) if step.target is not None: raise VerificationError( f"step {step.step_index} kind=compare_multiplicative must not " f"declare a target; got {step.target!r}" ) if step.before_value != 0.0: raise VerificationError( f"step {step.step_index} kind=compare_multiplicative declares " f"before_value={step.before_value}, expected 0.0 " f"(comparison sets fresh state)" ) ref_units = [ unit for (entity, unit) in state if entity == cmp.reference_actor ] if not ref_units: raise VerificationError( f"step {step.step_index} kind=compare_multiplicative references " f"actor {cmp.reference_actor!r} which holds no state" ) if len(set(ref_units)) > 1: raise VerificationError( f"step {step.step_index} kind=compare_multiplicative is " f"ambiguous: reference actor {cmp.reference_actor!r} holds " f"state in multiple units {sorted(set(ref_units))!r}" ) unit = ref_units[0] ref_value = state[(cmp.reference_actor, unit)] fresh_after = ref_value * float(cmp.factor) if fresh_after != step.after_value: raise VerificationError( f"step {step.step_index} declares after_value=" f"{step.after_value}, verifier computed {fresh_after}" ) actor_key = (step.actor, unit) if actor_key in state: raise VerificationError( f"step {step.step_index} kind=compare_multiplicative would " f"overwrite existing state for ({step.actor!r}, {unit!r})" ) state[actor_key] = fresh_after def _resolve_answer( unknown: Unknown, state: dict[tuple[str, str], float] ) -> float | None: if unknown.entity is None: return sum(v for (_, unit), v in state.items() if unit == unknown.unit) return state.get((unknown.entity, unknown.unit))