Files
theclaymethod__unslop/scripts/harvest_classify.py
Clayton Kim d6f632117f Consolidate eval/scanner internals: shared modules, in-process calls, faster gates
Applies a reviewed cleanup list without changing any script's CLI flags, JSON
keys, exit codes, or stderr messages (all pinned by eval rows).

Shared modules (dedup):
- scripts/_lang.py: ENGLISH_FUNCTION_WORDS/english_function_share/is_probably_english,
  previously byte-identical copies in banned_phrase_scan.py and structure_scan.py.
- structure_scan.py now imports split_sentences from readability_metrics instead
  of keeping its own copy.
- silhouette_scan.py's stopword set is a verified pure superset of
  structure_scan's; SILHOUETTE_STOPWORDS = structure's set | the extras.
- harvest_classify.py imports recency_value/DATE_FLOOR from harvest_samples.
- evals/_check_support.py: ROOT, run(cmd, timeout=60), load_evals() -- the
  timeout=60 safety net that only check_contrib.py had now covers check_pairs,
  check_seeded_docs, check_mimic, check_contrib, check_voice, and
  check_pattern_coverage too.

Small cleanups: contribute.py's row_fn drops its unused category param;
run_model_parity.py's resolve_models param renamed responses->payload;
harvest_classify.py's heuristic() attaches suspect_ai/dictated so
rank_enriched needs no reconstruction; check_pattern_coverage.py's paired flag
formulas become a plain "if neither: both = True"; check_contrib.py drops the
__import__("scripts.contribute", ...) spelling for a normal import;
run_mimic_refine.py computes docs_a/matrix_a once and passes it to both
make_live_source and write_outputs; calibrate_pairs.py factors its four
near-identical contraction-replacement closures into one _contraction_repl
helper used by both directions.

Altitude items: GENRE_SUPPRESSIONS lookup tables replace the inline
`genre != "..."` conditionals in structure_scan.py and silhouette_scan.py;
check_gates_doc.py additively verifies every *.py token in a gate command
exists under ROOT (behavioral-tune and rubric-judge are exempt -- neither
command has a .py token).

Efficiency (Phase 2), each verified against the same eval rows / diffed
outputs before landing:
- run_model_parity.py replaces its subprocess-per-scanner-call helpers with
  in-process imports of banned_phrase_scan/structure_scan/validate_preservation
  (mirrors run_mimic_refine's import pattern). PARITY slice: 14.2s -> 0.33s.
- voice_score.py's lcs_len (O(n*m) DP) is replaced by
  has_common_substring_over(), an O(n+m) rolling-hash check for "any shared
  substring longer than the 120-char threshold" (hash matches are verified
  against the source text, so no false positives). Nothing pins the exact
  longest_common_substring value (checked); it now reports the matched
  threshold window length on a hit, 0 otherwise -- documented in the
  docstring. The violation boolean is unchanged.
- gi_score() precomputes per-key distances once per candidate/impostor
  instead of recomputing distances() from scratch every trial; trials do a
  subset-weighted sum over the precomputed values. Arithmetically exact
  (same RNG draw order, same float sums) -- verified the VOICE-08
  determinism value and the full check_voice --separation/--gi/--gaming
  output are byte-identical before/after.
- check_voice.py and check_pairs.py convert their subprocess-per-cell/row
  scanner calls to in-process imports (voice_score/voice_profile,
  banned_phrase_scan/structure_scan), mirroring the CLI's own decline/exit
  logic so output stays byte-compatible.

Deferred (out of scope for a contract-safe pass): a protects-grain redesign,
giving silhouette_scan.py its own English-decline gate, and decomposing
run_mimic_refine.py's build_report().

python3 evals/run_adversarial.py: 434 PASS / 1 XFAIL / 0 FAIL, unchanged
throughout; wall time 54.8s -> 25.3s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CYksdLbXbTAxcAQjvHz5
2026-07-06 16:34:29 -07:00

