feat(proposal-review): pure idle_summary function (IT-a)
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.
This commit is contained in:
parent
3ec5e91433
commit
65c8587207
3 changed files with 132 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
52
core/proposal_review/summary.py
Normal file
52
core/proposal_review/summary.py
Normal file
|
|
@ -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:<type>``).
|
||||
"""
|
||||
|
||||
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:<type>`` 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"]
|
||||
77
tests/test_proposal_review_summary.py
Normal file
77
tests/test_proposal_review_summary.py
Normal file
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
Loading…
Reference in a new issue