Files
bikoret/kushya_generator/data/prepare_training.py
T
Scott KarpandClaude Opus 4.6 e223ab15b3 Phase 4 complete: Steps 21, 22, 24 — Korban external integration, L2 Urim v'Tumim, Kushya generator
Vayikra 9:24 — vatetzei esh milifnei Hashem vatokhal al hamizbeach.
Phase 4 of the architecture is now complete. The remaining three steps
that were marked "blocked on external infrastructure" are built —
discipline-respecting, eish-zarah-aware, and tested.

## Step 22 — Korban Processor external model integration
   (korban_processor/src/korban_processor/external_offering.py)

   The single legitimate entry point for external model consultation.
   Wraps Claude API calls through the offering protocol so external
   output is treated as korban (raw material), never authoritative:

   - semichah  — provenance is tagged (avodah zarah Form 1 prevention)
   - shechitah — assistant voice, hedging, formatting stripped
   - zerikah   — vital content extracted
   - haktarah  — converted to Prolog propositions
   - route     — sent to internal component per offering type

   The raw Korban is preserved on the ProcessedKorban (gilui arayot
   Form 3 prevention — input/output coupling never severed).
   No internal component should call call_claude() directly.

   11 tests, mocked external calls. Live API test opt-in via
   SHORASHIM_LIVE_API=1.

## Step 24 — L2 Urim v'Tumim interface
   (vav_pipeline/lib/vav_pipeline/urim_v_tumim.ex)

   PRD §4.7: housed in the kohen gadol's breastplate; consulted only
   on high-stakes decisions where normal deliberation is insufficient.
   Bamidbar 27:21 — al-piv yetz'u v'al-piv yavo'u.

   Three escalation steps, three honest outcomes:
     1. resolved_by_specification — re-read produced an answer
     2. resolved_by_external — external testimony recorded (NOT binding)
     3. deferred_to_human — the system stops, asks for human judgment

   Pre-condition gating refuses cases that didn't reach Sanhedrin teiku
   first (no preempting deliberation) or that aren't flagged high-stakes
   (ordinary teiku stays teiku). Records every consultation with steps
   taken — there is no quiet escalation.

   Per PRD: "The Urim v'Tumim never fabricates certainty where none
   exists." When external also fails, the system stops. It does not
   proceed on a guess.

   Added to VavPipeline supervision tree. 14 tests.

## Step 21 — Kushya generator scaffolding
   (kushya_generator/)

   Directory layout per Architecture §3.6 with three layers:

   - data/training_pairs/seed_pairs.jsonl — 10 hand-curated bootstrap
     pairs covering 9 kushya types (scope_narrowing, internal_tension,
     harmonization, exception_test, edge_case, scope_application,
     rabbinic_constraint, method_question, subject_identification)
   - data/prepare_training.py — validates the four kushya requirements
     (specific Torah passage, identified weakness, question form,
     non-trivial), produces train.jsonl + eval.jsonl with stratified
     80/20 split by type
   - fine_tune/config.yaml + train.py — QLoRA configuration and
     training script. Operator's task — needs GPU. Script is
     complete and self-contained.
   - inference/kushya_api.py — three-backend interface
     (fine_tuned → external → fallback). When fine-tuned model isn't
     deployed, falls back to external via Korban Processor (Step 22).
     The fallback is honest: returns no kushya rather than inventing
     one. The Talmudic Engine's rule-based challenge.pl remains the
     primary kushya source.

   17 tests covering dataset validation, backend selection, and
   reference extraction.

## .gitignore fix

   The earlier "data/" line was too broad — it caught
   kushya_generator/data/. Scoped to /data/ (root-level only).

## Test counts

   - Elixir:           274 (was 260; +14 for UrimVTumim)
   - Korban Python:    196 + 1 skipped (was 195; +1 module = 11 tests)
   - Kushya Python:     17 (new)
   - Haskell:           33 (unchanged)
   - Prolog:           195 (unchanged)
   - Total:            715 + 19 L3 excluded

## Phase 4 status

   All 12 steps now have implementations:
   - Steps 23, 25, 26, 27, 28, 29, 30, 31, 32: complete
   - Steps 21, 22, 24: complete with discipline preserved

   Step 21's fine-tuning run remains the operator's GPU task; the
   pipeline is built such that a deployed adapter slots into the
   inference backend without touching the Talmudic Engine or anything
   above.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 17:12:03 -04:00

191 lines
6.3 KiB
Python

