build_dpo_dataset

Build marola's DPO preference dataset (MIP-0025 §4.3, Layer 3) from core/llm/Reviewer.scala's own historical reject/revise decisions.

Real historical decisions are already captured, once, as the DSPy-compiled review_prompt.json demos (core/src/main/resources/review_prompt.json) — bootstrap examples where a real Ollama reviewer graded a real draft summary. Wherever a demo's verdict wasn't "approve", the reviewer's own final_summary is a real correction of a real flawed draft: that is exactly a (chosen, rejected) preference pair, with no invented text on either side. An "approve" demo carries no preference signal (the draft was fine as-is) and yields no pair — one pair per reject/revise decision, never more, never fabricated when there isn't one.

Run: python build_dpo_dataset.py (or just finetune-dpo-dataset) Self-test: python build_dpo_dataset.py --self-test (or just quality-other)

review_prompt.json comes from the app's resources tarball (MIP-0070 §5.4), unpacked into .tmp/resources by just resources-fetch; --resources DIR overrides it, as in build_dataset.py.

  1"""Build marola's DPO preference dataset (MIP-0025 §4.3, Layer 3) from `core/llm/Reviewer.scala`'s
  2own historical reject/revise decisions.
  3
  4Real historical decisions are already captured, once, as the DSPy-compiled `review_prompt.json`
  5demos (core/src/main/resources/review_prompt.json) — bootstrap examples where a real Ollama
  6reviewer graded a real draft summary. Wherever a demo's verdict wasn't "approve", the reviewer's
  7own `final_summary` is a real correction of a real flawed draft: that is exactly a (chosen,
  8rejected) preference pair, with no invented text on either side. An "approve" demo carries no
  9preference signal (the draft was fine as-is) and yields no pair — one pair per reject/revise
 10decision, never more, never fabricated when there isn't one.
 11
 12Run:  python build_dpo_dataset.py             (or `just finetune-dpo-dataset`)
 13Self-test:  python build_dpo_dataset.py --self-test   (or `just quality-other`)
 14
 15review_prompt.json comes from the app's resources tarball (MIP-0070 §5.4), unpacked into
 16`.tmp/resources` by `just resources-fetch`; `--resources DIR` overrides it, as in build_dataset.py.
 17"""
 18
 19from __future__ import annotations
 20
 21import argparse
 22import json
 23import sys
 24from pathlib import Path
 25
 26sys.path.insert(0, str(Path(__file__).resolve().parent))
 27import build_dataset as bd  # reuse INPUT_FIELDS/render_inputs — same conditions shape
 28
 29RESOURCES = bd.RESOURCES
 30OUT = Path(__file__).resolve().parent / "data"
 31
 32DPO_INPUT_FIELDS = bd.INPUT_FIELDS
 33
 34
 35def review_records(path: Path) -> list[dict]:
 36    """Real Reviewer decisions: one dict per review_prompt.json demo, in ReviewResult's own shape
 37    (Reviewer.scala's `ReviewResult(finalSummary, score, verdict)`) plus the draft it reviewed."""
 38    doc = json.loads(path.read_text(encoding="utf-8"))
 39    records = []
 40    for demo in doc.get("demos", []):
 41        if "review_json" not in demo or "summary" not in demo:
 42            continue
 43        review = json.loads(demo["review_json"])
 44        records.append(
 45            {
 46                "conditions": {k: demo[k] for k in DPO_INPUT_FIELDS if k in demo},
 47                "draft_summary": demo["summary"],
 48                "score": review.get("score"),
 49                "verdict": review.get("verdict", "approve"),
 50                "final_summary": review.get("final_summary", demo["summary"]),
 51            }
 52        )
 53    return records
 54
 55
 56def preference_pairs(records: list[dict]) -> list[dict]:
 57    """One (chosen, rejected) pair per real reject/revise record; none for "approve"."""
 58    pairs = []
 59    for r in records:
 60        if r["verdict"] == "approve":
 61            continue
 62        if r["final_summary"] == r["draft_summary"]:
 63            continue  # flagged but the text didn't actually change — no real preference signal
 64        prompt = bd.render_inputs(r["conditions"], DPO_INPUT_FIELDS)
 65        pairs.append(
 66            {"prompt": prompt, "chosen": r["final_summary"], "rejected": r["draft_summary"]}
 67        )
 68    return pairs
 69
 70
 71def main(resources: Path = RESOURCES) -> None:
 72    records = review_records(resources / "review_prompt.json")
 73    pairs = preference_pairs(records)
 74    OUT.mkdir(parents=True, exist_ok=True)
 75    with (OUT / "dpo_pairs.jsonl").open("w", encoding="utf-8") as f:
 76        for p in pairs:
 77            f.write(json.dumps(p, ensure_ascii=False) + "\n")
 78    print(
 79        f"wrote {len(pairs)} DPO preference pairs (from {len(records)} reviewer decisions) to {OUT}"
 80    )
 81
 82
 83# --- self-test: a small fixture of Reviewer decisions, not the real ones ---------------------
 84_FIXTURE_RECORDS = [
 85    {
 86        "conditions": {"beach_name": "Fixture Beach A", "score": "90"},
 87        "draft_summary": "Great conditions, go for it.",
 88        "score": 90,
 89        "verdict": "approve",
 90        "final_summary": "Great conditions, go for it.",
 91    },
 92    {
 93        "conditions": {"beach_name": "Fixture Beach B", "score": "40"},
 94        "draft_summary": "Nice and calm, a pleasant swim.",
 95        "score": 35,
 96        "verdict": "revise",
 97        "final_summary": "Calm but high jellyfish risk — worth a second look before swimming.",
 98    },
 99    {
100        "conditions": {"beach_name": "Fixture Beach C", "score": "5"},
101        "draft_summary": "Great day for a swim.",
102        "score": 5,
103        "verdict": "reject",
104        "final_summary": "Unsafe: strong riptide and rough seas — do not swim.",
105    },
106]
107
108
109def _self_test(resources: Path = RESOURCES) -> None:
110    # Fixture with reject/revise events: exactly one pair per non-approve event, nothing invented.
111    pairs = preference_pairs(_FIXTURE_RECORDS)
112    assert len(pairs) == 2, f"expected 2 pairs (one revise + one reject), got {len(pairs)}"
113    chosen_texts = {p["chosen"] for p in pairs}
114    assert _FIXTURE_RECORDS[1]["final_summary"] in chosen_texts
115    assert _FIXTURE_RECORDS[2]["final_summary"] in chosen_texts
116    for p in pairs:
117        assert p["chosen"] != p["rejected"], "chosen and rejected must differ"
118
119    # A fixture with only "approve" events: no reject/revise → zero pairs, none invented.
120    approve_only = [r for r in _FIXTURE_RECORDS if r["verdict"] == "approve"]
121    assert preference_pairs(approve_only) == [], "an all-approve fixture must yield zero pairs"
122
123    # Real source check: review_prompt.json's own demos really do have reject/revise decisions,
124    # and every generated pair's text matches one of them verbatim — no rewording, no invention.
125    real_records = review_records(resources / "review_prompt.json")
126    real_pairs = preference_pairs(real_records)
127    assert real_pairs, "no real reject/revise decisions found in review_prompt.json's demos"
128    real_texts = {
129        (r["final_summary"], r["draft_summary"])
130        for r in real_records
131        if r["verdict"] != "approve" and r["final_summary"] != r["draft_summary"]
132    }
133    generated_texts = {(p["chosen"], p["rejected"]) for p in real_pairs}
134    assert generated_texts <= real_texts, "a generated pair's text doesn't match a real decision"
135
136    print(
137        f"self-test OK: {len(pairs)} pairs from the fixture, {len(real_pairs)} from "
138        f"review_prompt.json's real reviewer decisions, all verbatim"
139    )
140
141
142def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
143    ap = argparse.ArgumentParser(
144        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
145    )
146    ap.add_argument(
147        "--resources",
148        type=Path,
149        default=RESOURCES,
150        help="dir with review_prompt.json (default: .tmp/resources, from `just resources-fetch`)",
151    )
152    ap.add_argument("--self-test", action="store_true")
153    return ap.parse_args(argv)
154
155
156if __name__ == "__main__":
157    args = parse_args()
158    if args.self_test:
159        _self_test(args.resources)
160    else:
161        main(args.resources)
RESOURCES = PosixPath('/home/runner/work/marola-ml/marola-ml/.tmp/resources')
OUT = PosixPath('/home/runner/work/marola-ml/marola-ml/finetune/data')
DPO_INPUT_FIELDS = ('beach_name', 'hour_local', 'sea_temp_c', 'wind_kmh', 'wave_height_m', 'jellyfish_risk', 'whale_sighting_likelihood', 'score')
def review_records(path: pathlib.Path) -> list[dict]:
36def review_records(path: Path) -> list[dict]:
37    """Real Reviewer decisions: one dict per review_prompt.json demo, in ReviewResult's own shape
38    (Reviewer.scala's `ReviewResult(finalSummary, score, verdict)`) plus the draft it reviewed."""
39    doc = json.loads(path.read_text(encoding="utf-8"))
40    records = []
41    for demo in doc.get("demos", []):
42        if "review_json" not in demo or "summary" not in demo:
43            continue
44        review = json.loads(demo["review_json"])
45        records.append(
46            {
47                "conditions": {k: demo[k] for k in DPO_INPUT_FIELDS if k in demo},
48                "draft_summary": demo["summary"],
49                "score": review.get("score"),
50                "verdict": review.get("verdict", "approve"),
51                "final_summary": review.get("final_summary", demo["summary"]),
52            }
53        )
54    return records

