test_p3_adaptive_execution.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """P3-M6 regression: web AdaptiveLoop closed loop + executor bridge.
  2. Validates the complete adaptive closed loop on the web side (AdaptiveLoop),
  3. including the new submit_batch_to_executor bridge that wraps the current
  4. pending batch into an adaptive_batch task for the local executor:
  5. fake plan -> initialize_search (initial batch)
  6. -> submit_batch_to_executor (create task)
  7. -> fake executor runs the task and reports results
  8. -> report_results feeds the search back
  9. -> get_next_batch -> ... -> until budget exhausted / converged
  10. Run: python scripts/test_p3_adaptive_execution.py (exit 0 = PASS)
  11. Uses an isolated temp SQLite DB and KIMI_API_KEY="" so no AI backend is
  12. contacted. No real Motor-CAD is involved.
  13. """
  14. import json
  15. import os
  16. import sys
  17. import tempfile
  18. _TMP = os.path.join(tempfile.mkdtemp(), "test_afm.db")
  19. os.environ["AFM_DB_PATH"] = _TMP
  20. os.environ["KIMI_API_KEY"] = ""
  21. sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web", "backend"))
  22. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  23. from app.database import init_db # noqa: E402
  24. init_db()
  25. from app.services.adaptive_loop import create_loop # noqa: E402
  26. from app.services.task_manager import get_task_manager # noqa: E402
  27. tm = get_task_manager()
  28. # ------------------------------------------------------------------ 1. create
  29. loop = create_loop(
  30. user_requirement="maximize average torque within feasibility constraints",
  31. total_budget=8,
  32. batch_size=4,
  33. )
  34. # Inject a fake plan to bypass AI plan generation (parameters are the L0-known
  35. # names so the feasibility pre-screening matches).
  36. loop.plan = {
  37. "plan_name": "p3-fake",
  38. "topology": "SSSR",
  39. "scan_variables": [
  40. {"name": "airgap_mm", "min_value": 0.8, "max_value": 2.0, "step": 0.1, "unit": "mm"},
  41. {"name": "current_a", "min_value": 5.0, "max_value": 20.0, "step": 0.5, "unit": "A"},
  42. ],
  43. "search_strategy": {"max_solver_calls": 8, "batch_size": 4, "initial_samples": 4},
  44. "acceptance_criteria": {
  45. "objective_metric": "tavg_nm",
  46. "objective_direction": "maximize",
  47. "hard_constraints": [],
  48. },
  49. }
  50. # ---------------------------------------------------------- 2. init search
  51. res = loop.initialize_search()
  52. assert res["phase"] == "search_initialized", res
  53. init_points = res["initial_batch"]
  54. assert len(init_points) > 0, res
  55. init_ids = sorted(p["id"] for p in init_points)
  56. print("[1] initialize_search OK: initial_batch=%d ids=%s" % (len(init_points), init_ids))
  57. # --------------------------------------------- 3. submit-batch (new bridge)
  58. sub = loop.submit_batch_to_executor()
  59. tid = sub["task_id"]
  60. assert tid, sub
  61. assert sub["n_points"] == len(init_points), sub
  62. assert loop.phase.value == "simulation_running", loop.phase
  63. task = tm.get_task(tid)
  64. assert task is not None, tid
  65. assert task["task_type"] == "adaptive_batch", task
  66. assert task["loop_id"] == loop.loop_id, task
  67. assert sorted(task["point_ids"]) == init_ids, (task["point_ids"], init_ids)
  68. print("[2] submit_batch_to_executor OK: task=%s points=%d type=%s"
  69. % (tid, sub["n_points"], task["task_type"]))
  70. def run_batch(tid):
  71. """Fake local executor: read the task parameters and fabricate metrics."""
  72. task = tm.get_task(tid)
  73. with open(task["task_file"], "r", encoding="utf-8") as f:
  74. payload = json.load(f)
  75. results = []
  76. for i, params in enumerate(payload["parameters"]):
  77. pid = params.get("point_id")
  78. results.append({
  79. "point_id": pid,
  80. "point_index": i,
  81. "params": params,
  82. "metrics": {"tavg_nm": round(8.0 + 0.5 * pid, 3), "efficiency_pct": 90.0 + (pid % 5)},
  83. "status": "OK",
  84. })
  85. tm.report_results(tid, results, status="completed")
  86. return results
  87. # --------------------------------------------------------- 4. drive the loop
  88. steps = 0
  89. max_steps = 12
  90. batches = set()
  91. all_tids = []
  92. while steps < max_steps:
  93. steps += 1
  94. sub = loop.submit_batch_to_executor()
  95. if not sub["task_id"]:
  96. # nothing pending: ask the search for the next batch
  97. nb = loop.get_next_batch()
  98. if not nb.get("points"):
  99. break
  100. sub = loop.submit_batch_to_executor()
  101. if not sub["task_id"]:
  102. break
  103. batches.add(sub["batch_id"])
  104. all_tids.append(sub["task_id"])
  105. results = run_batch(sub["task_id"])
  106. point_results = [
  107. {"point_id": r["point_id"], "metrics": r["metrics"], "status": "ok"}
  108. for r in results
  109. ]
  110. loop.report_results(point_results)
  111. print("[3] step %d batch=%s n_points=%d phase=%s"
  112. % (steps, sub["batch_id"], len(point_results), loop.phase.value))
  113. comp = loop.check_completion()
  114. if comp["completed"]:
  115. break
  116. final_phase = loop.phase.value
  117. state = loop.search.get_state_summary()
  118. print("[4] FINAL phase=%s batches=%s completed_points=%s used_budget=%s"
  119. % (final_phase, sorted(batches), state.get("completed_points"), state.get("used_budget")))
  120. assert final_phase in ("budget_exhausted", "converged", "completed"), final_phase
  121. assert state.get("completed_points", 0) >= 4, state
  122. assert state.get("used_budget", 0) > 0, state
  123. assert len(batches) >= 1, batches
  124. # ----------------------------------------------------------------- 5. checks
  125. # every submitted batch task must be persisted with the adaptive fields
  126. adaptive_tasks = [tm.get_task(t) for t in all_tids]
  127. assert len(adaptive_tasks) == len(batches) == len(all_tids), (len(adaptive_tasks), len(batches))
  128. for t in adaptive_tasks:
  129. assert t is not None, "task missing"
  130. assert t.get("task_type") == "adaptive_batch", t
  131. assert t.get("loop_id") == loop.loop_id, t
  132. assert t.get("dynamic") is True, t
  133. print("[5] %d adaptive_batch tasks persisted with loop_id/dynamic fields" % len(adaptive_tasks))
  134. # submitted-batch bridge must be idempotent: second call with no pending
  135. # points returns task_id=None instead of creating a duplicate task.
  136. dup = loop.submit_batch_to_executor()
  137. assert dup.get("task_id") is None, dup
  138. print("[6] submit-batch idempotency OK (no duplicate task on no pending batch)")
  139. print("\nALL P3-M6 ADAPTIVE EXECUTION BRIDGE TESTS PASSED")