"""
Prepare training data for the kushya generator.
Reads .jsonl files from training_pairs/, validates each record against the
four kushya requirements (Architecture §3.6), and writes:
train.jsonl — 80%, used for QLoRA training
eval.jsonl — 20%, held out for evaluation
Validation rejects pairs that don't meet the requirements rather than
silently degrading training quality.
"""
import json
import os
import re
import sys
from pathlib import Path
from typing import Iterable
KUSHYA_DIR = Path(__file__).resolve().parent / "training_pairs"
OUTPUT_DIR = Path(__file__).resolve().parent
# The four requirements from Architecture §3.6 fine_tune_objective.
REQUIRED_TYPES = {
"scope_narrowing",
"scope_application",
"internal_tension",
"exception_test",
"harmonization",
"edge_case",
"rabbinic_constraint",
"method_question",
"subject_identification",
}
class ValidationError(Exception):
pass
def validate_pair(pair: dict, source: str, line_no: int) -> None:
"""Apply the four kushya requirements + schema check."""
where = f"{source}:{line_no}"
if not isinstance(pair, dict):
raise ValidationError(f"{where}: not a JSON object")
for field in ("interpretation", "kushya", "torah_refs", "type"):
if field not in pair:
raise ValidationError(f"{where}: missing field {field!r}")
if not isinstance(pair["interpretation"], str) or not pair["interpretation"].strip():
raise ValidationError(f"{where}: interpretation must be a non-empty string")
if not isinstance(pair["kushya"], str) or not pair["kushya"].strip():
raise ValidationError(f"{where}: kushya must be a non-empty string")
if not isinstance(pair["torah_refs"], list):
raise ValidationError(f"{where}: torah_refs must be a list")
if pair["type"] not in REQUIRED_TYPES:
raise ValidationError(
f"{where}: type {pair['type']!r} not in known types {sorted(REQUIRED_TYPES)}"
)
# Requirement 3: kushya must be expressible as a question.
# Strict: must contain '?'. Interrogative words alone are not enough
# — "this is unclear" contains "is" but isn't a question.
if "?" not in pair["kushya"]:
raise ValidationError(
f"{where}: kushya must be expressible as a question (must contain '?') — "
f"got {pair['kushya'][:60]!r}"
)
# Requirement 1: must reference a specific Torah passage. Either in
# torah_refs (preferred) or in the kushya text itself.
torah_book_pattern = (
r"\b(Bereishit|Shemot|Vayikra|Bamidbar|Devarim|Mishnah|Gemara|Mishlei|"
r"Tehillim|Yeshayahu|Yechezkel|Yirmiyahu|Sanhedrin|Eruvin|Yoma|Berakhot)\b"
)
if not pair["torah_refs"] and not re.search(torah_book_pattern, pair["kushya"]):
raise ValidationError(
f"{where}: kushya must cite a specific Torah passage "
f"(empty torah_refs and no book name in text)"
)
# Requirement 4: not resolvable by trivial clarification.
# Heuristic: kushya should be at least 40 characters of substantive text.
if len(pair["kushya"].strip()) < 40:
raise ValidationError(
f"{where}: kushya is too short to be a substantive challenge "
f"({len(pair['kushya'].strip())} chars)"
)
def load_jsonl(path: Path) -> Iterable[tuple[int, dict]]:
with open(path) as f:
for i, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
yield i, json.loads(line)
except json.JSONDecodeError as e:
raise ValidationError(f"{path.name}:{i}: invalid JSON — {e}")
def to_training_format(pair: dict) -> dict:
"""Format a validated pair for fine-tuning.
Architecture §3.6 training_data_format:
- input: "Proposed interpretation: [text]"
- output: "Kushya: [challenge question grounded in Torah text]"
"""
refs_str = ", ".join(pair["torah_refs"]) if pair["torah_refs"] else "(no explicit refs)"
return {
"input": f"Proposed interpretation: {pair['interpretation']}",
"output": f"Kushya: {pair['kushya']}",
"metadata": {
"type": pair["type"],
"torah_refs": pair["torah_refs"],
"refs_str": refs_str,
},
}
def split_train_eval(records: list[dict], train_fraction: float = 0.8) -> tuple[list[dict], list[dict]]:
"""Stratified split by type."""
by_type: dict[str, list[dict]] = {}
for r in records:
by_type.setdefault(r["metadata"]["type"], []).append(r)
train: list[dict] = []
evaln: list[dict] = []
for typ, items in by_type.items():
cut = max(1, int(len(items) * train_fraction)) if len(items) > 1 else len(items)
train.extend(items[:cut])
evaln.extend(items[cut:])
return train, evaln
def main() -> int:
if not KUSHYA_DIR.exists():
print(f"ERROR: {KUSHYA_DIR} does not exist", file=sys.stderr)
return 1
all_pairs: list[dict] = []
errors: list[str] = []
for jsonl_file in sorted(KUSHYA_DIR.glob("*.jsonl")):
for line_no, pair in load_jsonl(jsonl_file):
try:
validate_pair(pair, jsonl_file.name, line_no)
all_pairs.append(to_training_format(pair))
except ValidationError as e:
errors.append(str(e))
if errors:
print("Validation errors — pairs rejected:", file=sys.stderr)
for e in errors:
print(f" {e}", file=sys.stderr)
print(file=sys.stderr)
if not all_pairs:
print("No valid pairs found. Cannot prepare training set.", file=sys.stderr)
return 1
train, evaln = split_train_eval(all_pairs)
train_path = OUTPUT_DIR / "train.jsonl"
eval_path = OUTPUT_DIR / "eval.jsonl"
with open(train_path, "w") as f:
for r in train:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
with open(eval_path, "w") as f:
for r in evaln:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"Validated: {len(all_pairs)} pairs")
print(f"Rejected: {len(errors)}")
print(f"Train: {len(train)} → {train_path}")
print(f"Eval: {len(evaln)} → {eval_path}")
return 0 if not errors else 1
if __name__ == "__main__":
sys.exit(main())