feat(w019): wire core teaching propose-miner/propose-curriculum CLI commands (#266)

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 <jsonl> --miner-id <id> [--revision <rev>] [--out <jsonl>]

  core teaching propose-curriculum \
      --findings <jsonl> --curriculum-id <id> [--revision <rev>] [--out <jsonl>]

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.
This commit is contained in:
Shay 2026-05-25 06:09:57 -07:00 committed by GitHub
parent a1a085057e
commit 2c5eb2e36b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 330 additions and 0 deletions

View file

@ -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",

View file

@ -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