224 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Classify harvested candidates into situation/register coverage cells."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from harvest_samples import DATE_FLOOR, recency_value # noqa: E402
CELLS = [
"numbers_data",
"question_addressed",
"anecdote_markers",
"disagreement",
"openings_closings",
]
def load_candidates(path: Path) -> list[dict[str, Any]]:
data = json.loads(path.read_text())
if isinstance(data, dict):
return data.get("candidates", [])
if isinstance(data, list):
return data
raise ValueError("candidates file must contain a list or {candidates: [...]}")
def cells_for(text: str) -> list[str]:
lowered = text.lower()
cells = []
if re.search(r"\b\d+(?:[,.]\d+)?%?\b", lowered):
cells.append("numbers_data")
if "?" in text or re.search(r"\b(?:why|how|what|when|where|which)\b", lowered):
cells.append("question_addressed")
if re.search(r"\b(?:last quarter|yesterday|once|during|when we|i noticed|i remember)\b", lowered):
cells.append("anecdote_markers")
if re.search(r"\b(?:disagree|however|instead|not convinced|push back)\b", lowered):
cells.append("disagreement")
if re.search(r"\b(?:hi|thanks|best|regards|closing|opening|first off)\b", lowered):
cells.append("openings_closings")
return cells
def quality_for(candidate: dict[str, Any], cells: list[str]) -> int:
words = int(candidate.get("words") or len(re.findall(r"\w+", candidate.get("text", ""))))
score = 3
if 40 <= words <= 220:
score += 1
if cells:
score += 1
if candidate.get("suspect_ai"):
score -= 2
if candidate.get("dictated"):
score -= 1
return max(1, min(5, score))
def candidate_id(candidate: dict[str, Any], index: int) -> Any:
return candidate.get("id", index)
def source_position(candidate: dict[str, Any]) -> str:
source = candidate.get("source", {})
return str(source.get("line", source.get("offset", "")))
def coverage_from(candidates: list[dict[str, Any]]) -> dict[str, int]:
coverage = {cell: 0 for cell in CELLS}
for candidate in candidates:
for cell in candidate.get("cells", []):
coverage[cell] = coverage.get(cell, 0) + 1
return coverage
def rank_enriched(candidates: list[dict[str, Any]]) -> list[int]:
seen_empty = set()
rank_rows = []
for idx, candidate in enumerate(candidates):
cells = candidate.get("cells", [])
fills_empty = any(cell not in seen_empty for cell in cells)
seen_empty.update(cells)
rank_rows.append((idx, fills_empty))
ranked = sorted(
rank_rows,
key=lambda row: (
not row[1],
-int(candidates[row[0]].get("quality") or 0),
-recency_value(candidates[row[0]]),
bool(candidates[row[0]].get("suspect_ai")),
bool(candidates[row[0]].get("dictated")),
str(candidates[row[0]].get("source", {}).get("path", "")),
source_position(candidates[row[0]]),
row[0],
),
)
return [candidate_id(candidates[idx], idx) for idx, _ in ranked]
def heuristic(candidates: list[dict[str, Any]]) -> dict[str, Any]:
enriched = []
seen_empty = set()
for idx, candidate in enumerate(candidates):
cells = cells_for(candidate.get("text", ""))
quality = quality_for(candidate, cells)
fills_empty = any(cell not in seen_empty for cell in cells)
seen_empty.update(cells)
enriched.append({
"index": idx,
"id": candidate.get("id", idx),
"cells": cells,
"quality": quality,
"why": "lexical heuristic matched " + (", ".join(cells) if cells else "no named cell"),
"fills_empty_coverage_cell": fills_empty,
"source": candidate.get("source", {}),
"suspect_ai": candidate.get("suspect_ai"),
"dictated": candidate.get("dictated"),
})
return {
"coverage_matrix": coverage_from(enriched),
"candidates": enriched,
"ranking": rank_enriched(enriched),
}
def write_agent_tasks(candidates: list[dict[str, Any]], out_dir: Path) -> dict[str, Any]:
out_dir.mkdir(parents=True, exist_ok=True)
chunks = []
prompt = (
"Classify each candidate into WP10b situation/register cells. "
"Return JSONL rows with candidate_index, cells, quality 1-5, and one-line why. "
"Cells include numbers_data, question_addressed, anecdote_markers, "
"disagreement, openings_closings, plus any clearly justified additional cell."
)
for start in range(0, len(candidates), 10):
chunk = candidates[start:start + 10]
path = out_dir / f"harvest-classify-{start // 10 + 1:03d}.json"
payload = {
"contract": "tier-1-pack-detector",
"prompt": prompt,
"candidates": [
{"candidate_index": start + i, "text": c.get("text", ""), "source": c.get("source", {})}
for i, c in enumerate(chunk)
],
}
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
chunks.append(str(path))
return {"task_files": chunks, "chunk_size": 10}
def result_candidate_key(row: dict[str, Any]) -> Any:
for key in ("candidate_id", "id", "candidate_index", "index"):
if key in row:
return row[key]
raise ValueError("result row missing candidate id")
def merge_results(candidates_path: Path, results_path: Path) -> dict[str, Any]:
candidates = load_candidates(candidates_path)
rows_by_id: dict[Any, dict[str, Any]] = {}
for line in results_path.read_text().splitlines():
if line.strip():
row = json.loads(line)
rows_by_id[result_candidate_key(row)] = row
merged = []
for idx, candidate in enumerate(candidates):
cid = candidate_id(candidate, idx)
row = rows_by_id.get(cid)
if row is None and idx in rows_by_id:
row = rows_by_id[idx]
cells = list(row.get("cells", [])) if row else []
quality = int(row.get("quality")) if row and row.get("quality") is not None else quality_for(candidate, cells)
why = str(row.get("why", "no classifier result")) if row else "no classifier result"
merged.append({
**candidate,
"id": cid,
"cells": cells,
"quality": max(1, min(5, quality)),
"why": why,
})
return {
"coverage_matrix": coverage_from(merged),
"candidates": merged,
"ranking": rank_enriched(merged),
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--candidates", required=True)
parser.add_argument("--mode", choices=["heuristic", "agent"], default="heuristic")
parser.add_argument("--out-dir", default="harvest-agent-tasks")
parser.add_argument("--merge")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
if args.merge:
print(json.dumps(merge_results(Path(args.candidates), Path(args.merge)), indent=2, sort_keys=True))
return 0
candidates = load_candidates(Path(args.candidates))
if args.mode == "heuristic":
print(json.dumps(heuristic(candidates), indent=2, sort_keys=True))
else:
print(json.dumps(write_agent_tasks(candidates, Path(args.out_dir)), indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
import sys
raise SystemExit(main(sys.argv[1:]))