core/tests/test_teaching_gaps.py
Shay 84e74eede8 feat(teaching): discovery gaps aggregator + auto-promotion queue (Phase 1.1+1.2)
Closes the corpus flywheel.  ADR-0055 Phase B emits DiscoveryCandidate
JSONL to the discovery sink, but until now there was no operator-facing
view: candidates accumulated to disk, no one grepped them, the system's
"I would have grounded this if I had a chain" signal went into a void.

P1.1 — Discovery aggregator (teaching/gaps.py).

  Pure reader over the discovery-sink monthly-rollover layout
  (<root>/<YYYY>/<YYYY-MM>.jsonl).  aggregate_gaps(root, since,
  sample_limit) groups emitted candidates by (subject, intent) cell
  and returns a deterministic ranked tuple of Gap records.

  - count: total emissions
  - boundary_clean_count: subset whose boundary_clean flag held
    (refusal/hedge-tainted emissions split out so operators can filter)
  - sample_candidate_ids: up to N retained ids per cell, sorted
  - months_seen: every month token where the cell appeared

  --since YYYY-MM filters by file naming convention (no timestamp
  dependency).  Malformed lines silently skipped.  Default root:
  teaching/discovery_log.

  CLI: core teaching gaps [--root PATH] [--since YYYY-MM] [--top N]
                          [--sample-limit N] [--json]

P1.2 — Auto-promotion queue (teaching/promotion.py).

  promote_gaps(gaps, threshold, include_tainted) lifts cells whose
  effective count meets the threshold into GapPromotion records.

  - Default mode: boundary_clean_count gates promotion.  Tainted-only
    cells (count > 0 but all emissions refusal/hedge-tainted) do not
    auto-promote — those may indicate the prompt hit a safety axis,
    not a curriculum gap.
  - include_tainted=True counts every emission (operator override).
  - Threshold must be >= 1 (zero threshold defeats the queue).
  - queue_id is stable + deterministic (gap:<intent>:<subject>@<N>).
  - No content synthesis — promotion never invents connective or
    object; only an operator can author a complete chain via the
    propose/replay/accept pipeline.

  CLI: core teaching queue [--threshold N] [--include-tainted]
                           [--root PATH] [--since YYYY-MM] [--json]

Operator workflow (closed loop):

  operator → core chat                            # asks question
           ← cold turn emits DiscoveryCandidate
  operator → core teaching gaps --top 10          # ranked gaps
  operator → core teaching queue --threshold 3    # auto-promoted
  operator → authors candidate JSONL
  operator → core teaching propose <path>         # replay gate runs
  operator → core teaching review <id> --accept   # corpus mutates

24 new tests (13 gaps + 11 promotion), all pure / no I/O dependencies,
fast (<1s combined).  Full lane: 1933 passed, 2 skipped.
2026-05-18 16:04:39 -07:00

220 lines
7.9 KiB
Python

