test_p3_closed_loop.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """P3-M5 integration: real HTTP closed loop with a mock local executor.
  2. Run: python scripts/test_p3_closed_loop.py (exit 0 = PASS)
  3. Spins up the real FastAPI web backend on an isolated temp SQLite DB, drives
  4. the AdaptiveOrchestrator, and lets a real TaskExecutor (mock solver) poll and
  5. execute adaptive batches over HTTP. Verifies the full chain:
  6. orchestrator.start_loop -> task created (HTTP) -> executor claims ->
  7. mock simulation -> report_results (point_id) -> orchestrator.advance_loop
  8. feeds back -> next batch -> ... -> budget exhausted.
  9. Requires: requests, uvicorn, fastapi. No real Motor-CAD.
  10. """
  11. import json
  12. import os
  13. import subprocess
  14. import sys
  15. import tempfile
  16. import time
  17. import urllib.request
  18. _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  19. _BACKEND = os.path.join(_ROOT, "web", "backend")
  20. _SCRIPTS = os.path.join(_ROOT, "scripts")
  21. _TMP = tempfile.mkdtemp(prefix="p3loop_")
  22. _DB = os.path.join(_TMP, "web.db")
  23. _STATE = os.path.join(_TMP, "loops")
  24. _PORT = 8137
  25. _BASE = "http://127.0.0.1:%d" % _PORT
  26. os.environ["AFM_DB_PATH"] = _DB
  27. os.environ["KIMI_API_KEY"] = "" # keep test hermetic: no AI calls
  28. sys.path.insert(0, _BACKEND)
  29. sys.path.insert(0, _SCRIPTS)
  30. sys.path.insert(0, _ROOT) # src.* (l0 re-export) resolves from repo root
  31. from app.database import init_db # noqa: E402
  32. init_db()
  33. from app.services.strategy_orchestrator import AdaptiveOrchestrator # noqa: E402
  34. from app.services.task_manager import get_task_manager # noqa: E402
  35. WEB_ENV = dict(os.environ, AFM_DB_PATH=_DB, AFM_PORT=str(_PORT))
  36. def wait_health(timeout=30):
  37. url = _BASE + "/api/monitor/health"
  38. deadline = time.time() + timeout
  39. while time.time() < deadline:
  40. try:
  41. with urllib.request.urlopen(url, timeout=3) as r:
  42. if r.status == 200:
  43. return True
  44. except Exception:
  45. time.sleep(0.5)
  46. return False
  47. # ---- start web ----
  48. print("[0] starting web backend on :%d (temp DB)" % _PORT, flush=True)
  49. web = subprocess.Popen(
  50. [sys.executable, "-m", "uvicorn", "app.main:app",
  51. "--host", "127.0.0.1", "--port", str(_PORT), "--log-level", "warning"],
  52. cwd=_BACKEND, env=WEB_ENV,
  53. stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
  54. )
  55. try:
  56. assert wait_health(), "web backend did not become healthy"
  57. print("[1] web backend healthy", flush=True)
  58. tm = get_task_manager()
  59. orch = AdaptiveOrchestrator(state_dir=_STATE)
  60. # ---- orchestrator start ----
  61. res = orch.start_loop(
  62. loop_id="loop-http",
  63. parameters=[
  64. {"name": "airgap_mm", "min_value": 0.8, "max_value": 2.0, "step": 0.1, "unit": "mm"},
  65. {"name": "current_a", "min_value": 5.0, "max_value": 20.0, "step": 0.5},
  66. ],
  67. total_budget=8, batch_size=4, initial_samples=4,
  68. objective_metric="tavg_nm", objective_direction="maximize",
  69. )
  70. assert res["phase"] == "running" and res["current_task_id"], res
  71. print("[2] orchestrator started: task=%s" % res["current_task_id"], flush=True)
  72. # ---- local executor (mock) over real HTTP ----
  73. import task_executor as te_mod
  74. te_mod.requests = None if not True else te_mod.requests # keep real requests
  75. from task_executor import TaskExecutor
  76. ex = TaskExecutor(web_base_url=_BASE, enable_mock=True,
  77. executor_id="motorcad-mock-m5")
  78. thread = ex.start_polling(interval=2)
  79. print("[3] mock executor polling started", flush=True)
  80. # ---- drive the loop ----
  81. steps = 0
  82. max_steps = 12
  83. batches = set()
  84. while steps < max_steps:
  85. steps += 1
  86. view = orch.get_loop_status("loop-http")
  87. phase = view["phase"]
  88. if phase in ("converged", "budget_exhausted", "failed"):
  89. print("[4] terminal phase=%s after %d advance steps" % (phase, steps), flush=True)
  90. break
  91. tid = view.get("current_task_id")
  92. if not tid:
  93. view = orch.advance_loop("loop-http")
  94. continue
  95. # wait for the executor to finish this batch over HTTP
  96. deadline = time.time() + 90
  97. while time.time() < deadline:
  98. t = tm.get_task(tid)
  99. if t and t["status"] in ("completed", "failed", "cancelled"):
  100. break
  101. time.sleep(1)
  102. else:
  103. raise RuntimeError("batch task %s did not finish in time" % tid)
  104. view = orch.advance_loop("loop-http")
  105. batches.add(view.get("current_batch"))
  106. print("[5] advance -> batch=%s task=%s phase=%s n_results=%s"
  107. % (view.get("current_batch"), view.get("current_task_id"),
  108. view["phase"], view["n_results"]), flush=True)
  109. ex.stop()
  110. thread.join(timeout=5)
  111. final = orch.get_loop_status("loop-http")
  112. print("[6] FINAL phase=%s batches=%s n_results=%s"
  113. % (final["phase"], sorted(batches), final["n_results"]), flush=True)
  114. assert final["phase"] in ("converged", "budget_exhausted"), final
  115. assert final["n_results"] >= 4, final
  116. ss = final.get("search_state") or {}
  117. assert ss.get("completed_points", 0) >= 4, ss
  118. # every reported point must carry point_id and be fed back
  119. assert ss.get("used_budget", 0) > 0, ss
  120. print("\nALL P3-M5 HTTP CLOSED LOOP TESTS PASSED", flush=True)
  121. finally:
  122. # cleanup
  123. try:
  124. ex.stop()
  125. except Exception:
  126. pass
  127. web.terminate()
  128. try:
  129. web.wait(timeout=10)
  130. except Exception:
  131. web.kill()
  132. print("[cleanup] web stopped", flush=True)