From 2c5eb2e36b5d566a7b0f2e8eb4441d10a77f5fc1 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 25 May 2026 06:09:57 -0700 Subject: [PATCH] feat(w019): wire core teaching propose-miner/propose-curriculum CLI commands (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes W-019 wiring debt. Per Phase 2 operator decision (path a): wire CLI — smallest reachability fix, no architectural commitment. teaching/from_miner.py and teaching/from_curriculum.py (ADR-0095/ADR-0104) correctly build source-stamped PackMutationProposals but had no CLI or runtime caller — test-live only. Now reachable: core teaching propose-miner \ --findings --miner-id [--revision ] [--out ] core teaching propose-curriculum \ --findings --curriculum-id [--revision ] [--out ] Changes to core/cli.py: - _load_findings_jsonl(): deserializes ContemplationFinding records from operator-provided JSONL (as_dict() round-trip format). - _read_jsonl_file(): shared JSONL line reader. - cmd_teaching_propose_miner(): calls from_miner.from_findings(); writes proposals to --out JSONL or stdout; prints proposals/rejections summary to stderr. Returns 0 if any proposals built, 1 otherwise. - cmd_teaching_propose_curriculum(): same shape for curriculum path. - _current_git_revision(): --revision default, falls back to "unknown". - _write_miner_curriculum_batch(): shared proposal serialisation + summary. - Two new subcommands registered: propose-miner, propose-curriculum. tests/test_teaching_propose_cli.py: 5 tests covering round-trip loading, stdout output, file output, and empty-findings error path. --- core/cli.py | 169 +++++++++++++++++++++++++++++ tests/test_teaching_propose_cli.py | 161 +++++++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 tests/test_teaching_propose_cli.py diff --git a/core/cli.py b/core/cli.py index e0a8b832..ccf8524c 100644 --- a/core/cli.py +++ b/core/cli.py @@ -1230,6 +1230,130 @@ def cmd_teaching_propose(args: argparse.Namespace) -> int: return 0 if rec["state"] in ("pending", "accepted") else 1 +def _load_findings_jsonl(path: str) -> list: + """Load ContemplationFinding objects from a JSONL file (W-019).""" + from core.contemplation.schema import ( + ContemplationEvidenceRef, ContemplationFinding, FindingKind, + ) + from teaching.epistemic import EpistemicStatus + + findings = [] + for raw in _read_jsonl_file(Path(path)): + evidence_refs = tuple( + ContemplationEvidenceRef( + source_type=e["source_type"], + source_id=e["source_id"], + pointer=e["pointer"], + summary=e.get("summary", ""), + ) + for e in raw.get("evidence_refs", []) + ) + findings.append(ContemplationFinding( + kind=FindingKind(raw["kind"]), + subject=raw["subject"], + predicate=raw["predicate"], + object=raw.get("object"), + evidence_refs=evidence_refs, + proposed_action=raw["proposed_action"], + substrate_hash=raw.get("substrate_hash", ""), + epistemic_status=EpistemicStatus( + raw.get("epistemic_status", EpistemicStatus.SPECULATIVE.value) + ), + finding_id=raw.get("finding_id", ""), + )) + return findings + + +def _read_jsonl_file(path: Path) -> list: + """Read a JSONL file and return a list of parsed dicts.""" + lines = [] + with path.open(encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line: + lines.append(json.loads(line)) + return lines + + +def cmd_teaching_propose_miner(args: argparse.Namespace) -> int: + """W-019: build PackMutationProposals from miner ContemplationFinding JSONL.""" + from teaching.from_miner import MinerProposalError, from_findings + + findings = _load_findings_jsonl(args.findings) + if not findings: + _die(f"no findings in {args.findings}", code=1) + + revision = args.revision or _current_git_revision() + try: + batch = from_findings( + findings, + miner_id=args.miner_id, + emitted_at_revision=revision, + ) + except MinerProposalError as exc: + _die(f"batch construction failed: {exc}", code=1) + + out_path = Path(args.out) if args.out else None + _write_miner_curriculum_batch(batch.proposals, batch.rejections, out_path) + return 0 if batch.proposals else 1 + + +def cmd_teaching_propose_curriculum(args: argparse.Namespace) -> int: + """W-019: build PackMutationProposals from curriculum ContemplationFinding JSONL.""" + from teaching.from_curriculum import CurriculumProposalError, from_findings + + findings = _load_findings_jsonl(args.findings) + if not findings: + _die(f"no findings in {args.findings}", code=1) + + revision = args.revision or _current_git_revision() + try: + batch = from_findings( + findings, + curriculum_id=args.curriculum_id, + emitted_at_revision=revision, + ) + except CurriculumProposalError as exc: + _die(f"batch construction failed: {exc}", code=1) + + out_path = Path(args.out) if args.out else None + _write_miner_curriculum_batch(batch.proposals, batch.rejections, out_path) + return 0 if batch.proposals else 1 + + +def _current_git_revision() -> str: + """Return the current git HEAD SHA (first 12 chars) or 'unknown'.""" + import subprocess + try: + result = subprocess.run( + ["git", "rev-parse", "--short=12", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + return result.stdout.strip() or "unknown" + except Exception: # noqa: BLE001 + return "unknown" + + +def _write_miner_curriculum_batch( + proposals: tuple, + rejections: tuple, + out_path: Path | None, +) -> None: + """Write PackMutationProposal batch to JSONL and print summary.""" + lines = [json.dumps(p.as_dict(), sort_keys=True, ensure_ascii=False) for p in proposals] + if out_path is not None: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") + print(f"wrote {len(proposals)} proposal(s) → {out_path}") + else: + for line in lines: + print(line) + print(f"proposals : {len(proposals)}", file=sys.stderr) + print(f"rejections: {len(rejections)}", file=sys.stderr) + for rej in rejections: + print(f" rejected {rej.get('finding_id', '?')}: {rej.get('reason', '?')}", file=sys.stderr) + + def cmd_teaching_proposals(args: argparse.Namespace) -> int: from teaching.proposals import ProposalLog @@ -3383,6 +3507,51 @@ def build_parser() -> argparse.ArgumentParser: ) teaching_propose.set_defaults(func=cmd_teaching_propose) + # W-019 — miner and curriculum proposal construction paths (ADR-0095/0104) + teaching_propose_miner = teaching_sub.add_parser( + "propose-miner", + help="build PackMutationProposals from miner ContemplationFinding JSONL (ADR-0095)", + ) + teaching_propose_miner.add_argument( + "--findings", required=True, + help="path to JSONL file of ContemplationFinding records (kind=pack_mutation_candidate)", + ) + teaching_propose_miner.add_argument( + "--miner-id", required=True, + help="miner identifier stamped on proposals (e.g. 'articulation_quality_v1')", + ) + teaching_propose_miner.add_argument( + "--revision", default=None, + help="emitted_at_revision string (defaults to current git HEAD SHA)", + ) + teaching_propose_miner.add_argument( + "--out", default=None, + help="output JSONL path for proposals (default: stdout)", + ) + teaching_propose_miner.set_defaults(func=cmd_teaching_propose_miner) + + teaching_propose_curriculum = teaching_sub.add_parser( + "propose-curriculum", + help="build PackMutationProposals from curriculum ContemplationFinding JSONL (ADR-0104)", + ) + teaching_propose_curriculum.add_argument( + "--findings", required=True, + help="path to JSONL file of ContemplationFinding records (kind=pack_mutation_candidate)", + ) + teaching_propose_curriculum.add_argument( + "--curriculum-id", required=True, + help="curriculum identifier stamped on proposals (e.g. 'gsm8k_curriculum_v1')", + ) + teaching_propose_curriculum.add_argument( + "--revision", default=None, + help="emitted_at_revision string (defaults to current git HEAD SHA)", + ) + teaching_propose_curriculum.add_argument( + "--out", default=None, + help="output JSONL path for proposals (default: stdout)", + ) + teaching_propose_curriculum.set_defaults(func=cmd_teaching_propose_curriculum) + teaching_proposals = teaching_sub.add_parser( "proposals", help="list proposals in the append-only log", diff --git a/tests/test_teaching_propose_cli.py b/tests/test_teaching_propose_cli.py new file mode 100644 index 00000000..eb043c57 --- /dev/null +++ b/tests/test_teaching_propose_cli.py @@ -0,0 +1,161 @@ +"""Tests for W-019: core teaching propose-miner / propose-curriculum CLI commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from core.contemplation.schema import ( + ContemplationEvidenceRef, + ContemplationFinding, + FindingKind, +) +from teaching.epistemic import EpistemicStatus + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _good_finding( + *, + subject: str = "knowledge", + predicate: str = "requires", + object_: str | None = "evidence", + proposed_action: str = "extend cognition pack with knowledge→evidence chain", + substrate_hash: str = "abc123", +) -> ContemplationFinding: + return ContemplationFinding( + kind=FindingKind.PACK_MUTATION_CANDIDATE, + subject=subject, + predicate=predicate, + object=object_, + evidence_refs=( + ContemplationEvidenceRef( + source_type="articulation_observation", + source_id="run-1", + pointer="turn:1", + summary="weak surface", + ), + ), + proposed_action=proposed_action, + substrate_hash=substrate_hash, + ) + + +def _write_findings_jsonl(tmp_path: Path, findings: list) -> Path: + path = tmp_path / "findings.jsonl" + lines = [json.dumps(f.as_dict(), sort_keys=True, ensure_ascii=False) for f in findings] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# _load_findings_jsonl — unit test +# --------------------------------------------------------------------------- + +def test_load_findings_jsonl_round_trips(tmp_path: Path) -> None: + from core.cli import _load_findings_jsonl + finding = _good_finding() + jsonl_path = _write_findings_jsonl(tmp_path, [finding]) + loaded = _load_findings_jsonl(str(jsonl_path)) + assert len(loaded) == 1 + assert loaded[0].subject == "knowledge" + assert loaded[0].kind is FindingKind.PACK_MUTATION_CANDIDATE + assert loaded[0].epistemic_status is EpistemicStatus.SPECULATIVE + + +# --------------------------------------------------------------------------- +# cmd_teaching_propose_miner +# --------------------------------------------------------------------------- + +def test_propose_miner_writes_proposals_to_stdout( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + import argparse + from core.cli import cmd_teaching_propose_miner + + finding = _good_finding() + jsonl_path = _write_findings_jsonl(tmp_path, [finding]) + + args = argparse.Namespace( + findings=str(jsonl_path), + miner_id="test_miner_v1", + revision="test-revision", + out=None, + ) + rc = cmd_teaching_propose_miner(args) + assert rc == 0 + captured = capsys.readouterr() + proposal = json.loads(captured.out.strip().splitlines()[0]) + assert "proposal_id" in proposal + assert proposal["subject"] == "knowledge" + + +def test_propose_miner_writes_to_file(tmp_path: Path) -> None: + import argparse + from core.cli import cmd_teaching_propose_miner + + finding = _good_finding() + jsonl_path = _write_findings_jsonl(tmp_path, [finding]) + out_path = tmp_path / "out.jsonl" + + args = argparse.Namespace( + findings=str(jsonl_path), + miner_id="test_miner_v1", + revision="test-revision", + out=str(out_path), + ) + rc = cmd_teaching_propose_miner(args) + assert rc == 0 + lines = out_path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + proposal = json.loads(lines[0]) + assert proposal["subject"] == "knowledge" + + +# --------------------------------------------------------------------------- +# cmd_teaching_propose_curriculum +# --------------------------------------------------------------------------- + +def test_propose_curriculum_writes_proposals_to_stdout( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + import argparse + from core.cli import cmd_teaching_propose_curriculum + + finding = _good_finding(subject="truth", predicate="grounds", proposed_action="ground truth chain") + jsonl_path = _write_findings_jsonl(tmp_path, [finding]) + + args = argparse.Namespace( + findings=str(jsonl_path), + curriculum_id="gsm8k_curriculum_v1", + revision="test-revision", + out=None, + ) + rc = cmd_teaching_propose_curriculum(args) + assert rc == 0 + captured = capsys.readouterr() + proposal = json.loads(captured.out.strip().splitlines()[0]) + assert "proposal_id" in proposal + assert proposal["subject"] == "truth" + + +def test_propose_miner_returns_nonzero_on_empty_findings(tmp_path: Path) -> None: + import argparse + from core.cli import cmd_teaching_propose_miner + + empty_path = tmp_path / "empty.jsonl" + empty_path.write_text("", encoding="utf-8") + + args = argparse.Namespace( + findings=str(empty_path), + miner_id="test_miner_v1", + revision="test-revision", + out=None, + ) + with pytest.raises(SystemExit) as exc_info: + cmd_teaching_propose_miner(args) + assert exc_info.value.code == 1