core/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

206 lines
7.5 KiB
Python

"""teaching/gaps.py — Phase 1.1: aggregate emitted DiscoveryCandidates
into a ranked view of (subject, intent) cells the runtime would have
grounded if the corpus had a chain there.
ADR-0055 Phase B emits ``DiscoveryCandidate`` JSONL lines to an
attached :class:`teaching.discovery_sink.DiscoveryCandidateSink`.
:class:`DiscoveryMonthlyFileSink` persists them under
``<root>/<YYYY>/<YYYY-MM>.jsonl``. That stream answers "which
prompts couldn't ground today" — but it's append-only and operators
don't grep raw JSONL.
This module is the **reader side** of the flywheel: it walks the
sink's persisted output and groups candidates by ``(subject, intent)``
cell so operators can see at a glance which cells are most-asked
without resorting to ad-hoc shell pipelines.
Design constraints (matching CLAUDE.md doctrine):
- Pure reader. No mutation of any sink file. Aggregation is
derived state; the sink remains the source of truth.
- Deterministic ordering: cells are sorted by (count desc, subject,
intent) so the same input always produces the same view.
- Date filtering operates on the sink's file naming convention
(``<YYYY>/<YYYY-MM>.jsonl``) — month-level granularity, no
timestamp dependency.
- Malformed lines are skipped silently; the aggregator never raises
on a single bad line. The point is a useful summary, not a
schema validator (use ``teaching audit`` for that).
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
_DEFAULT_ROOT: Path = Path(__file__).resolve().parent / "discovery_log"
# Sink file naming convention: ``<root>/<YYYY>/<YYYY-MM>.jsonl``.
# Regex anchors to filename only — full path components are not
# matched so the same regex applies to nested-or-flat layouts that
# alternative sinks might use.
_MONTH_FILE_RE = re.compile(r"^(\d{4})-(\d{2})\.jsonl$")
_MONTH_TOKEN_RE = re.compile(r"^(\d{4})-(\d{2})$")
@dataclass(frozen=True, slots=True)
class Gap:
"""One aggregated ``(subject, intent)`` cell.
Fields:
- ``subject`` / ``intent``: the cell identity (lower-case).
- ``count``: total number of candidate emissions whose proposed
chain referenced this cell, across all aggregated months.
- ``boundary_clean_count``: subset of ``count`` whose
``boundary_clean`` flag was True (refusal/hedge-tainted
candidates are still counted toward ``count`` but split out
here so operators can filter).
- ``sample_candidate_ids``: up to 5 candidate_ids contributing
to this cell, sorted for determinism useful for spot-checks.
- ``months_seen``: sorted ``YYYY-MM`` months where this cell
appeared at least once.
"""
subject: str
intent: str
count: int
boundary_clean_count: int
sample_candidate_ids: tuple[str, ...]
months_seen: tuple[str, ...]
def as_dict(self) -> dict[str, object]:
return {
"subject": self.subject,
"intent": self.intent,
"count": self.count,
"boundary_clean_count": self.boundary_clean_count,
"sample_candidate_ids": list(self.sample_candidate_ids),
"months_seen": list(self.months_seen),
}
def _normalise_since(since: str | None) -> tuple[int, int] | None:
"""Return ``(year, month)`` for *since*, or None if absent.
Raises :class:`ValueError` on a malformed token (caller surfaces
a friendly CLI error).
"""
if since is None:
return None
match = _MONTH_TOKEN_RE.match(since.strip())
if not match:
raise ValueError(
f"--since {since!r} is not a YYYY-MM token (e.g. '2026-05')"
)
return int(match.group(1)), int(match.group(2))
def _iter_candidate_files(
root: Path, *, since: tuple[int, int] | None
) -> Iterable[tuple[str, Path]]:
"""Yield ``(month_token, path)`` for every JSONL file under *root*
whose filename matches the monthly-sink convention.
Files outside the ``YYYY-MM.jsonl`` convention are skipped the
sink can grow alternative names later without breaking the
aggregator's behavior on the canonical layout.
"""
if not root.exists() or not root.is_dir():
return
for path in sorted(root.rglob("*.jsonl")):
m = _MONTH_FILE_RE.match(path.name)
if not m:
continue
year = int(m.group(1))
month = int(m.group(2))
if since is not None and (year, month) < since:
continue
yield f"{year:04d}-{month:02d}", path
def aggregate_gaps(
root: Path = _DEFAULT_ROOT,
*,
since: str | None = None,
sample_limit: int = 5,
) -> tuple[Gap, ...]:
"""Aggregate every emitted ``DiscoveryCandidate`` under *root* into
a ranked tuple of :class:`Gap` records.
``since`` accepts a ``YYYY-MM`` token. When supplied, only
candidate files whose monthly token is ``>= since`` are read.
The returned tuple is sorted by ``(count desc, subject asc,
intent asc)`` so identical inputs produce identical orderings
important for deterministic CLI output that operators can diff
across invocations.
"""
since_tuple = _normalise_since(since)
counts: dict[tuple[str, str], int] = {}
clean_counts: dict[tuple[str, str], int] = {}
samples: dict[tuple[str, str], list[str]] = {}
months: dict[tuple[str, str], set[str]] = {}
for month_token, path in _iter_candidate_files(root, since=since_tuple):
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
chain = entry.get("proposed_chain")
if not isinstance(chain, dict):
continue
subject = chain.get("subject")
intent = chain.get("intent")
if not isinstance(subject, str) or not isinstance(intent, str):
continue
subject = subject.strip().lower()
intent = intent.strip().lower()
if not subject or not intent:
continue
key = (subject, intent)
counts[key] = counts.get(key, 0) + 1
if entry.get("boundary_clean") is True:
clean_counts[key] = clean_counts.get(key, 0) + 1
sample_list = samples.setdefault(key, [])
candidate_id = entry.get("candidate_id")
if (
isinstance(candidate_id, str)
and candidate_id
and len(sample_list) < sample_limit
and candidate_id not in sample_list
):
sample_list.append(candidate_id)
months.setdefault(key, set()).add(month_token)
rows: list[Gap] = []
for key, total in counts.items():
subject, intent = key
rows.append(
Gap(
subject=subject,
intent=intent,
count=total,
boundary_clean_count=clean_counts.get(key, 0),
sample_candidate_ids=tuple(sorted(samples.get(key, ()))),
months_seen=tuple(sorted(months.get(key, ()))),
)
)
rows.sort(key=lambda g: (-g.count, g.subject, g.intent))
return tuple(rows)
__all__ = ["Gap", "aggregate_gaps"]