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>
203 lines
7.3 KiB
Python
203 lines
7.3 KiB
Python
"""
|
||
QLoRA fine-tuning script for the kushya generator.
|
||
|
||
Architecture §3.6.
|
||
|
||
Requires GPU and the heavy ML stack:
|
||
pip install torch transformers peft bitsandbytes datasets accelerate trl pyyaml
|
||
|
||
Run:
|
||
python train.py --config config.yaml
|
||
|
||
This script is the operator's task — it requires GPU compute we don't have
|
||
in the build environment. The script is complete and self-contained; when
|
||
the operator has the hardware, it runs.
|
||
|
||
The output is a LoRA adapter (small file, ~100MB) that wraps the base model.
|
||
Quantize and serve via inference/serve.py for the Talmudic Engine to call.
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# Defer heavy imports until execution so the file can be parsed and
|
||
# linted in environments without torch/transformers installed.
|
||
|
||
|
||
def load_config(path: Path) -> dict:
|
||
import yaml
|
||
with open(path) as f:
|
||
return yaml.safe_load(f)
|
||
|
||
|
||
def load_jsonl(path: Path) -> list[dict]:
|
||
records = []
|
||
with open(path) as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line:
|
||
records.append(json.loads(line))
|
||
return records
|
||
|
||
|
||
def format_records(records: list[dict], template: str) -> list[str]:
|
||
return [
|
||
template.format(input=r["input"], output=r["output"])
|
||
for r in records
|
||
]
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="QLoRA fine-tune the kushya generator")
|
||
parser.add_argument("--config", type=Path, default=Path(__file__).parent / "config.yaml")
|
||
args = parser.parse_args()
|
||
|
||
if not args.config.exists():
|
||
print(f"ERROR: config not found at {args.config}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
config = load_config(args.config)
|
||
|
||
print("=" * 60)
|
||
print("KUSHYA GENERATOR — QLORA FINE-TUNING")
|
||
print("=" * 60)
|
||
print(f"Base model: {config['base_model']['name']}")
|
||
print(f"LoRA r/α: {config['lora']['r']} / {config['lora']['alpha']}")
|
||
print(f"Epochs: {config['training']['num_train_epochs']}")
|
||
print(f"LR: {config['training']['learning_rate']}")
|
||
print()
|
||
|
||
# Resolve data paths relative to the config file.
|
||
base_dir = args.config.parent
|
||
train_path = (base_dir / config["data"]["train_file"]).resolve()
|
||
eval_path = (base_dir / config["data"]["eval_file"]).resolve()
|
||
|
||
if not train_path.exists():
|
||
print(f"ERROR: train file not found at {train_path}", file=sys.stderr)
|
||
print(f"Run: python ../data/prepare_training.py", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
train_records = load_jsonl(train_path)
|
||
eval_records = load_jsonl(eval_path) if eval_path.exists() else []
|
||
|
||
print(f"Train records: {len(train_records)}")
|
||
print(f"Eval records: {len(eval_records)}")
|
||
|
||
if len(train_records) < 100:
|
||
print()
|
||
print(f"WARNING: only {len(train_records)} training records.")
|
||
print("Architecture §3.6 expects training_pairs/ to contain extracted")
|
||
print("kushyot from Bavli, Yerushalmi, and Rishonim. The seed pairs")
|
||
print("are sufficient to validate the training pipeline but not to")
|
||
print("produce a useful kushya generator. Curate more pairs first.")
|
||
print()
|
||
|
||
template = config["data"]["prompt_template"]
|
||
train_texts = format_records(train_records, template)
|
||
eval_texts = format_records(eval_records, template) if eval_records else []
|
||
|
||
# Heavy imports only at execution time.
|
||
try:
|
||
import torch
|
||
from datasets import Dataset
|
||
from transformers import (
|
||
AutoModelForCausalLM,
|
||
AutoTokenizer,
|
||
BitsAndBytesConfig,
|
||
TrainingArguments,
|
||
)
|
||
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
|
||
from trl import SFTTrainer
|
||
except ImportError as e:
|
||
print(f"ERROR: required ML library not installed: {e}", file=sys.stderr)
|
||
print()
|
||
print("Install with:")
|
||
print(" pip install torch transformers peft bitsandbytes datasets accelerate trl pyyaml")
|
||
sys.exit(1)
|
||
|
||
bnb_config = BitsAndBytesConfig(
|
||
load_in_4bit=True,
|
||
bnb_4bit_compute_dtype=getattr(torch, config["base_model"]["bnb_4bit_compute_dtype"]),
|
||
bnb_4bit_quant_type=config["base_model"]["bnb_4bit_quant_type"],
|
||
bnb_4bit_use_double_quant=config["base_model"]["bnb_4bit_use_double_quant"],
|
||
)
|
||
|
||
print(f"Loading base model {config['base_model']['name']}...")
|
||
tokenizer = AutoTokenizer.from_pretrained(
|
||
config["base_model"]["name"],
|
||
trust_remote_code=config["base_model"]["trust_remote_code"],
|
||
)
|
||
if tokenizer.pad_token is None:
|
||
tokenizer.pad_token = tokenizer.eos_token
|
||
|
||
model = AutoModelForCausalLM.from_pretrained(
|
||
config["base_model"]["name"],
|
||
quantization_config=bnb_config,
|
||
device_map="auto",
|
||
trust_remote_code=config["base_model"]["trust_remote_code"],
|
||
)
|
||
model = prepare_model_for_kbit_training(model)
|
||
|
||
lora_config = LoraConfig(
|
||
r=config["lora"]["r"],
|
||
lora_alpha=config["lora"]["alpha"],
|
||
lora_dropout=config["lora"]["dropout"],
|
||
bias=config["lora"]["bias"],
|
||
task_type=config["lora"]["task_type"],
|
||
target_modules=config["lora"]["target_modules"],
|
||
)
|
||
model = get_peft_model(model, lora_config)
|
||
model.print_trainable_parameters()
|
||
|
||
train_ds = Dataset.from_dict({"text": train_texts})
|
||
eval_ds = Dataset.from_dict({"text": eval_texts}) if eval_texts else None
|
||
|
||
training_args = TrainingArguments(
|
||
output_dir=config["training"]["output_dir"],
|
||
num_train_epochs=config["training"]["num_train_epochs"],
|
||
per_device_train_batch_size=config["training"]["per_device_train_batch_size"],
|
||
per_device_eval_batch_size=config["training"]["per_device_eval_batch_size"],
|
||
gradient_accumulation_steps=config["training"]["gradient_accumulation_steps"],
|
||
gradient_checkpointing=config["training"]["gradient_checkpointing"],
|
||
optim=config["training"]["optim"],
|
||
learning_rate=config["training"]["learning_rate"],
|
||
lr_scheduler_type=config["training"]["lr_scheduler_type"],
|
||
warmup_ratio=config["training"]["warmup_ratio"],
|
||
weight_decay=config["training"]["weight_decay"],
|
||
max_grad_norm=config["training"]["max_grad_norm"],
|
||
fp16=config["training"]["fp16"],
|
||
bf16=config["training"]["bf16"],
|
||
logging_steps=config["training"]["logging_steps"],
|
||
eval_strategy=config["training"]["evaluation_strategy"] if eval_ds else "no",
|
||
eval_steps=config["training"]["eval_steps"] if eval_ds else None,
|
||
save_strategy=config["training"]["save_strategy"],
|
||
save_total_limit=config["training"]["save_total_limit"],
|
||
group_by_length=config["training"]["group_by_length"],
|
||
report_to=config["training"]["report_to"],
|
||
seed=config["training"]["seed"],
|
||
)
|
||
|
||
trainer = SFTTrainer(
|
||
model=model,
|
||
args=training_args,
|
||
train_dataset=train_ds,
|
||
eval_dataset=eval_ds,
|
||
tokenizer=tokenizer,
|
||
max_seq_length=config["data"]["max_seq_length"],
|
||
dataset_text_field="text",
|
||
)
|
||
|
||
trainer.train()
|
||
trainer.save_model(config["training"]["output_dir"])
|
||
print()
|
||
print(f"Training complete. Adapter saved to {config['training']['output_dir']}")
|
||
print()
|
||
print("Next: quantize to GGUF and serve via inference/serve.py")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|