Real Reviewer decisions: one dict per review_prompt.json demo, in ReviewResult's own shape (Reviewer.scala's ReviewResult(finalSummary, score, verdict)) plus the draft it reviewed.

def preference_pairs(records: list[dict]) -> list[dict]:
57def preference_pairs(records: list[dict]) -> list[dict]:
58    """One (chosen, rejected) pair per real reject/revise record; none for "approve"."""
59    pairs = []
60    for r in records:
61        if r["verdict"] == "approve":
62            continue
63        if r["final_summary"] == r["draft_summary"]:
64            continue  # flagged but the text didn't actually change — no real preference signal
65        prompt = bd.render_inputs(r["conditions"], DPO_INPUT_FIELDS)
66        pairs.append(
67            {"prompt": prompt, "chosen": r["final_summary"], "rejected": r["draft_summary"]}
68        )
69    return pairs

One (chosen, rejected) pair per real reject/revise record; none for "approve".

def main( resources: pathlib.Path = PosixPath('/home/runner/work/marola-ml/marola-ml/.tmp/resources')) -> None:
72def main(resources: Path = RESOURCES) -> None:
73    records = review_records(resources / "review_prompt.json")
74    pairs = preference_pairs(records)
75    OUT.mkdir(parents=True, exist_ok=True)
76    with (OUT / "dpo_pairs.jsonl").open("w", encoding="utf-8") as f:
77        for p in pairs:
78            f.write(json.dumps(p, ensure_ascii=False) + "\n")
79    print(
80        f"wrote {len(pairs)} DPO preference pairs (from {len(records)} reviewer decisions) to {OUT}"
81    )
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
143def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
144    ap = argparse.ArgumentParser(
145        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
146    )
147    ap.add_argument(
148        "--resources",
149        type=Path,
150        default=RESOURCES,
151        help="dir with review_prompt.json (default: .tmp/resources, from `just resources-fetch`)",
152    )
153    ap.add_argument("--self-test", action="store_true")
154    return ap.parse_args(argv)