test_search_state_summary.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. # -*- coding: utf-8 -*-
  2. """P5-M3: unit tests for get_state_summary() batch_summary / l0_summary.
  3. Covers: happy path, post-report aggregation, empty search boundary,
  4. infeasible-point L0 reasons, objective direction (max/min), and
  5. unknown point_id report resilience.
  6. All source is ASCII only. Run: python scripts/test_search_state_summary.py
  7. exit 0 = PASS.
  8. """
  9. import os
  10. import sys
  11. import unittest
  12. _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  13. _BACKEND = os.path.join(_ROOT, "web", "backend")
  14. sys.path.insert(0, _BACKEND)
  15. sys.path.insert(0, _ROOT)
  16. from app.services.feasibility_search import ( # noqa: E402
  17. FeasibilityFirstSearch,
  18. ParameterRange,
  19. )
  20. def _make_search(direction="maximize", metric="tavg_nm"):
  21. """Build a minimal search with one parameter range."""
  22. return FeasibilityFirstSearch(
  23. parameters=[
  24. ParameterRange(name="airgap_mm", min_value=0.5, max_value=2.0, step=0.1),
  25. ],
  26. total_budget=20,
  27. batch_size=4,
  28. initial_samples=8,
  29. objective_metric=metric,
  30. objective_direction=direction,
  31. seed=42,
  32. )
  33. class TestStateSummaryNewFields(unittest.TestCase):
  34. """P5-M3: batch_summary / l0_summary / infeasible / failed fields."""
  35. def test_initial_batch_has_new_fields(self):
  36. """Happy path: after generate_initial_batch, all new fields present."""
  37. s = _make_search()
  38. s.generate_initial_batch()
  39. state = s.get_state_summary()
  40. for key in ("infeasible_points", "failed_points", "batch_summary", "l0_summary"):
  41. self.assertIn(key, state, "missing key: %s" % key)
  42. self.assertIsInstance(state["batch_summary"], list)
  43. self.assertGreater(len(state["batch_summary"]), 0, "at least batch 0")
  44. b0 = state["batch_summary"][0]
  45. for key in ("batch_id", "total", "pending", "ok", "infeasible", "failed", "best_objective"):
  46. self.assertIn(key, b0, "batch_summary entry missing: %s" % key)
  47. self.assertEqual(b0["total"], b0["pending"] + b0["ok"] + b0["infeasible"] + b0["failed"])
  48. l0 = state["l0_summary"]
  49. for key in ("sampled", "feasible", "infeasible", "pass_rate", "top_infeasible_reasons"):
  50. self.assertIn(key, l0, "l0_summary missing: %s" % key)
  51. self.assertEqual(l0["sampled"], state["total_points"])
  52. self.assertEqual(l0["feasible"] + l0["infeasible"], l0["sampled"])
  53. def test_report_results_updates_batch_summary(self):
  54. """Report ok results: batch_summary ok count rises, best_objective set."""
  55. s = _make_search(direction="maximize")
  56. pts = s.generate_initial_batch()
  57. pending = [p for p in pts if p.status == "pending"]
  58. self.assertGreater(len(pending), 0, "need at least one pending point")
  59. target = pending[0]
  60. s.report_result(target.id, {"tavg_nm": 1.5}, "ok")
  61. state = s.get_state_summary()
  62. b0 = state["batch_summary"][0]
  63. self.assertEqual(b0["ok"], 1)
  64. self.assertEqual(b0["best_objective"], 1.5)
  65. self.assertEqual(state["completed_points"], 1)
  66. def test_minimize_direction_best_objective(self):
  67. """Minimize: best_objective is the minimum reported value."""
  68. s = _make_search(direction="minimize", metric="total_losses_w")
  69. pts = s.generate_initial_batch()
  70. pending = [p for p in pts if p.status == "pending"]
  71. self.assertGreaterEqual(len(pending), 2, "need 2 pending points")
  72. s.report_result(pending[0].id, {"total_losses_w": 50.0}, "ok")
  73. s.report_result(pending[1].id, {"total_losses_w": 30.0}, "ok")
  74. state = s.get_state_summary()
  75. self.assertEqual(state["batch_summary"][0]["best_objective"], 30.0)
  76. def test_failed_point_counted(self):
  77. """Report failed: failed_points increments, batch_summary failed count."""
  78. s = _make_search()
  79. pts = s.generate_initial_batch()
  80. pending = [p for p in pts if p.status == "pending"]
  81. self.assertGreater(len(pending), 0)
  82. s.report_result(pending[0].id, {}, "failed")
  83. state = s.get_state_summary()
  84. self.assertEqual(state["failed_points"], 1)
  85. self.assertEqual(state["batch_summary"][0]["failed"], 1)
  86. def test_empty_search_boundary(self):
  87. """Boundary: search created but no batch generated -> empty summaries."""
  88. s = _make_search()
  89. state = s.get_state_summary()
  90. self.assertEqual(state["total_points"], 0)
  91. self.assertEqual(state["batch_summary"], [])
  92. self.assertEqual(state["l0_summary"]["sampled"], 0)
  93. self.assertEqual(state["l0_summary"]["pass_rate"], 0.0)
  94. self.assertEqual(state["infeasible_points"], 0)
  95. self.assertEqual(state["failed_points"], 0)
  96. def test_infeasible_points_l0_reasons_structure(self):
  97. """Infeasible points (if any) carry top_infeasible_reasons with name/count/category."""
  98. s = _make_search()
  99. s.generate_initial_batch()
  100. state = s.get_state_summary()
  101. reasons = state["l0_summary"]["top_infeasible_reasons"]
  102. self.assertIsInstance(reasons, list)
  103. for r in reasons:
  104. self.assertIn("name", r)
  105. self.assertIn("count", r)
  106. self.assertIn("category", r)
  107. self.assertGreaterEqual(r["count"], 1)
  108. def test_unknown_point_id_report_no_crash(self):
  109. """Resilience: report_result for unknown point_id does not raise."""
  110. s = _make_search()
  111. s.generate_initial_batch()
  112. # Should not raise; implementation may silently ignore or log.
  113. try:
  114. s.report_result(999999, {"tavg_nm": 1.0}, "ok")
  115. except Exception as exc:
  116. self.fail("report_result unknown id raised: %s" % exc)
  117. state = s.get_state_summary()
  118. # Unknown point must not inflate completed counts.
  119. self.assertEqual(state["completed_points"], 0)
  120. def test_batch_summary_sorted_by_batch_id(self):
  121. """batch_summary entries sorted ascending by batch_id."""
  122. s = _make_search()
  123. s.generate_initial_batch()
  124. s.select_next_batch()
  125. state = s.get_state_summary()
  126. ids = [b["batch_id"] for b in state["batch_summary"]]
  127. self.assertEqual(ids, sorted(ids))
  128. if __name__ == "__main__":
  129. unittest.main(verbosity=2)