"""v0.54.0 — `soup advise` pre-flight decision engine. Three Parts: - Part A: Verdict engine (taxonomy / dataset profile / Verdict dataclass / CLI). - Part B: Probe runner (ROIEstimate / synth_probe_* helpers / --probe flag). - Part C: Cross-project learning (advise_history + `soup advise compare`). """ from __future__ import annotations import json import os from pathlib import Path from typing import List import pytest from typer.testing import CliRunner from soup_cli.commands import advise as advise_cmd from soup_cli.utils.advise import ( CHOICES, TASK_CATEGORIES, DatasetProfile, ROIEstimate, Verdict, build_verdict, classify_task, compute_dataset_profile, format_verdict_rubric, load_advise_dataset, next_command_for, synth_probe_baselines, synth_probe_lora_delta, ) from soup_cli.utils.advise_history import ( HistoryEntry, history_path, load_history, record_verdict, summarize_history, ) runner = CliRunner() # --------------------------------------------------------------------------- # Test fixtures # --------------------------------------------------------------------------- def _write_jsonl(path: Path, rows: list[dict]) -> None: path.write_text( "\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8" ) @pytest.fixture def tiny_sft_rows() -> List[dict]: return [ {"prompt": f"Q{i}", "response": f"A{i} text body example here"} for i in range(20) ] @pytest.fixture def big_sft_rows() -> List[dict]: return [ { "prompt": f"User question number {i}: explain X", "response": f"Response number {i} with varied vocabulary {i*7 % 999}", } for i in range(200) ] @pytest.fixture def preference_rows() -> List[dict]: return [ { "prompt": f"Q{i}", "chosen": f"good answer {i}", "rejected": f"bad answer {i}", } for i in range(120) ] @pytest.fixture def reasoning_rows() -> List[dict]: # Size >= _MIN_ROWS_FOR_GRPO (500) so build_verdict routes to GRPO. return [ { "prompt": f"Solve {i}*{i}", "response": f"Step: multiply {i} by {i}{i*i}", } for i in range(600) ] @pytest.fixture def factual_rows() -> List[dict]: return [ { "question": f"What is the capital of country_{i}?", "answer": f"city_{i}_distinct_label_{i*3}", } for i in range(120) ] # =========================================================================== # Part A — Verdict engine # =========================================================================== class TestPublicSurface: def test_choices_constant(self): assert CHOICES == ("PROMPT_ENG", "RAG", "SFT", "DPO", "GRPO") def test_task_categories_constant(self): assert "factual_lookup" in TASK_CATEGORIES assert "reasoning" in TASK_CATEGORIES assert "tool_use" in TASK_CATEGORIES assert len(TASK_CATEGORIES) == 7 def test_verdict_frozen(self): v = Verdict( choice="SFT", confidence=0.7, reason="r", reverse_when="w", task_category="reasoning", ) with pytest.raises(Exception): v.choice = "DPO" # type: ignore[misc] def test_roi_estimate_default(self): r = ROIEstimate() assert r.prompt_eng_delta is None assert r.sft_delta is None def test_dataset_profile_frozen(self): p = DatasetProfile( row_count=10, avg_input_chars=10.0, avg_output_chars=10.0, type_token_diversity=0.5, label_variance=0.5, ) with pytest.raises(Exception): p.row_count = 99 # type: ignore[misc] class TestLoadAdviseDataset: def test_happy(self, tmp_path): p = tmp_path / "data.jsonl" _write_jsonl(p, [{"a": 1}, {"a": 2}]) os.chdir(tmp_path) rows = load_advise_dataset("data.jsonl") assert len(rows) == 2 def test_rejects_outside_cwd(self, tmp_path): os.chdir(tmp_path) outside = tmp_path.parent / "outside.jsonl" outside.write_text("{}\n", encoding="utf-8") with pytest.raises(ValueError, match="cwd"): load_advise_dataset(str(outside)) def test_rejects_symlink(self, tmp_path): if os.name == "nt": pytest.skip("symlink behaviour differs on Windows") target = tmp_path / "real.jsonl" target.write_text("{}\n", encoding="utf-8") link = tmp_path / "linked.jsonl" os.symlink(target, link) os.chdir(tmp_path) with pytest.raises(ValueError, match="symlink"): load_advise_dataset("linked.jsonl") def test_rejects_null_byte_path(self, tmp_path): os.chdir(tmp_path) with pytest.raises(ValueError, match="NUL"): load_advise_dataset("bad\x00path.jsonl") def test_rejects_empty_path(self): with pytest.raises(ValueError, match="non-empty"): load_advise_dataset("") def test_rejects_non_string(self): with pytest.raises(ValueError): load_advise_dataset(None) # type: ignore[arg-type] def test_missing_file(self, tmp_path): os.chdir(tmp_path) with pytest.raises(FileNotFoundError): load_advise_dataset("nope.jsonl") def test_malformed_json_line(self, tmp_path): p = tmp_path / "bad.jsonl" p.write_text("not json\n", encoding="utf-8") os.chdir(tmp_path) with pytest.raises(ValueError, match="valid JSON"): load_advise_dataset("bad.jsonl") def test_non_object_row(self, tmp_path): p = tmp_path / "list.jsonl" p.write_text("[1,2,3]\n", encoding="utf-8") os.chdir(tmp_path) with pytest.raises(ValueError, match="JSON object"): load_advise_dataset("list.jsonl") def test_blank_lines_skipped(self, tmp_path): p = tmp_path / "data.jsonl" p.write_text('{"a":1}\n\n{"a":2}\n', encoding="utf-8") os.chdir(tmp_path) rows = load_advise_dataset("data.jsonl") assert len(rows) == 2 def test_utf8_bom_stripped(self, tmp_path): p = tmp_path / "bom.jsonl" p.write_bytes(b'\xef\xbb\xbf{"a":1}\n') os.chdir(tmp_path) rows = load_advise_dataset("bom.jsonl") assert rows[0]["a"] == 1 class TestClassifyTask: def test_reasoning_via_traces(self, reasoning_rows): assert classify_task(reasoning_rows) == "reasoning" def test_tool_use_via_field(self): rows = [{"tool_calls": [{"name": "x"}], "prompt": "p"}] * 10 assert classify_task(rows) == "tool_use" def test_summarization_keyword(self): rows = [{"prompt": "Summarize this text", "response": "tldr"}] * 30 assert classify_task(rows) == "summarization" def test_classification_keyword(self): rows = [ {"prompt": "classify this", "response": "label A"} for _ in range(30) ] assert classify_task(rows) == "classification" def test_default_fallback(self): rows = [{"a": 1}] assert classify_task(rows) == "factual_lookup" def test_goal_steers(self): rows = [{"prompt": "x", "response": "y"}] * 5 assert classify_task(rows, goal="translate text") == "format_conversion" def test_rejects_non_sequence(self): with pytest.raises(TypeError): classify_task(42) # type: ignore[arg-type] def test_null_byte_goal_rejected(self): with pytest.raises(ValueError): classify_task([{}], goal="bad\x00goal") def test_oversize_goal_rejected(self): with pytest.raises(ValueError): classify_task([{}], goal="x" * 99999) def test_non_string_goal_rejected(self): with pytest.raises(TypeError): classify_task([{}], goal=123) # type: ignore[arg-type] def test_messages_field_chat_format(self): rows = [ { "messages": [ {"role": "user", "content": "translate this"}, {"role": "assistant", "content": "translated"}, ] } ] * 20 assert classify_task(rows) == "format_conversion" class TestComputeDatasetProfile: def test_basic(self, big_sft_rows): p = compute_dataset_profile(big_sft_rows) assert p.row_count == 200 assert p.avg_input_chars > 0 assert p.avg_output_chars > 0 assert 0.0 <= p.type_token_diversity <= 1.0 assert 0.0 <= p.label_variance <= 1.0 def test_empty(self): p = compute_dataset_profile([]) assert p.row_count == 0 assert p.type_token_diversity == 0.0 def test_preference_detected(self, preference_rows): p = compute_dataset_profile(preference_rows) assert p.has_chosen_rejected is True def test_reasoning_detected(self, reasoning_rows): p = compute_dataset_profile(reasoning_rows) assert p.has_reasoning_traces is True def test_base_model_proximity_validates(self): with pytest.raises(TypeError): compute_dataset_profile([], base_model_proximity=True) # bool def test_base_model_proximity_bounds(self): with pytest.raises(ValueError): compute_dataset_profile([], base_model_proximity=1.5) def test_base_model_proximity_nan(self): with pytest.raises(ValueError): compute_dataset_profile([], base_model_proximity=float("nan")) def test_rejects_non_sequence(self): with pytest.raises(TypeError): compute_dataset_profile(42) # type: ignore[arg-type] class TestBuildVerdict: def test_preference_routes_to_dpo(self, preference_rows): profile = compute_dataset_profile(preference_rows) v = build_verdict(profile, "reasoning") assert v.choice == "DPO" def test_reasoning_with_traces_routes_to_grpo(self, reasoning_rows): profile = compute_dataset_profile(reasoning_rows) v = build_verdict(profile, "reasoning") assert v.choice == "GRPO" def test_tiny_routes_to_prompt_eng(self, tiny_sft_rows): profile = compute_dataset_profile(tiny_sft_rows) v = build_verdict(profile, "summarization") assert v.choice == "PROMPT_ENG" def test_factual_high_variance_routes_to_rag(self, factual_rows): profile = compute_dataset_profile(factual_rows) v = build_verdict(profile, "factual_lookup") assert v.choice == "RAG" def test_default_routes_to_sft(self, big_sft_rows): profile = compute_dataset_profile(big_sft_rows) v = build_verdict(profile, "summarization") assert v.choice == "SFT" def test_unknown_task_category(self, big_sft_rows): profile = compute_dataset_profile(big_sft_rows) with pytest.raises(ValueError, match="task_category"): build_verdict(profile, "nonsense") def test_invalid_roi_type(self, big_sft_rows): profile = compute_dataset_profile(big_sft_rows) with pytest.raises(TypeError): build_verdict(profile, "summarization", roi="bad") # type: ignore def test_confidence_in_unit_interval(self, big_sft_rows): profile = compute_dataset_profile(big_sft_rows) v = build_verdict(profile, "summarization") assert 0.0 <= v.confidence <= 1.0 def test_reason_non_empty(self, big_sft_rows): profile = compute_dataset_profile(big_sft_rows) v = build_verdict(profile, "summarization") assert v.reason assert v.reverse_when def test_roi_is_attached(self, big_sft_rows): profile = compute_dataset_profile(big_sft_rows) roi = ROIEstimate(sft_delta=0.42) v = build_verdict(profile, "summarization", roi=roi) assert v.estimated_roi.sft_delta == 0.42 # =========================================================================== # Part B — Probe runner # =========================================================================== class TestSynthProbeBaselines: def test_keys_present(self, big_sft_rows): out = synth_probe_baselines(big_sft_rows) assert set(out.keys()) == {"zero_shot", "few_shot", "rag"} def test_empty_input(self): out = synth_probe_baselines([]) assert out == {"zero_shot": 0.0, "few_shot": 0.0, "rag": 0.0} def test_bounded(self, big_sft_rows): out = synth_probe_baselines(big_sft_rows) for v in out.values(): assert -1.0 <= v <= 1.0 def test_rejects_bool_n_holdout(self, big_sft_rows): with pytest.raises(TypeError): synth_probe_baselines(big_sft_rows, n_holdout=True) def test_rejects_non_int_n_holdout(self, big_sft_rows): with pytest.raises(TypeError): synth_probe_baselines(big_sft_rows, n_holdout=1.5) # type: ignore def test_rejects_n_holdout_out_of_range(self, big_sft_rows): with pytest.raises(ValueError): synth_probe_baselines(big_sft_rows, n_holdout=0) with pytest.raises(ValueError): synth_probe_baselines(big_sft_rows, n_holdout=99_999) def test_rejects_non_sequence(self): with pytest.raises(TypeError): synth_probe_baselines(42) # type: ignore[arg-type] class TestSynthProbeLoraDelta: def test_returns_tuple(self, big_sft_rows): delta, secs = synth_probe_lora_delta(big_sft_rows) assert isinstance(delta, float) assert isinstance(secs, float) def test_tiny_returns_zero(self, tiny_sft_rows): delta, secs = synth_probe_lora_delta(tiny_sft_rows) assert delta == 0.0 assert secs > 0 def test_wall_clock_bounded(self, big_sft_rows): _, secs = synth_probe_lora_delta(big_sft_rows, n_steps=10_000) assert secs <= 600.0 def test_delta_bounded(self, big_sft_rows): delta, _ = synth_probe_lora_delta(big_sft_rows) assert -0.5 < delta < 1.0 def test_rejects_bool_n_steps(self, big_sft_rows): with pytest.raises(TypeError): synth_probe_lora_delta(big_sft_rows, n_steps=True) def test_rejects_n_steps_out_of_range(self, big_sft_rows): with pytest.raises(ValueError): synth_probe_lora_delta(big_sft_rows, n_steps=0) class TestFormatVerdictRubric: def test_basic(self): v = Verdict( choice="SFT", confidence=0.7, reason="r1", reverse_when="r2", task_category="reasoning", ) text = format_verdict_rubric(v) assert "SFT" in text assert "Confidence" in text assert "Reason" in text assert "Reverses when" in text def test_roi_renders(self): roi = ROIEstimate(sft_delta=0.3, prompt_eng_delta=-0.1) v = Verdict( choice="SFT", confidence=0.7, reason="r", reverse_when="w", task_category="reasoning", estimated_roi=roi, ) text = format_verdict_rubric(v) assert "+0.300" in text assert "-0.100" in text def test_not_measured_when_none(self): v = Verdict( choice="SFT", confidence=0.7, reason="r", reverse_when="w", task_category="reasoning", ) text = format_verdict_rubric(v) assert "(not measured)" in text def test_rejects_non_verdict(self): with pytest.raises(TypeError): format_verdict_rubric({"choice": "SFT"}) # type: ignore[arg-type] # =========================================================================== # Part C — Cross-project learning # =========================================================================== @pytest.fixture def history_file(tmp_path): return str(tmp_path / "history.jsonl") def _make_verdict() -> Verdict: return Verdict( choice="SFT", confidence=0.7, reason="r", reverse_when="w", task_category="reasoning", ) class TestRecordVerdict: def test_happy(self, history_file): entry = record_verdict( _make_verdict(), accepted=True, outcome=0.5, path=history_file, ) assert isinstance(entry, HistoryEntry) assert entry.choice == "SFT" assert entry.accepted is True assert entry.outcome == 0.5 assert Path(history_file).exists() def test_rejects_non_verdict(self, history_file): with pytest.raises(TypeError): record_verdict({}, accepted=True, path=history_file) # type: ignore def test_rejects_non_bool_accepted(self, history_file): with pytest.raises(TypeError): record_verdict( _make_verdict(), accepted="yes", path=history_file, # type: ignore ) def test_rejects_bool_outcome(self, history_file): with pytest.raises(TypeError): record_verdict( _make_verdict(), accepted=True, outcome=True, # type: ignore path=history_file, ) def test_rejects_non_finite_outcome(self, history_file): with pytest.raises(ValueError): record_verdict( _make_verdict(), accepted=True, outcome=float("inf"), path=history_file, ) def test_rejects_outcome_out_of_range(self, history_file): with pytest.raises(ValueError): record_verdict( _make_verdict(), accepted=True, outcome=1.5, path=history_file, ) def test_rejects_null_byte_notes(self, history_file): with pytest.raises(ValueError): record_verdict( _make_verdict(), accepted=True, notes="bad\x00", path=history_file, ) def test_rejects_oversize_notes(self, history_file): with pytest.raises(ValueError): record_verdict( _make_verdict(), accepted=True, notes="x" * 99999, path=history_file, ) def test_rejects_invalid_choice(self, history_file): v = Verdict( choice="NONSENSE", confidence=0.5, reason="r", reverse_when="w", task_category="reasoning", ) with pytest.raises(ValueError): record_verdict(v, accepted=True, path=history_file) def test_rejects_invalid_task_category(self, history_file): v = Verdict( choice="SFT", confidence=0.5, reason="r", reverse_when="w", task_category="nonsense", ) with pytest.raises(ValueError): record_verdict(v, accepted=True, path=history_file) def test_rejects_symlink_path(self, tmp_path): if os.name == "nt": pytest.skip("symlink semantics differ on Windows") target = tmp_path / "real.jsonl" target.write_text("", encoding="utf-8") link = tmp_path / "link.jsonl" os.symlink(target, link) with pytest.raises(ValueError, match="symlink"): record_verdict(_make_verdict(), accepted=True, path=str(link)) def test_notes_strips_newlines(self, history_file): entry = record_verdict( _make_verdict(), accepted=True, notes="line1\nline2\rline3", path=history_file, ) assert "\n" not in entry.notes assert "\r" not in entry.notes class TestLoadHistory: def test_empty(self, history_file): assert load_history(path=history_file) == [] def test_round_trip(self, history_file): record_verdict(_make_verdict(), accepted=True, path=history_file) record_verdict(_make_verdict(), accepted=False, path=history_file) entries = load_history(path=history_file) assert len(entries) == 2 # Newest first. assert entries[0].accepted is False def test_skips_malformed_lines(self, tmp_path): p = tmp_path / "h.jsonl" good = ( '{"choice":"SFT","task_category":"reasoning",' '"confidence":0.5,"accepted":true}' ) p.write_text("not json\n" + good + "\n", encoding="utf-8") entries = load_history(path=str(p)) assert len(entries) == 1 def test_skips_invalid_choice(self, tmp_path): p = tmp_path / "h.jsonl" p.write_text( '{"choice":"NONSENSE","task_category":"reasoning","confidence":0.5,"accepted":true}\n', encoding="utf-8", ) entries = load_history(path=str(p)) assert entries == [] def test_limit_bounds(self, history_file): with pytest.raises(ValueError): load_history(limit=0, path=history_file) with pytest.raises(ValueError): load_history(limit=10_000_000, path=history_file) def test_limit_bool_rejected(self, history_file): with pytest.raises(TypeError): load_history(limit=True, path=history_file) def test_limit_caps_returned(self, history_file): for _ in range(5): record_verdict(_make_verdict(), accepted=True, path=history_file) entries = load_history(limit=3, path=history_file) assert len(entries) == 3 class TestHistoryPath: def test_default_under_home(self, monkeypatch): monkeypatch.delenv("SOUP_ADVISE_HISTORY_PATH", raising=False) p = history_path() assert p.endswith(os.path.join(".soup", "advise_history.jsonl")) def test_env_override_in_tempdir(self, monkeypatch, tmp_path): # tmp_path is typically inside the system temp dir import tempfile as _tempfile sysdir = os.path.realpath(_tempfile.gettempdir()) override = os.path.join(sysdir, "soup_advise_test.jsonl") monkeypatch.setenv("SOUP_ADVISE_HISTORY_PATH", override) p = history_path() assert os.path.realpath(p) == os.path.realpath(override) def test_env_override_out_of_bounds_falls_back(self, monkeypatch): # /etc is neither home, cwd, nor tempdir on POSIX. if os.name == "nt": pytest.skip("path semantics differ on Windows") monkeypatch.setenv("SOUP_ADVISE_HISTORY_PATH", "/etc/advise.jsonl") p = history_path() assert p != "/etc/advise.jsonl" def test_env_null_byte_falls_back(self, monkeypatch): # OS env layer rejects raw NUL bytes (POSIX execve / Win32 SetEnv # both refuse `\x00`), so we exercise the helper's defence-in-depth # NUL guard via a stubbed env dict rather than `monkeypatch.setenv`. from soup_cli.utils import advise_history original = advise_history.os.environ try: advise_history.os.environ = {"SOUP_ADVISE_HISTORY_PATH": "x\x00y"} # type: ignore[assignment] p = history_path() finally: advise_history.os.environ = original # type: ignore[assignment] assert "\x00" not in p class TestSummarizeHistory: def test_counts(self, history_file): v_sft = _make_verdict() v_dpo = Verdict( choice="DPO", confidence=0.7, reason="r", reverse_when="w", task_category="reasoning", ) record_verdict(v_sft, accepted=True, path=history_file) record_verdict(v_dpo, accepted=True, path=history_file) record_verdict(v_dpo, accepted=True, path=history_file) entries = load_history(path=history_file) counts = summarize_history(entries) assert counts["SFT"] == 1 assert counts["DPO"] == 2 assert counts["GRPO"] == 0 # =========================================================================== # CLI smoke # =========================================================================== class TestCLI: def test_help(self): result = runner.invoke(advise_cmd.app, ["--help"]) assert result.exit_code == 0 assert "advise" in result.output.lower() or "pre-flight" in result.output.lower() def test_explain_no_prior_verdict(self, tmp_path, monkeypatch): monkeypatch.setenv("TMPDIR", str(tmp_path)) monkeypatch.setenv("TEMP", str(tmp_path)) monkeypatch.setenv("TMP", str(tmp_path)) # Clean cached verdict if any last = advise_cmd._last_verdict_path() if os.path.exists(last): os.unlink(last) result = runner.invoke(advise_cmd.app, ["explain"]) # Acceptable: no prior verdict → exit 1 OR previous verdict present assert result.exit_code in (0, 1) def test_compare_empty(self, tmp_path, monkeypatch): monkeypatch.setenv( "SOUP_ADVISE_HISTORY_PATH", str(tmp_path / "history.jsonl"), ) result = runner.invoke(advise_cmd.app, ["compare"]) assert result.exit_code == 0 assert "No history" in result.output or "history" in result.output.lower() def test_default_happy_path(self, tmp_path, monkeypatch): os.chdir(tmp_path) p = tmp_path / "data.jsonl" _write_jsonl(p, [ {"prompt": f"summarize doc {i}", "response": f"tldr {i}"} for i in range(120) ]) result = runner.invoke( advise_cmd.app, ["run", "data.jsonl", "--goal", "summarize"] ) assert result.exit_code == 0, result.output # Either SFT or PROMPT_ENG depending on heuristics; must be a known choice assert any(c in result.output for c in CHOICES) def test_default_missing_data(self): result = runner.invoke(advise_cmd.app, []) # `no_args_is_help=True`: Click 8.0–8.1 returns rc=0, Click 8.2+ # returns rc=2 (the "missing command" convention). Both renderings # print the help text, so accept either. assert result.exit_code in (0, 2) assert "Usage" in result.output or "advise" in result.output.lower() def test_default_nonexistent_data(self, tmp_path): os.chdir(tmp_path) result = runner.invoke(advise_cmd.app, ["run", "nope.jsonl"]) assert result.exit_code == 1 assert "Dataset error" in result.output or "not found" in result.output.lower() def test_default_outside_cwd(self, tmp_path): os.chdir(tmp_path) outside = tmp_path.parent / "outside.jsonl" outside.write_text("{}\n", encoding="utf-8") result = runner.invoke(advise_cmd.app, ["run", str(outside)]) assert result.exit_code == 1 def test_probe_flag(self, tmp_path, monkeypatch): os.chdir(tmp_path) p = tmp_path / "data.jsonl" _write_jsonl(p, [ {"prompt": f"q{i}", "response": f"a{i}"} for i in range(120) ]) result = runner.invoke( advise_cmd.app, ["run", "data.jsonl", "--goal", "summarize", "--probe"], ) assert result.exit_code == 0, result.output assert "ROI" in result.output or "delta" in result.output.lower() def test_record_flag(self, tmp_path, monkeypatch): monkeypatch.setenv( "SOUP_ADVISE_HISTORY_PATH", str(tmp_path / "history.jsonl"), ) os.chdir(tmp_path) p = tmp_path / "data.jsonl" _write_jsonl(p, [ {"prompt": f"q{i}", "response": f"a{i}"} for i in range(120) ]) result = runner.invoke( advise_cmd.app, ["run", "data.jsonl", "--goal", "summarize", "--record"], ) assert result.exit_code == 0 assert (tmp_path / "history.jsonl").exists() entries = load_history(path=str(tmp_path / "history.jsonl")) assert len(entries) == 1 def test_compare_after_record(self, tmp_path, monkeypatch): history = tmp_path / "history.jsonl" monkeypatch.setenv("SOUP_ADVISE_HISTORY_PATH", str(history)) record_verdict(_make_verdict(), accepted=True, path=str(history)) result = runner.invoke(advise_cmd.app, ["compare"]) assert result.exit_code == 0 assert "SFT" in result.output def test_explain_after_default(self, tmp_path, monkeypatch): # Run advise to populate scratch file, then explain os.chdir(tmp_path) p = tmp_path / "data.jsonl" _write_jsonl(p, [ {"prompt": f"q{i}", "response": f"a{i}"} for i in range(120) ]) result1 = runner.invoke( advise_cmd.app, ["run", "data.jsonl", "--goal", "summarize"] ) assert result1.exit_code == 0 result2 = runner.invoke(advise_cmd.app, ["explain"]) assert result2.exit_code == 0 assert "Choice" in result2.output def test_compare_limit_bounds(self): result = runner.invoke(advise_cmd.app, ["compare", "--limit", "0"]) assert result.exit_code != 0 # =========================================================================== # Source-level wiring checks (regression guards) # =========================================================================== class TestSourceWiring: def test_cli_registers_advise(self): cli_path = Path(__file__).resolve().parent.parent / "soup_cli" / "cli.py" text = cli_path.read_text(encoding="utf-8") assert "_advise_cmd" in text assert 'name="advise"' in text def test_version_bump(self): from soup_cli import __version__ # Asserts a forward-compatible floor (v0.54.0 shipped advise); # later releases that bump the version must not regress this gate. assert __version__ >= "0.54.0" def test_advise_module_imports(self): # Importable without heavy deps (lazy imports inside helpers). from soup_cli.commands import advise as _cmd # noqa: F401 from soup_cli.utils import advise as _advise # noqa: F401 from soup_cli.utils import advise_history as _hist # noqa: F401 class TestArgvRewriter: def test_no_advise_in_argv(self): from soup_cli.cli import _rewrite_advise_argv argv = ["soup", "train", "--config", "x.yaml"] assert _rewrite_advise_argv(argv) == argv def test_advise_with_subcommand_unchanged(self): from soup_cli.cli import _rewrite_advise_argv for sub in ("run", "explain", "compare", "--help"): argv = ["soup", "advise", sub] assert _rewrite_advise_argv(argv) == argv def test_advise_with_data_injects_run(self): from soup_cli.cli import _rewrite_advise_argv argv = ["soup", "advise", "data.jsonl"] out = _rewrite_advise_argv(argv) assert out == ["soup", "advise", "run", "data.jsonl"] def test_advise_with_data_and_flags(self): from soup_cli.cli import _rewrite_advise_argv argv = ["soup", "advise", "data.jsonl", "--goal", "summarize"] out = _rewrite_advise_argv(argv) assert out[2] == "run" assert out[3] == "data.jsonl" def test_advise_with_flag_only_unchanged(self): from soup_cli.cli import _rewrite_advise_argv argv = ["soup", "advise", "--probe"] assert _rewrite_advise_argv(argv) == argv def test_advise_alone_unchanged(self): from soup_cli.cli import _rewrite_advise_argv argv = ["soup", "advise"] assert _rewrite_advise_argv(argv) == argv # =========================================================================== # Edge / defensive tests to round out coverage # =========================================================================== class TestDefensive: def test_classify_task_with_messages_assistant_only(self): rows = [ {"messages": [{"role": "assistant", "content": "x"}]} ] * 5 # No keywords + no tool_calls → default assert classify_task(rows) == "factual_lookup" def test_dataset_profile_handles_non_mapping_row(self): rows = [{"a": 1}, "not a dict", {"b": 2}] # type: ignore[list-item] p = compute_dataset_profile(rows) # type: ignore[arg-type] assert p.row_count == 3 def test_build_verdict_with_preference_overrides_tiny(self): # Preference rule should fire BEFORE tiny rule rows = [ {"chosen": "a", "rejected": "b"} for _ in range(5) ] profile = compute_dataset_profile(rows) v = build_verdict(profile, "reasoning") assert v.choice == "DPO" def test_synth_probe_baselines_rag_correlates_with_variance( self, factual_rows ): out = synth_probe_baselines(factual_rows) # High-variance factual dataset → rag delta should not be the worst. assert out["rag"] >= out["zero_shot"] - 0.1 def test_format_rubric_handles_nan_delta(self): roi = ROIEstimate(prompt_eng_delta=float("nan")) v = Verdict( choice="SFT", confidence=0.5, reason="r", reverse_when="w", task_category="reasoning", estimated_roi=roi, ) text = format_verdict_rubric(v) assert "non-finite" in text or "nan" in text.lower() def test_record_verdict_creates_parent_dir(self, tmp_path): target = tmp_path / "nested" / "subdir" / "h.jsonl" record_verdict(_make_verdict(), accepted=True, path=str(target)) assert target.exists() def test_load_history_returns_list(self, tmp_path): p = str(tmp_path / "h.jsonl") # Doesn't exist yet assert load_history(path=p) == [] def test_history_entry_frozen(self): e = HistoryEntry( timestamp="t", project="p", choice="SFT", task_category="reasoning", confidence=0.5, reason="r", reverse_when="w", accepted=True, outcome=None, ) with pytest.raises(Exception): e.choice = "DPO" # type: ignore[misc] # =========================================================================== # TDD-review additions — boundary + concurrency + just-added security paths # =========================================================================== class TestTDDFollowups: def test_read_last_verdict_rejects_symlink(self, tmp_path, monkeypatch): if os.name == "nt": pytest.skip("symlink semantics differ on Windows") target = tmp_path / "real.json" target.write_text('{"choice":"SFT"}', encoding="utf-8") link = tmp_path / "advise_last.json" os.symlink(target, link) monkeypatch.setattr(advise_cmd, "_last_verdict_path", lambda: str(link)) # Symlinked scratch file must NOT be read — returns None silently. assert advise_cmd._read_last_verdict() is None def test_oversized_history_line_skipped(self, tmp_path): p = tmp_path / "h.jsonl" oversize = ( '{"choice":"SFT","task_category":"reasoning","confidence":0.5,' '"accepted":true,"notes":"' + ("a" * 70000) + '"}' ) good = ( '{"choice":"DPO","task_category":"reasoning","confidence":0.5,' '"accepted":true}' ) p.write_text(oversize + "\n" + good + "\n", encoding="utf-8") entries = load_history(path=str(p)) # Oversize line dropped, valid one kept. assert len(entries) == 1 assert entries[0].choice == "DPO" def test_concurrent_record_no_torn_writes(self, tmp_path): import threading p = str(tmp_path / "h.jsonl") errors: list = [] def worker(idx: int): try: record_verdict( _make_verdict(), accepted=True, notes=f"thread-{idx}", path=p, ) except Exception as exc: # noqa: BLE001 errors.append(exc) threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)] for t in threads: t.start() for t in threads: t.join() assert not errors, errors entries = load_history(limit=20, path=p) # Every line valid JSON + parseable as HistoryEntry. assert len(entries) == 8 def test_prompt_eng_at_49_sft_at_50(self): prof_small = DatasetProfile( row_count=49, avg_input_chars=10.0, avg_output_chars=10.0, type_token_diversity=0.5, label_variance=0.3, ) prof_50 = DatasetProfile( row_count=50, avg_input_chars=10.0, avg_output_chars=10.0, type_token_diversity=0.5, label_variance=0.3, ) assert build_verdict(prof_small, "summarization").choice == "PROMPT_ENG" assert build_verdict(prof_50, "summarization").choice == "SFT" def test_grpo_threshold_exact(self): prof_499 = DatasetProfile( row_count=499, avg_input_chars=10.0, avg_output_chars=10.0, type_token_diversity=0.5, label_variance=0.3, has_reasoning_traces=True, ) prof_500 = DatasetProfile( row_count=500, avg_input_chars=10.0, avg_output_chars=10.0, type_token_diversity=0.5, label_variance=0.3, has_reasoning_traces=True, ) # 499 rows of reasoning traces → still SFT (below GRPO floor). assert build_verdict(prof_499, "reasoning").choice == "SFT" # 500 rows → GRPO. assert build_verdict(prof_500, "reasoning").choice == "GRPO" def test_notes_boundary_4096_vs_4097(self, tmp_path): p = str(tmp_path / "h.jsonl") # Exactly 4096 chars accepted. record_verdict(_make_verdict(), accepted=True, notes="a" * 4096, path=p) # 4097 rejected. with pytest.raises(ValueError, match="4096"): record_verdict(_make_verdict(), accepted=True, notes="a" * 4097, path=p) def test_symlink_history_path_rejected_on_load(self, tmp_path): if os.name == "nt": pytest.skip("symlink semantics differ on Windows") target = tmp_path / "real.jsonl" target.write_text("", encoding="utf-8") link = tmp_path / "linked.jsonl" os.symlink(target, link) with pytest.raises(ValueError, match="symlink"): load_history(path=str(link)) def test_build_verdict_null_byte_goal_rejected(self): profile = DatasetProfile( row_count=100, avg_input_chars=10.0, avg_output_chars=10.0, type_token_diversity=0.5, label_variance=0.3, ) with pytest.raises(ValueError, match="NUL"): build_verdict(profile, "summarization", goal="bad\x00goal") def test_fmt_delta_bool_renders_invalid(self): roi = ROIEstimate(prompt_eng_delta=True) # type: ignore[arg-type] v = Verdict( choice="SFT", confidence=0.5, reason="r", reverse_when="w", task_category="reasoning", estimated_roi=roi, ) text = format_verdict_rubric(v) # bool is rejected as invalid even though it's technically isinstance(int). assert "(invalid)" in text or "+1.000" in text or "+0.000" in text class TestAdviseSchemaField: """Plan's cross-cutting bullet: schema-only `advise: AdviseConfig` on SoupConfig.""" def test_advise_field_default_none(self): from soup_cli.config.loader import load_config_from_string yaml_text = """ base: test-model task: sft data: train: ./data.jsonl """ cfg = load_config_from_string(yaml_text) assert cfg.advise is None def test_advise_goal_accepted(self): from soup_cli.config.loader import load_config_from_string yaml_text = """ base: test-model task: sft data: train: ./data.jsonl advise: goal: "summarize my reports" probe: true """ cfg = load_config_from_string(yaml_text) assert cfg.advise is not None assert cfg.advise.goal == "summarize my reports" assert cfg.advise.probe is True assert cfg.advise.record is False def test_advise_null_byte_goal_rejected(self): # YAML parser already rejects raw NUL bytes; the AdviseConfig # field_validator is the defence-in-depth layer for callers that # bypass YAML (e.g. construct AdviseConfig directly). from pydantic import ValidationError from soup_cli.config.schema import AdviseConfig with pytest.raises(ValidationError, match="null"): AdviseConfig(goal="bad\x00goal") def test_advise_oversize_goal_rejected(self): from soup_cli.config.loader import load_config_from_string oversized = "a" * 5000 yaml_text = f""" base: test-model task: sft data: train: ./data.jsonl advise: goal: "{oversized}" """ with pytest.raises(ValueError, match="4096|characters"): load_config_from_string(yaml_text) class TestNextCommandFor: @pytest.mark.parametrize("choice,expected_token", [ ("PROMPT_ENG", "soup chat"), ("RAG", "RAG is outside"), ("SFT", "soup autopilot"), ("DPO", "soup autopilot"), ("GRPO", "soup autopilot"), ]) def test_known_choices(self, choice: str, expected_token: str): v = Verdict( choice=choice, confidence=0.5, reason="r", reverse_when="w", task_category="reasoning", ) assert expected_token in next_command_for(v) def test_rejects_non_verdict(self): with pytest.raises(TypeError): next_command_for({"choice": "SFT"}) # type: ignore[arg-type] class TestProbeForwardCompatKwargs: def test_baselines_accepts_future_kwargs(self, big_sft_rows): # v0.54.1 will add live model/device/timeout — calling with them # today must not raise. out = synth_probe_baselines( big_sft_rows, n_holdout=50, model="meta-llama/Llama-3.1-8B", device="cpu", timeout_seconds=60, ) assert "zero_shot" in out def test_lora_delta_accepts_future_kwargs(self, big_sft_rows): delta, _ = synth_probe_lora_delta( big_sft_rows, n_steps=50, model="meta-llama/Llama-3.1-8B", device="cpu", lr=2e-5, timeout_seconds=120, ) assert isinstance(delta, float)