"""Phase 1.1 — discovery-gap aggregation tests.
The contract these tests pin:
- ``aggregate_gaps`` is a pure reader: never mutates sink files,
returns deterministic ordering, skips malformed lines silently.
- Filenames follow ``YYYY-MM.jsonl`` under ``<root>/<YYYY>/`` —
other names are ignored.
- ``--since YYYY-MM`` filters by month (inclusive lower bound).
- ``boundary_clean=false`` candidates are counted but split out so
operators can filter refusal/hedge-tainted cells separately.
- ``top`` truncation preserves order.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from teaching.gaps import Gap, aggregate_gaps
def _write_line(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(payload, sort_keys=True, separators=(",", ":")))
fh.write("\n")
def _candidate(
*,
candidate_id: str,
subject: str,
intent: str,
boundary_clean: bool = True,
) -> dict:
return {
"candidate_id": candidate_id,
"proposed_chain": {
"subject": subject,
"intent": intent,
"connective": None,
"object": None,
},
"trigger": "would_have_grounded",
"source_turn_trace": "trace-" + candidate_id,
"pack_consistent": True,
"boundary_clean": boundary_clean,
"review_state": "unreviewed",
}
# ---------------------------------------------------------------------------
# Aggregation — basic counts, ordering, sample retention
# ---------------------------------------------------------------------------
def test_aggregates_by_subject_intent(tmp_path: Path) -> None:
sink = tmp_path / "2026" / "2026-05.jsonl"
_write_line(sink, _candidate(candidate_id="a", subject="parent", intent="cause"))
_write_line(sink, _candidate(candidate_id="b", subject="parent", intent="cause"))
_write_line(sink, _candidate(candidate_id="c", subject="child", intent="verification"))
rows = aggregate_gaps(tmp_path)
assert len(rows) == 2
parent_cause = next(g for g in rows if g.subject == "parent")
child_verif = next(g for g in rows if g.subject == "child")
assert parent_cause.intent == "cause"
assert parent_cause.count == 2
assert parent_cause.boundary_clean_count == 2
assert parent_cause.sample_candidate_ids == ("a", "b")
assert parent_cause.months_seen == ("2026-05",)
assert child_verif.count == 1
def test_rank_order_count_desc_then_subject(tmp_path: Path) -> None:
sink = tmp_path / "2026" / "2026-05.jsonl"
# 3x parent-cause, 2x child-cause, 1x family-cause
for i in range(3):
_write_line(sink, _candidate(candidate_id=f"p{i}", subject="parent", intent="cause"))
for i in range(2):
_write_line(sink, _candidate(candidate_id=f"c{i}", subject="child", intent="cause"))
_write_line(sink, _candidate(candidate_id="f0", subject="family", intent="cause"))
rows = aggregate_gaps(tmp_path)
assert [g.subject for g in rows] == ["parent", "child", "family"]
assert [g.count for g in rows] == [3, 2, 1]
def test_top_truncation_preserves_order(tmp_path: Path) -> None:
sink = tmp_path / "2026" / "2026-05.jsonl"
for i in range(3):
_write_line(sink, _candidate(candidate_id=f"p{i}", subject="parent", intent="cause"))
_write_line(sink, _candidate(candidate_id="c0", subject="child", intent="cause"))
rows = aggregate_gaps(tmp_path)
assert len(rows) == 2
# Top-1 should yield the parent cell only.
assert rows[0].subject == "parent"
assert rows[0].count == 3
# ---------------------------------------------------------------------------
# Boundary-clean accounting
# ---------------------------------------------------------------------------
def test_boundary_tainted_candidates_count_but_split(tmp_path: Path) -> None:
sink = tmp_path / "2026" / "2026-05.jsonl"
_write_line(sink, _candidate(candidate_id="clean", subject="parent", intent="cause"))
_write_line(sink, _candidate(
candidate_id="tainted", subject="parent", intent="cause", boundary_clean=False,
))
rows = aggregate_gaps(tmp_path)
assert len(rows) == 1
assert rows[0].count == 2
assert rows[0].boundary_clean_count == 1
# ---------------------------------------------------------------------------
# --since month filter
# ---------------------------------------------------------------------------
def test_since_filter_excludes_earlier_months(tmp_path: Path) -> None:
_write_line(tmp_path / "2026" / "2026-04.jsonl",
_candidate(candidate_id="april", subject="parent", intent="cause"))
_write_line(tmp_path / "2026" / "2026-05.jsonl",
_candidate(candidate_id="may", subject="parent", intent="cause"))
rows = aggregate_gaps(tmp_path, since="2026-05")
assert len(rows) == 1
assert rows[0].count == 1
assert rows[0].sample_candidate_ids == ("may",)
def test_since_filter_includes_boundary_month(tmp_path: Path) -> None:
_write_line(tmp_path / "2026" / "2026-05.jsonl",
_candidate(candidate_id="may", subject="parent", intent="cause"))
rows = aggregate_gaps(tmp_path, since="2026-05")
assert len(rows) == 1
def test_since_rejects_malformed_token(tmp_path: Path) -> None:
with pytest.raises(ValueError):
aggregate_gaps(tmp_path, since="May 2026")
# ---------------------------------------------------------------------------
# Robustness — missing root, malformed JSONL, non-monthly filenames
# ---------------------------------------------------------------------------
def test_missing_root_returns_empty_tuple(tmp_path: Path) -> None:
rows = aggregate_gaps(tmp_path / "does_not_exist")
assert rows == ()
def test_malformed_lines_silently_skipped(tmp_path: Path) -> None:
sink = tmp_path / "2026" / "2026-05.jsonl"
sink.parent.mkdir(parents=True, exist_ok=True)
sink.write_text(
"\n".join([
"not json",
"{}", # missing proposed_chain
json.dumps({"proposed_chain": {"subject": ""}}), # empty subject
json.dumps(_candidate(candidate_id="ok", subject="parent", intent="cause")),
]),
encoding="utf-8",
)
rows = aggregate_gaps(tmp_path)
assert len(rows) == 1
assert rows[0].subject == "parent"
assert rows[0].count == 1
def test_non_monthly_filenames_ignored(tmp_path: Path) -> None:
bad = tmp_path / "2026" / "notes.jsonl"
good = tmp_path / "2026" / "2026-05.jsonl"
_write_line(bad, _candidate(candidate_id="bad", subject="parent", intent="cause"))
_write_line(good, _candidate(candidate_id="good", subject="parent", intent="cause"))
rows = aggregate_gaps(tmp_path)
assert len(rows) == 1
assert rows[0].count == 1
assert rows[0].sample_candidate_ids == ("good",)
def test_aggregation_is_deterministic(tmp_path: Path) -> None:
sink = tmp_path / "2026" / "2026-05.jsonl"
for s in ("parent", "child", "ancestor"):
_write_line(sink, _candidate(candidate_id=f"id-{s}", subject=s, intent="cause"))
a = aggregate_gaps(tmp_path)
b = aggregate_gaps(tmp_path)
assert a == b
assert [g.as_dict() for g in a] == [g.as_dict() for g in b]
def test_sample_limit_caps_retained_ids(tmp_path: Path) -> None:
sink = tmp_path / "2026" / "2026-05.jsonl"
for i in range(10):
_write_line(sink, _candidate(candidate_id=f"id-{i:02d}", subject="parent", intent="cause"))
rows = aggregate_gaps(tmp_path, sample_limit=3)
assert rows[0].count == 10
assert len(rows[0].sample_candidate_ids) == 3
def test_gap_dataclass_is_frozen() -> None:
gap = Gap(
subject="parent", intent="cause", count=1,
boundary_clean_count=1, sample_candidate_ids=("a",), months_seen=("2026-05",),
)
with pytest.raises((AttributeError, TypeError)):
gap.count = 99 # type: ignore[misc]