From 65c85872070097010d36015651b041c20c1c9a5c Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 7 Jun 2026 22:25:00 -0700 Subject: [PATCH] feat(proposal-review): pure idle_summary function (IT-a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/proposal_review/summary.py: idle_summary(root=None) -> ProposalReviewIdleSummary composes the landed scan/build_report/dry_check into one JSON-safe primitives-only value (safe, total, review_needed, malformed, by_family tuple-of-pairs, errors). Pure read — no mutation, no clock, no runtime touch. errors carries the dry-check violations (why not safe); IT-b reuses it for a captured reporter exception. 7 tests incl. read-only + deterministic + json-serializable. --- core/proposal_review/__init__.py | 3 ++ core/proposal_review/summary.py | 52 ++++++++++++++++++ tests/test_proposal_review_summary.py | 77 +++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 core/proposal_review/summary.py create mode 100644 tests/test_proposal_review_summary.py diff --git a/core/proposal_review/__init__.py b/core/proposal_review/__init__.py index 384e5daf..7db01f82 100644 --- a/core/proposal_review/__init__.py +++ b/core/proposal_review/__init__.py @@ -19,16 +19,19 @@ from core.proposal_review.report import ( ) from core.proposal_review.safety import SafetyVerdict, dry_check from core.proposal_review.scan import DEFAULT_SINK, default_sink, scan +from core.proposal_review.summary import ProposalReviewIdleSummary, idle_summary __all__ = [ "DEFAULT_SINK", "MalformedArtifact", "PendingProposal", + "ProposalReviewIdleSummary", "ProposalReviewReport", "SafetyVerdict", "build_report", "default_sink", "dry_check", + "idle_summary", "report_json", "report_text", "scan", diff --git a/core/proposal_review/summary.py b/core/proposal_review/summary.py new file mode 100644 index 00000000..2a107253 --- /dev/null +++ b/core/proposal_review/summary.py @@ -0,0 +1,52 @@ +"""Pure idle-use summary of the proposal sink (IT-a). + +``idle_summary`` composes the landed read-only pieces — ``scan`` (RPT-a) + ``build_report`` +(RPT-b) + ``dry_check`` (RPT-c) — into one small, JSON-safe value the runtime's ``idle_tick`` +can surface (IT-b) without importing the reporter's internals. Pure read: no mutation, no clock. + +The summary is deliberately primitives-only (no paths, no raw file content, no mutable dicts) so +it is trivially serializable if ``IdleTickResult`` is ever persisted. ``errors`` carries the +reason the sink is not ``safe`` — the dry-check violations here; IT-b additionally uses it to +record a captured reporter exception (``proposal_review_failed:``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from core.proposal_review.report import build_report +from core.proposal_review.safety import dry_check +from core.proposal_review.scan import scan + + +@dataclass(frozen=True, slots=True) +class ProposalReviewIdleSummary: + """A JSON-safe snapshot of the proposal sink for idle surfacing. ``safe`` is the dry-check + verdict; ``errors`` is empty iff safe (or carries ``proposal_review_failed:`` when the + reporter itself raised, set by the runtime sub-pass).""" + + safe: bool + total: int + review_needed: int + malformed: int + by_family: tuple[tuple[str, int], ...] + errors: tuple[str, ...] = () + + +def idle_summary(root: Path | None = None) -> ProposalReviewIdleSummary: + """Scan → report → dry-check the proposal sink into a JSON-safe idle summary. Pure read.""" + proposals, malformed = scan(root) + report = build_report(proposals, malformed) + verdict = dry_check(proposals, malformed, root=root) + return ProposalReviewIdleSummary( + safe=verdict.ok, + total=report.total, + review_needed=len(report.review_needed), + malformed=report.malformed, + by_family=tuple(report.by_family.items()), + errors=verdict.violations, + ) + + +__all__ = ["ProposalReviewIdleSummary", "idle_summary"] diff --git a/tests/test_proposal_review_summary.py b/tests/test_proposal_review_summary.py new file mode 100644 index 00000000..15eac218 --- /dev/null +++ b/tests/test_proposal_review_summary.py @@ -0,0 +1,77 @@ +"""Tests for the pure idle_summary function (IT-a).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from core.proposal_review.summary import ProposalReviewIdleSummary, idle_summary +from evals.constraint_oracle.runner import _load_r2_gold +from generate.contemplation import contemplate + + +def _emit(root: Path) -> None: + for fx in _load_r2_gold(): + if fx["expect"] == "reader_refuses" and fx["reader_reason"] in ( + "missing_total_count", + "missing_weighted_total", + ): + contemplate(fx["text"], proposal_root=root, case_id=fx["id"]) + + +def test_empty_sink_is_safe_and_zero(tmp_path: Path) -> None: + s = idle_summary(tmp_path) + assert s == ProposalReviewIdleSummary(safe=True, total=0, review_needed=0, malformed=0, by_family=()) + + +def test_valid_proposals_summarized(tmp_path: Path) -> None: + _emit(tmp_path) + s = idle_summary(tmp_path) + assert s.safe and s.total == 2 and s.review_needed == 2 and s.malformed == 0 + assert dict(s.by_family) == {"missing_total_count": 1, "missing_weighted_total": 1} + assert s.errors == () + + +def test_malformed_makes_unsafe(tmp_path: Path) -> None: + (tmp_path / "bad.json").write_text("{ not json", encoding="utf-8") + s = idle_summary(tmp_path) + assert not s.safe and s.malformed == 1 and s.errors + + +def test_unsafe_artifact_flagged(tmp_path: Path) -> None: + doc = { + "status": "proposal_only", "failure_family": "missing_total_count", + "problem_text_sha256": "a" * 64, "observed_attempts": [], + "suggested_next_fixture": None, "requires_review": True, "mounted": True, # <- unsafe + } + import hashlib + + h = hashlib.sha256(b"missing_total_count:" + b"a" * 64).hexdigest() + (tmp_path / f"{h}.json").write_text(json.dumps(doc), encoding="utf-8") + s = idle_summary(tmp_path) + assert not s.safe and any("mounted" in e for e in s.errors) + + +def test_summary_is_deterministic(tmp_path: Path) -> None: + _emit(tmp_path) + assert idle_summary(tmp_path) == idle_summary(tmp_path) + + +def test_summary_is_read_only(tmp_path: Path) -> None: + _emit(tmp_path) + before = {p.name: p.read_bytes() for p in tmp_path.glob("*.json")} + idle_summary(tmp_path) + after = {p.name: p.read_bytes() for p in tmp_path.glob("*.json")} + assert before == after and before + + +def test_summary_is_json_serializable(tmp_path: Path) -> None: + _emit(tmp_path) + s = idle_summary(tmp_path) + # primitives only -> trivially serializable (no paths / raw content / mutable dicts) + json.dumps( + { + "safe": s.safe, "total": s.total, "review_needed": s.review_needed, + "malformed": s.malformed, "by_family": list(s.by_family), "errors": list(s.errors), + } + )