| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147 |
- # -*- coding: utf-8 -*-
- """P5-M3: unit tests for get_state_summary() batch_summary / l0_summary.
- Covers: happy path, post-report aggregation, empty search boundary,
- infeasible-point L0 reasons, objective direction (max/min), and
- unknown point_id report resilience.
- All source is ASCII only. Run: python scripts/test_search_state_summary.py
- exit 0 = PASS.
- """
- import os
- import sys
- import unittest
- _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- _BACKEND = os.path.join(_ROOT, "web", "backend")
- sys.path.insert(0, _BACKEND)
- sys.path.insert(0, _ROOT)
- from app.services.feasibility_search import ( # noqa: E402
- FeasibilityFirstSearch,
- ParameterRange,
- )
- def _make_search(direction="maximize", metric="tavg_nm"):
- """Build a minimal search with one parameter range."""
- return FeasibilityFirstSearch(
- parameters=[
- ParameterRange(name="airgap_mm", min_value=0.5, max_value=2.0, step=0.1),
- ],
- total_budget=20,
- batch_size=4,
- initial_samples=8,
- objective_metric=metric,
- objective_direction=direction,
- seed=42,
- )
- class TestStateSummaryNewFields(unittest.TestCase):
- """P5-M3: batch_summary / l0_summary / infeasible / failed fields."""
- def test_initial_batch_has_new_fields(self):
- """Happy path: after generate_initial_batch, all new fields present."""
- s = _make_search()
- s.generate_initial_batch()
- state = s.get_state_summary()
- for key in ("infeasible_points", "failed_points", "batch_summary", "l0_summary"):
- self.assertIn(key, state, "missing key: %s" % key)
- self.assertIsInstance(state["batch_summary"], list)
- self.assertGreater(len(state["batch_summary"]), 0, "at least batch 0")
- b0 = state["batch_summary"][0]
- for key in ("batch_id", "total", "pending", "ok", "infeasible", "failed", "best_objective"):
- self.assertIn(key, b0, "batch_summary entry missing: %s" % key)
- self.assertEqual(b0["total"], b0["pending"] + b0["ok"] + b0["infeasible"] + b0["failed"])
- l0 = state["l0_summary"]
- for key in ("sampled", "feasible", "infeasible", "pass_rate", "top_infeasible_reasons"):
- self.assertIn(key, l0, "l0_summary missing: %s" % key)
- self.assertEqual(l0["sampled"], state["total_points"])
- self.assertEqual(l0["feasible"] + l0["infeasible"], l0["sampled"])
- def test_report_results_updates_batch_summary(self):
- """Report ok results: batch_summary ok count rises, best_objective set."""
- s = _make_search(direction="maximize")
- pts = s.generate_initial_batch()
- pending = [p for p in pts if p.status == "pending"]
- self.assertGreater(len(pending), 0, "need at least one pending point")
- target = pending[0]
- s.report_result(target.id, {"tavg_nm": 1.5}, "ok")
- state = s.get_state_summary()
- b0 = state["batch_summary"][0]
- self.assertEqual(b0["ok"], 1)
- self.assertEqual(b0["best_objective"], 1.5)
- self.assertEqual(state["completed_points"], 1)
- def test_minimize_direction_best_objective(self):
- """Minimize: best_objective is the minimum reported value."""
- s = _make_search(direction="minimize", metric="total_losses_w")
- pts = s.generate_initial_batch()
- pending = [p for p in pts if p.status == "pending"]
- self.assertGreaterEqual(len(pending), 2, "need 2 pending points")
- s.report_result(pending[0].id, {"total_losses_w": 50.0}, "ok")
- s.report_result(pending[1].id, {"total_losses_w": 30.0}, "ok")
- state = s.get_state_summary()
- self.assertEqual(state["batch_summary"][0]["best_objective"], 30.0)
- def test_failed_point_counted(self):
- """Report failed: failed_points increments, batch_summary failed count."""
- s = _make_search()
- pts = s.generate_initial_batch()
- pending = [p for p in pts if p.status == "pending"]
- self.assertGreater(len(pending), 0)
- s.report_result(pending[0].id, {}, "failed")
- state = s.get_state_summary()
- self.assertEqual(state["failed_points"], 1)
- self.assertEqual(state["batch_summary"][0]["failed"], 1)
- def test_empty_search_boundary(self):
- """Boundary: search created but no batch generated -> empty summaries."""
- s = _make_search()
- state = s.get_state_summary()
- self.assertEqual(state["total_points"], 0)
- self.assertEqual(state["batch_summary"], [])
- self.assertEqual(state["l0_summary"]["sampled"], 0)
- self.assertEqual(state["l0_summary"]["pass_rate"], 0.0)
- self.assertEqual(state["infeasible_points"], 0)
- self.assertEqual(state["failed_points"], 0)
- def test_infeasible_points_l0_reasons_structure(self):
- """Infeasible points (if any) carry top_infeasible_reasons with name/count/category."""
- s = _make_search()
- s.generate_initial_batch()
- state = s.get_state_summary()
- reasons = state["l0_summary"]["top_infeasible_reasons"]
- self.assertIsInstance(reasons, list)
- for r in reasons:
- self.assertIn("name", r)
- self.assertIn("count", r)
- self.assertIn("category", r)
- self.assertGreaterEqual(r["count"], 1)
- def test_unknown_point_id_report_no_crash(self):
- """Resilience: report_result for unknown point_id does not raise."""
- s = _make_search()
- s.generate_initial_batch()
- # Should not raise; implementation may silently ignore or log.
- try:
- s.report_result(999999, {"tavg_nm": 1.0}, "ok")
- except Exception as exc:
- self.fail("report_result unknown id raised: %s" % exc)
- state = s.get_state_summary()
- # Unknown point must not inflate completed counts.
- self.assertEqual(state["completed_points"], 0)
- def test_batch_summary_sorted_by_batch_id(self):
- """batch_summary entries sorted ascending by batch_id."""
- s = _make_search()
- s.generate_initial_batch()
- s.select_next_batch()
- state = s.get_state_summary()
- ids = [b["batch_id"] for b in state["batch_summary"]]
- self.assertEqual(ids, sorted(ids))
- if __name__ == "__main__":
- unittest.main(verbosity=2)
|