Skip to content

Commit b021b02

Browse files
fix: preserve checkpoint feedback in variants and persist search journal (#184)
* fix: preserve resume feedback and persist search journal checkpoints * chore: bump version to 1.3.6
1 parent 07b4cf4 commit b021b02

5 files changed

Lines changed: 306 additions & 6 deletions

File tree

plexe/CODE_INDEX.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code Index: plexe
22

3-
> Generated on 2026-03-02 12:38:31
3+
> Generated on 2026-03-02 19:57:53
44
55
Code structure and public interface documentation for the **plexe** package.
66

@@ -707,4 +707,4 @@ Main workflow orchestrator.
707707
- `evaluate_final(spark: SparkSession, context: BuildContext, solution: Solution, config: Config, on_checkpoint_saved: Callable[[str, Path, Path], None] | None) -> dict` - Phase 5: Final evaluation on test set sample.
708708
- `package_final_model(spark: SparkSession, context: BuildContext, solution: Solution, final_metrics: dict, on_checkpoint_saved: Callable[[str, Path, Path], None] | None) -> Path` - Package all final deliverables into a unified directory.
709709

710-
---
710+
---

plexe/workflow.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def build_model(
178178
context = BuildContext.from_dict(checkpoint_data["context"])
179179
if checkpoint_data.get("search_journal"):
180180
journal = SearchJournal.from_dict(checkpoint_data["search_journal"])
181+
context.scratch["_search_journal"] = journal
181182
logger.info(f"Restored SearchJournal with {len(journal.nodes)} solutions")
182183
if checkpoint_data.get("insight_store"):
183184
insight_store = InsightStore.from_dict(checkpoint_data["insight_store"])
@@ -1462,6 +1463,9 @@ def search_models(
14621463
# NOTE: Only scratch is isolated per-variant; all other attributes are shared (read-only)
14631464
variant_context = copy.copy(context)
14641465
variant_context.scratch = {} # Fresh scratch dict per variant
1466+
if "_user_feedback" in context.scratch:
1467+
# Preserve resume feedback while keeping per-variant scratch isolation.
1468+
variant_context.scratch["_user_feedback"] = context.scratch["_user_feedback"]
14651469

14661470
futures.append(
14671471
executor.submit(
@@ -1848,8 +1852,15 @@ def evaluate_final(
18481852
context.scratch["_evaluation_report"] = evaluation_report
18491853

18501854
# Save checkpoint (with insight_store if available)
1855+
search_journal = context.scratch.get("_search_journal")
18511856
insight_store = context.insight_store if hasattr(context, "insight_store") else None
1852-
_save_phase_checkpoint(PhaseNames.EVALUATE_FINAL, context, on_checkpoint_saved, insight_store=insight_store)
1857+
_save_phase_checkpoint(
1858+
PhaseNames.EVALUATE_FINAL,
1859+
context,
1860+
on_checkpoint_saved,
1861+
search_journal=search_journal,
1862+
insight_store=insight_store,
1863+
)
18531864

18541865
return metrics
18551866

@@ -2222,8 +2233,15 @@ def package_final_model(
22222233
logger.info(f" Archive: {tarball_path}")
22232234

22242235
# Save checkpoint (with insight_store if available)
2236+
search_journal = context.scratch.get("_search_journal")
22252237
insight_store = context.insight_store if hasattr(context, "insight_store") else None
2226-
_save_phase_checkpoint(PhaseNames.PACKAGE_FINAL_MODEL, context, on_checkpoint_saved, insight_store=insight_store)
2238+
_save_phase_checkpoint(
2239+
PhaseNames.PACKAGE_FINAL_MODEL,
2240+
context,
2241+
on_checkpoint_saved,
2242+
search_journal=search_journal,
2243+
insight_store=insight_store,
2244+
)
22272245

22282246
return package_dir
22292247

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "plexe"
3-
version = "1.3.5"
3+
version = "1.3.6"
44
description = "An agentic framework for building ML models from natural language"
55
authors = [
66
"Marcello De Bernardi <mdebernardi@plexe.ai>",

tests/CODE_INDEX.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code Index: tests
22

3-
> Generated on 2026-03-02 12:38:31
3+
> Generated on 2026-03-02 19:57:53
44
55
Test suite structure and test case documentation.
66

@@ -130,6 +130,7 @@ Unit tests for config helpers.
130130
- `test_temperature_fields_from_env(monkeypatch)` - No description
131131
- `test_temperature_fields_from_yaml(tmp_path, monkeypatch)` - No description
132132
- `test_get_temperature_resolves_override_and_default()` - No description
133+
- `test_setup_logging_disables_propagation()` - Plexe logger should not propagate to root to avoid duplicate log lines.
133134

134135
---
135136
## `unit/test_helpers.py`
@@ -210,6 +211,15 @@ Unit tests for validation functions.
210211
- `test_validate_metric_function_object_success()` - Callable with correct signature should pass.
211212
- `test_validate_metric_function_object_bad_signature()` - Callable with wrong arg names should fail.
212213

214+
---
215+
## `unit/workflow/test_checkpoint_resume_feedback.py`
216+
Tests for checkpoint resume feedback and persisted search journal behavior.
217+
218+
**Functions:**
219+
- `test_search_models_preserves_user_feedback_for_all_variants(monkeypatch, tmp_path)` - No description
220+
- `test_evaluate_final_checkpoint_persists_search_journal(monkeypatch, tmp_path)` - No description
221+
- `test_package_final_checkpoint_persists_search_journal(monkeypatch, tmp_path)` - No description
222+
213223
---
214224
## `unit/workflow/test_column_exclusion.py`
215225
Tests for column exclusion pipeline.
@@ -231,3 +241,13 @@ Unit tests for model card generation.
231241
- `test_generate_model_card_minimal_context(tmp_path: Path) -> None` - No description
232242

233243
---
244+
## `unit/workflow/test_resume_model_type_filtering.py`
245+
Tests for resume-time model type filtering.
246+
247+
**Functions:**
248+
- `test_filters_checkpoint_model_types_on_resume(tmp_path)` - No description
249+
- `test_uses_allowed_model_types_when_checkpoint_has_none(tmp_path)` - No description
250+
- `test_raises_when_allowed_types_do_not_intersect_checkpoint(tmp_path)` - No description
251+
- `test_does_not_filter_before_phase_one(tmp_path)` - No description
252+
253+
---
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Tests for checkpoint resume feedback and persisted search journal behavior."""
2+
3+
from __future__ import annotations
4+
5+
from types import SimpleNamespace
6+
7+
import pandas as pd
8+
9+
from plexe.config import Config
10+
from plexe.constants import PhaseNames
11+
from plexe.models import Baseline, BuildContext, Metric, Solution
12+
from plexe.search.journal import SearchJournal
13+
import plexe.workflow as workflow
14+
15+
16+
class _DummySparkDataFrame:
17+
def __init__(self, pdf: pd.DataFrame):
18+
self._pdf = pdf
19+
20+
def count(self) -> int:
21+
return len(self._pdf)
22+
23+
def limit(self, _rows: int) -> "_DummySparkDataFrame":
24+
return self
25+
26+
def toPandas(self) -> pd.DataFrame:
27+
return self._pdf
28+
29+
30+
class _DummySparkReader:
31+
def __init__(self, pdf: pd.DataFrame):
32+
self._pdf = pdf
33+
34+
def parquet(self, _uri: str) -> _DummySparkDataFrame:
35+
return _DummySparkDataFrame(self._pdf)
36+
37+
38+
class _DummySpark:
39+
def __init__(self, pdf: pd.DataFrame):
40+
self.read = _DummySparkReader(pdf)
41+
42+
43+
class _DummySearchPolicy:
44+
def decide_next_solution(self, journal, context, iteration, max_iterations): # noqa: D401
45+
return None
46+
47+
def should_stop(self, journal, iteration, max_iterations): # noqa: D401
48+
return True
49+
50+
51+
class _DummyIntegration:
52+
def __init__(self, transformed_path: str):
53+
self._transformed_path = transformed_path
54+
55+
def get_artifact_location(self, artifact_type, dataset_uri, experiment_id, work_dir): # noqa: D401
56+
return self._transformed_path
57+
58+
59+
class _DummyPlannerAgent:
60+
def __init__(self, *args, **kwargs):
61+
pass
62+
63+
def run(self):
64+
plan = SimpleNamespace(
65+
parent_solution_id=-1,
66+
model=SimpleNamespace(model_type="xgboost"),
67+
)
68+
return [plan, plan]
69+
70+
71+
class _DummyCoreMetrics:
72+
primary_metric_value = 0.91
73+
all_metrics = {"accuracy": 0.91}
74+
75+
76+
class _DummyEvaluationReport:
77+
verdict = "PASS"
78+
deployment_ready = True
79+
summary = "ok"
80+
core_metrics = _DummyCoreMetrics()
81+
82+
def to_dict(self) -> dict:
83+
return {"verdict": self.verdict, "deployment_ready": self.deployment_ready}
84+
85+
86+
class _DummyModelEvaluatorAgent:
87+
def __init__(self, *args, **kwargs):
88+
pass
89+
90+
def run(self, solution, test_sample_df, predictor): # noqa: D401
91+
return _DummyEvaluationReport()
92+
93+
94+
def _make_context(tmp_path) -> BuildContext:
95+
return BuildContext(
96+
user_id="user-1",
97+
experiment_id="exp-1",
98+
dataset_uri="/tmp/dataset.parquet",
99+
work_dir=tmp_path,
100+
intent="predict churn",
101+
)
102+
103+
104+
def test_search_models_preserves_user_feedback_for_all_variants(monkeypatch, tmp_path):
105+
context = _make_context(tmp_path)
106+
context.metric = Metric(name="accuracy", optimization_direction="higher")
107+
context.output_targets = ["target"]
108+
context.train_sample_uri = "/tmp/train.parquet"
109+
context.val_sample_uri = "/tmp/val.parquet"
110+
context.heuristic_baseline = Baseline(name="baseline", model_type="baseline", performance=0.5)
111+
feedback = {"comments": "Prefer neural networks over trees"}
112+
context.scratch["_user_feedback"] = feedback
113+
114+
config = Config()
115+
config.max_search_iterations = 1
116+
config.max_parallel_variants = 2
117+
118+
captured_feedback: list[dict | None] = []
119+
120+
def _fake_execute_variant(
121+
plan,
122+
solution_id,
123+
journal,
124+
spark,
125+
config,
126+
runner,
127+
pipelines_dir,
128+
transformed_output_base,
129+
variant_context,
130+
) -> Solution:
131+
captured_feedback.append(variant_context.scratch.get("_user_feedback"))
132+
return Solution(
133+
solution_id=solution_id,
134+
feature_pipeline=object(),
135+
model=object(),
136+
model_type="xgboost",
137+
performance=0.8,
138+
)
139+
140+
monkeypatch.setattr(workflow, "PlannerAgent", _DummyPlannerAgent)
141+
monkeypatch.setattr(workflow, "_execute_variant", _fake_execute_variant)
142+
monkeypatch.setattr(workflow, "retrain_on_full_dataset", lambda **kwargs: kwargs["best_solution"])
143+
monkeypatch.setattr(workflow, "_save_phase_checkpoint", lambda *args, **kwargs: None)
144+
145+
result = workflow.search_models(
146+
spark=object(),
147+
context=context,
148+
runner=object(),
149+
search_policy=_DummySearchPolicy(),
150+
config=config,
151+
integration=_DummyIntegration(str(tmp_path / "transformed")),
152+
)
153+
154+
assert result is not None
155+
assert len(captured_feedback) == 2
156+
assert all(item == feedback for item in captured_feedback)
157+
158+
159+
def test_evaluate_final_checkpoint_persists_search_journal(monkeypatch, tmp_path):
160+
context = _make_context(tmp_path)
161+
context.metric = Metric(name="accuracy", optimization_direction="higher")
162+
context.output_targets = ["target"]
163+
context.test_uri = "/tmp/test.parquet"
164+
165+
search_journal = SearchJournal(baseline=Baseline(name="baseline", model_type="baseline", performance=0.5))
166+
context.scratch["_search_journal"] = search_journal
167+
168+
model_dir = tmp_path / "solution_artifacts"
169+
model_dir.mkdir(parents=True, exist_ok=True)
170+
(model_dir / "predictor.py").write_text(
171+
"class XGBoostPredictor:\n" " def __init__(self, model_dir):\n" " self.model_dir = model_dir\n"
172+
)
173+
174+
solution = Solution(
175+
solution_id=1,
176+
feature_pipeline=object(),
177+
model=object(),
178+
model_type="xgboost",
179+
model_artifacts_path=model_dir,
180+
)
181+
182+
captured: dict = {}
183+
184+
def _capture_checkpoint(
185+
phase_name,
186+
context,
187+
on_checkpoint_saved,
188+
search_journal=None,
189+
insight_store=None,
190+
):
191+
captured["phase_name"] = phase_name
192+
captured["search_journal"] = search_journal
193+
194+
monkeypatch.setattr(workflow, "ModelEvaluatorAgent", _DummyModelEvaluatorAgent)
195+
monkeypatch.setattr(workflow, "_save_phase_checkpoint", _capture_checkpoint)
196+
monkeypatch.setattr(workflow, "save_report", lambda *args, **kwargs: None)
197+
198+
metrics = workflow.evaluate_final(
199+
spark=_DummySpark(pd.DataFrame({"feature": [1, 2], "target": [0, 1]})),
200+
context=context,
201+
solution=solution,
202+
config=Config(),
203+
)
204+
205+
assert metrics is not None
206+
assert captured["phase_name"] == PhaseNames.EVALUATE_FINAL
207+
assert captured["search_journal"] is search_journal
208+
209+
210+
def test_package_final_checkpoint_persists_search_journal(monkeypatch, tmp_path):
211+
context = _make_context(tmp_path)
212+
context.metric = Metric(name="accuracy", optimization_direction="higher")
213+
context.output_targets = ["target"]
214+
context.task_analysis = {"task_type": "binary_classification"}
215+
context.stats = {"total_rows": 2, "total_columns": 2}
216+
context.train_sample_uri = "/tmp/train_sample.parquet"
217+
context.heuristic_baseline = Baseline(name="baseline", model_type="baseline", performance=0.5)
218+
219+
search_journal = SearchJournal(baseline=context.heuristic_baseline)
220+
context.scratch["_search_journal"] = search_journal
221+
222+
model_artifacts_source = tmp_path / "model_artifacts_source"
223+
model_artifacts_source.mkdir(parents=True, exist_ok=True)
224+
(model_artifacts_source / "artifacts").mkdir(parents=True, exist_ok=True)
225+
(model_artifacts_source / "src").mkdir(parents=True, exist_ok=True)
226+
(model_artifacts_source / "src" / "pipeline.py").write_text("pipeline = None\n")
227+
(model_artifacts_source / "artifacts" / "metadata.json").write_text("{}")
228+
229+
solution = Solution(
230+
solution_id=1,
231+
feature_pipeline=object(),
232+
model=object(),
233+
model_type="xgboost",
234+
model_artifacts_path=model_artifacts_source,
235+
performance=0.8,
236+
)
237+
238+
captured: dict = {}
239+
240+
def _capture_checkpoint(
241+
phase_name,
242+
context,
243+
on_checkpoint_saved,
244+
search_journal=None,
245+
insight_store=None,
246+
):
247+
captured["phase_name"] = phase_name
248+
captured["search_journal"] = search_journal
249+
250+
monkeypatch.setattr(workflow, "_save_phase_checkpoint", _capture_checkpoint)
251+
monkeypatch.setattr(workflow, "generate_model_card", lambda context, final_metrics, evaluation_report: "# Card\n")
252+
253+
package_dir = workflow.package_final_model(
254+
spark=_DummySpark(pd.DataFrame({"feature": [1, 2], "target": [0, 1]})),
255+
context=context,
256+
solution=solution,
257+
final_metrics={"performance": 0.9, "test_samples": 2},
258+
)
259+
260+
assert package_dir.exists()
261+
assert captured["phase_name"] == PhaseNames.PACKAGE_FINAL_MODEL
262+
assert captured["search_journal"] is search_journal

0 commit comments

Comments
 (0)