core/core/comprehension_attempt/proposal.py
Shay 5903cba08a feat(teaching): proposal-only failure-artifact emitter (N5)
core/comprehension_attempt/proposal.py: emit_proposal writes a content-addressed teaching/proposals/comprehension_failures/<hash>.json ONLY for growth-surface families (proposal_allowed). Deliberately toothless: status always proposal_only, mounted always false, requires_review always true (enforced in __post_init__); the problem text is hashed (sha256), never stored; filename = sha256(family:text_sha) so the same failure is idempotent.

No proposal for a correct wrong=0 boundary. Serving never reads the sink (scanned: generate/stream, field/propagate, vault/store, generate/derivation, core/reliability_gate). Routes through the existing proposal-only flywheel (ADR-0055/0056/0057), never a parallel correction path. README pins the contract. 6 tests.
2026-06-07 08:48:45 -07:00

148 lines
5.3 KiB
Python

"""Proposal-only failure-artifact emitter (N5) — deliberately toothless.
When the contemplation pass (N6) meets a **growth-surface** failure family (``proposal_allowed
= True``), it may emit a single content-addressed JSON artifact under
``teaching/proposals/comprehension_failures/<hash>.json``. That artifact can *propose* a next
fixture/rule for human review. It can do nothing else:
```text
status is always "proposal_only"
mounted is always false
requires_review is always true
serving never reads these files
no reader/test is modified
```
This routes into the existing proposal-only teaching flywheel (ADR-0055/0056/0057) — it is NOT a
parallel correction path (CLAUDE.md teaching-safety). The alignment is
``failure -> classification -> proposal -> review -> ratification``, never ``failure -> self-patch``.
Content-addressing: the filename is ``sha256(failure_family : sha256(problem_text))`` — so the
same failure on the same text always writes the same path (idempotent), and the raw problem text
is **hashed, never stored**. Deterministic; no clock, no randomness.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from core.comprehension_attempt.failure_family import FailureFamily
from core.comprehension_attempt.model import ComprehensionAttempt
_PROPOSAL_STATUS = "proposal_only"
def default_proposal_root() -> Path:
"""``<repo>/teaching/proposals/comprehension_failures`` — the write-only proposal sink."""
return Path(__file__).resolve().parents[2] / "teaching" / "proposals" / "comprehension_failures"
def _sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _attempt_to_dict(attempt: ComprehensionAttempt) -> dict[str, Any]:
return {
"organ": attempt.organ,
"outcome": attempt.outcome,
"refusal_reason": attempt.refusal_reason,
"family": attempt.family,
"setup_signature": attempt.setup_signature,
"answer": attempt.answer,
}
@dataclass(frozen=True, slots=True)
class FailureProposal:
"""A proposal-only artifact. The invariant fields (``status``/``requires_review``/``mounted``)
are enforced in ``__post_init__`` so even a hand-constructed proposal cannot be made
serving-mountable."""
failure_family: str
problem_text_sha256: str
observed_attempts: tuple[dict[str, Any], ...]
status: str = _PROPOSAL_STATUS
suggested_next_fixture: None = None # v0: always None — a human authors the fixture on review
requires_review: bool = True
mounted: bool = False
def __post_init__(self) -> None:
if self.status != _PROPOSAL_STATUS:
raise ValueError(f"proposal status must be {_PROPOSAL_STATUS!r}; got {self.status!r}")
if self.mounted:
raise ValueError("a proposal can never be mounted")
if not self.requires_review:
raise ValueError("a proposal always requires review")
def content_hash(self) -> str:
"""Deterministic content address: same failure on same text -> same hash."""
return hashlib.sha256(
f"{self.failure_family}:{self.problem_text_sha256}".encode("utf-8")
).hexdigest()
def to_json_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"failure_family": self.failure_family,
"problem_text_sha256": self.problem_text_sha256,
"observed_attempts": list(self.observed_attempts),
"suggested_next_fixture": self.suggested_next_fixture,
"requires_review": self.requires_review,
"mounted": self.mounted,
}
def build_proposal(
text: str, family: FailureFamily, attempts: tuple[ComprehensionAttempt, ...]
) -> FailureProposal | None:
"""Build a proposal for a growth-surface family, or ``None`` for a correct wrong=0 boundary.
A family with ``proposal_allowed = False`` (every must-remain-refused boundary) yields NO
proposal — the loop never proposes against a faithful refusal.
"""
if not family.proposal_allowed:
return None
return FailureProposal(
failure_family=family.name,
problem_text_sha256=_sha256(text),
observed_attempts=tuple(_attempt_to_dict(a) for a in attempts),
)
def proposal_path(proposal: FailureProposal, root: Path | None = None) -> Path:
base = root if root is not None else default_proposal_root()
return base / f"{proposal.content_hash()}.json"
def emit_proposal(
text: str,
family: FailureFamily,
attempts: tuple[ComprehensionAttempt, ...],
*,
root: Path | None = None,
) -> Path | None:
"""Write a proposal-only artifact for a growth-surface family; return its path, or ``None``.
Idempotent: the same failure on the same text writes the same content-addressed path with
byte-identical content (``sort_keys``). Creates the sink directory on demand.
"""
proposal = build_proposal(text, family, attempts)
if proposal is None:
return None
path = proposal_path(proposal, root)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(proposal.to_json_dict(), indent=2, sort_keys=True), encoding="utf-8")
return path
__all__ = [
"FailureProposal",
"build_proposal",
"default_proposal_root",
"emit_proposal",
"proposal_path",
]