core/teaching/math_reasoning_trace.py
Shay f16ac96fb7
feat(teaching/W0): ReasoningTrace substrate for ADR-0172 Tier 1 (#379)
Schema-only module defining ReasoningStep / ReasoningTrace with
byte-identical canonical serialization and sha256 trace_id derivation.
Replay-equivalence is enforced by:

- sorted-key JSON, no whitespace, ensure_ascii=False, allow_nan=False
- recursive rejection of float values in payloads (replay hazard)
- step_index monotonicity from 0
- empty trace rejected
- Literal-checked step_kind across all eight Tier 1+2 kinds

No runtime hook. No import from chat/field/generate/algebra.
Downstream (W1 ShapeProposal, W2 decomposer) consume this schema.

Tests: 12 new, full teaching suite green (17 passed).
2026-05-27 12:21:59 -07:00

169 lines
5.2 KiB
Python

"""ADR-0172 W0 — ``ReasoningTrace`` substrate.
Schema-only module. Defines the byte-identical, replay-equivalent
reasoning trace carried by ``MathReaderRefusalShapeProposal`` (W1)
and emitted by the audit-corpus decomposer (W2).
Determinism contract:
- Canonical bytes are stable across processes and dict insertion order.
- ``trace_id`` is ``sha256(canonical_bytes)`` of the full step sequence.
- Floating-point values are forbidden in payloads (would break replay).
No runtime hook. No import from ``chat``/``field``/``generate``/``algebra``.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Literal, get_args
__all__ = (
"ReasoningStep",
"ReasoningTrace",
"StepKind",
"build_trace",
"canonical_bytes",
"compute_trace_id",
)
StepKind = Literal[
"observation",
"grouping",
"abstraction",
"hypothesis",
"test_design",
"test_application",
"test_result",
"conclusion",
]
_STEP_KINDS: frozenset[str] = frozenset(get_args(StepKind))
@dataclass(frozen=True)
class ReasoningStep:
step_index: int
step_kind: StepKind
input_pointers: tuple[str, ...]
claim: str
justification: str
output_payload: object
@dataclass(frozen=True)
class ReasoningTrace:
trace_id: str
steps: tuple[ReasoningStep, ...]
def _reject_floats(value: object, *, path: str) -> None:
"""Recursively reject any ``float`` instance in a JSON-shaped value.
Booleans are a ``bool`` subclass of ``int`` and remain permitted.
"""
if isinstance(value, float):
raise ValueError(
f"floating-point values are forbidden in canonical payloads (at {path})"
)
if isinstance(value, dict):
for key, sub in value.items():
if not isinstance(key, str):
raise ValueError(
f"payload dict keys must be strings (at {path}, got {type(key).__name__})"
)
_reject_floats(sub, path=f"{path}.{key}")
elif isinstance(value, (list, tuple)):
for idx, sub in enumerate(value):
_reject_floats(sub, path=f"{path}[{idx}]")
def _validate_payload(payload: object) -> None:
"""Ensure payload is JSON-serializable and float-free."""
try:
json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False)
except (TypeError, ValueError) as exc:
raise ValueError(f"output_payload is not JSON-serializable: {exc}") from exc
_reject_floats(payload, path="output_payload")
def _step_to_canonical(step: ReasoningStep) -> dict[str, object]:
return {
"step_index": step.step_index,
"step_kind": step.step_kind,
"input_pointers": list(step.input_pointers),
"claim": step.claim,
"justification": step.justification,
"output_payload": step.output_payload,
}
def _steps_canonical_bytes(steps: tuple[ReasoningStep, ...]) -> bytes:
payload = [_step_to_canonical(step) for step in steps]
return json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
allow_nan=False,
).encode("utf-8")
def canonical_bytes(trace: ReasoningTrace) -> bytes:
"""Return the canonical byte serialization of a trace.
The ``trace_id`` field itself is excluded (it is derived from these
very bytes). Stable across processes and dict insertion order.
"""
return _steps_canonical_bytes(trace.steps)
def compute_trace_id(steps: tuple[ReasoningStep, ...]) -> str:
"""Return ``sha256(canonical_bytes(steps))`` as hex digest."""
return hashlib.sha256(_steps_canonical_bytes(steps)).hexdigest()
def build_trace(steps: list[ReasoningStep] | tuple[ReasoningStep, ...]) -> ReasoningTrace:
"""Validate, freeze, and hash a reasoning trace.
Raises ``ValueError`` on:
- empty step list
- ``step_kind`` outside the ``StepKind`` Literal
- ``step_index`` not starting at 0 with monotonic +1 increments
- non-JSON-serializable payload
- any floating-point value inside a payload (replay hazard)
"""
frozen = tuple(steps)
if not frozen:
raise ValueError("reasoning trace must contain at least one step")
for expected_index, step in enumerate(frozen):
if step.step_kind not in _STEP_KINDS:
raise ValueError(
f"step {expected_index}: unknown step_kind {step.step_kind!r}"
)
if step.step_index != expected_index:
raise ValueError(
"step_index must start at 0 and increase monotonically by 1 "
f"(step {expected_index} has step_index={step.step_index})"
)
if not isinstance(step.input_pointers, tuple):
raise ValueError(
f"step {expected_index}: input_pointers must be a tuple"
)
for pointer in step.input_pointers:
if not isinstance(pointer, str):
raise ValueError(
f"step {expected_index}: input_pointers must contain strings only"
)
_validate_payload(step.output_payload)
trace_id = compute_trace_id(frozen)
return ReasoningTrace(trace_id=trace_id, steps=frozen)