run_model_probe.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """Reproduce the approved model assessment without changing production files.
  2. Uses the existing SSH alias for read-only baseline inspection and disposable,
  3. resource-limited OSRM containers. Synthetic XML and candidate Lua are generated
  4. only under an owned /tmp directory. No business database or credentials are read.
  5. """
  6. import hashlib
  7. import json
  8. from pathlib import Path
  9. import subprocess
  10. import sys
  11. import urllib.request
  12. VALHALLA_SHA = "34639a33510eb0764da77650e91f6efbb70e8daa"
  13. MAP_REPO = Path("D:/QtwCode/foodie/Foodie_HCM_Map")
  14. OUTPUT = Path(__file__).with_name("model-probe-results.json")
  15. def remote_probe():
  16. """This function executes on the SSH host, with the standard library only."""
  17. import datetime
  18. import math
  19. import tempfile
  20. import time
  21. import urllib.error
  22. import urllib.parse
  23. import xml.etree.ElementTree as ET
  24. image = "ghcr.io/project-osrm/osrm-backend:v5.27.1"
  25. release = Path("/opt/foodie-map-tw-20260909")
  26. production = ["foodie-map-tw-20260909-api-1", "foodie-map-tw-20260909-osrm-1",
  27. "foodie-map-tw-20260909-osrm-motorcycle-1"]
  28. def command(args):
  29. completed = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=90)
  30. if completed.returncode:
  31. raise RuntimeError("Command failed: " + " ".join(args) + "\n" + completed.stdout.decode(errors="replace")[-4000:])
  32. return completed.stdout
  33. def fingerprints():
  34. files = ["map_api/settings.py", "map_api/routing.py", "docker/osrm/profiles/motorcycle.lua"]
  35. hashes = {p: hashlib.sha256((release / p).read_bytes()).hexdigest() for p in files}
  36. for p in ("settings.py", "routing.py"):
  37. data = command(["docker", "exec", production[0], "cat", "/app/" + p])
  38. hashes["container:/app/" + p] = hashlib.sha256(data).hexdigest()
  39. hashes["container:car.lua"] = hashlib.sha256(command(
  40. ["docker", "exec", production[2], "cat", "/opt/car.lua"])).hexdigest()
  41. hashes["built_profile"] = command(["docker", "exec", production[2], "cat",
  42. "/data/profile.sha256"]).decode().strip()
  43. hashes["pbf"] = command(["docker", "exec", production[2], "cat",
  44. "/data/build.sha256"]).decode().strip()
  45. return hashes
  46. def read_url(url):
  47. with urllib.request.urlopen(url, timeout=8) as response:
  48. return json.load(response)
  49. result = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
  50. "image": image, "release": str(release), "before": fingerprints(),
  51. "cases": [], "production_probes": {}, "errors": []}
  52. baseline = (release / "docker/osrm/profiles/motorcycle.lua").read_text()
  53. car_lua = command(["docker", "exec", production[2], "cat", "/opt/car.lua"])
  54. result["combined_profile_matches_graph"] = hashlib.sha256(
  55. car_lua + baseline.encode()).hexdigest() == result["before"]["built_profile"]
  56. for name in ("config", "health/ready"):
  57. result["production_probes"][name] = read_url("http://127.0.0.1:8081/api/" + name)
  58. points = "pickup_lat=25.0330&pickup_lng=121.5654&drop_lat=25.0375&drop_lng=121.5637"
  59. for profile in ("car", "motorcycle"):
  60. for endpoint, params in (("route", points),
  61. ("route/multi", "coords=25.0330,121.5654;25.0375,121.5637")):
  62. route = read_url("http://127.0.0.1:8081/api/" + endpoint + "?" + params + "&profile=" + profile)
  63. result["production_probes"][endpoint + ":" + profile] = {
  64. k: route.get(k) for k in ("profile", "distance_meters", "duration_seconds", "traffic_aware")}
  65. # Probe chosen by this assessment: keep access restrictions; penalize gravel
  66. # through rate (not speed), and add a signal weight (not duration). Neither
  67. # number is a copied Valhalla constant or an approved production calibration.
  68. candidate = baseline.replace(" car.process_way(profile, proxy, result, relations)", """
  69. car.process_way(profile, proxy, result, relations)
  70. if way:get_value_by_key('surface') == 'gravel' then
  71. if result.forward_rate and result.forward_rate > 0 then
  72. result.forward_rate = result.forward_rate * 0.2
  73. end
  74. if result.backward_rate and result.backward_rate > 0 then
  75. result.backward_rate = result.backward_rate * 0.2
  76. end
  77. end""").replace("return {setup=setup, process_way=process_way, process_node=car.process_node, process_turn=car.process_turn}", """
  78. local function process_turn(profile, turn)
  79. car.process_turn(profile, turn)
  80. if turn.has_traffic_light then turn.weight = turn.weight + 8 end
  81. if math.abs(turn.angle) > 60 then turn.weight = turn.weight + 3 end
  82. end
  83. return {setup=setup, process_way=process_way, process_node=car.process_node, process_turn=process_turn}
  84. """)
  85. if candidate == baseline or "turn.weight = turn.weight + 8" not in candidate:
  86. raise RuntimeError("Unexpected baseline shape; no automatic production adaptation.")
  87. result["candidate_sha256"] = hashlib.sha256(candidate.encode()).hexdigest()
  88. root = ET.Element("osm", version="0.6", generator="foodie-isolated-model-assessment")
  89. counter = 0
  90. samples = {}
  91. def node(lat, lon, tags=None):
  92. nonlocal counter
  93. counter += 1
  94. element = ET.SubElement(root, "node", id=str(counter), lat=str(lat), lon=str(lon), version="1")
  95. for key, value in (tags or {}).items():
  96. ET.SubElement(element, "tag", k=key, v=value)
  97. return counter
  98. def way(ids, tags):
  99. nonlocal counter
  100. counter += 1
  101. element = ET.SubElement(root, "way", id=str(counter), version="1")
  102. for identifier in ids:
  103. ET.SubElement(element, "nd", ref=str(identifier))
  104. for key, value in tags.items():
  105. ET.SubElement(element, "tag", k=key, v=value)
  106. return counter
  107. # Independent diamonds ensure a prohibited short road has a real legal
  108. # detour. Inputs use exact shared junctions, avoiding arbitrary snap choices.
  109. def diamond(name, tags, width=0.004, signal=False, detour=True):
  110. lat, lon = 25.1 + len(samples) * 0.01, 121.5
  111. a, b = node(lat, lon), node(lat + 0.002, lon)
  112. mid = node(lat + 0.001, lon, {"highway": "traffic_signals"} if signal else {})
  113. way([a, mid, b], {"highway": "residential", "name": name, **tags})
  114. if detour:
  115. c, d = node(lat, lon + width), node(lat + 0.002, lon + width)
  116. way([a, c, d, b], {"highway": "residential", "name": name + "_detour"})
  117. if signal:
  118. branch = node(lat + 0.001, lon - 0.0003)
  119. way([mid, branch], {"highway": "residential", "name": "signal_branch"})
  120. samples[name] = [(lon, lat), (lon, lat + 0.002)]
  121. diamond("motorcycle_no", {"motorcycle": "no"})
  122. diamond("motorcar_no", {"motorcar": "no", "motorcycle": "yes"})
  123. for road in ("motorway", "motorway_link", "trunk"):
  124. diamond(road, {"highway": road})
  125. diamond("trunk_permitted", {"highway": "trunk", "motorcycle": "yes"})
  126. diamond("motorroad", {"motorroad": "yes"})
  127. diamond("motorroad_permitted", {"motorroad": "yes", "motorcycle": "yes"})
  128. diamond("oneway", {"oneway": "yes"})
  129. diamond("oneway_override", {"oneway": "yes", "oneway:motorcycle": "no"})
  130. diamond("speed_override", {"maxspeed:forward": "90", "maxspeed:motorcycle": "20"}, detour=False)
  131. diamond("gravel_choice", {"highway": "primary", "surface": "gravel"}, width=0.001)
  132. diamond("gravel_forced", {"highway": "primary", "surface": "gravel"}, detour=False)
  133. diamond("signals", {}, signal=True, detour=False)
  134. diamond("conditional_access", {"motorcycle:conditional": "no @ (00:00-24:00)"})
  135. # Turn relation distinguishes real path exclusion from simply assigning a
  136. # higher turn penalty. No two-stage-turn claim is made by this fixture.
  137. def turn_case(name, restriction_key):
  138. nonlocal counter
  139. lat, lon = 25.1 + len(samples) * 0.01, 121.5
  140. a, via, b = node(lat, lon), node(lat + 0.001, lon), node(lat + 0.001, lon + 0.001)
  141. c, d = node(lat + 0.003, lon), node(lat + 0.003, lon + 0.001)
  142. from_way = way([a, via], {"highway": "residential"})
  143. to_way = way([via, b], {"highway": "residential"})
  144. way([via, c, d, b], {"highway": "residential"})
  145. if restriction_key:
  146. counter += 1
  147. relation = ET.SubElement(root, "relation", id=str(counter), version="1")
  148. for kind, ref, role in (("way", from_way, "from"), ("node", via, "via"), ("way", to_way, "to")):
  149. ET.SubElement(relation, "member", type=kind, ref=str(ref), role=role)
  150. ET.SubElement(relation, "tag", k="type", v="restriction")
  151. ET.SubElement(relation, "tag", k=restriction_key, v="no_right_turn")
  152. samples[name] = [(lon, lat + 0.0001), (lon + 0.0009, lat + 0.001)]
  153. turn_case("motorcycle_turn", "restriction:motorcycle")
  154. turn_case("generic_turn", "restriction")
  155. turn_case("unrestricted_turn", None)
  156. # libosmium expects ordered entity types even though generated scenarios
  157. # were appended independently. Keep nodes before ways before relations.
  158. entities = sorted(list(root), key=lambda e: ({"node": 0, "way": 1, "relation": 2}[e.tag], int(e.attrib["id"])))
  159. root[:] = entities
  160. fixture = ET.tostring(root, encoding="utf-8", xml_declaration=True)
  161. samples["offroad"] = [samples["motorcycle_no"][0], (121.7, 25.7)]
  162. samples["disconnected"] = [samples["motorcycle_no"][0], samples["trunk_permitted"][1]]
  163. result["fixture_sha256"] = hashlib.sha256(fixture).hexdigest()
  164. containers = []
  165. urls = {}
  166. def progress(message):
  167. print(message, file=sys.stderr, flush=True)
  168. def route(profile, case, reverse=False):
  169. coords = samples[case][::-1] if reverse else samples[case]
  170. coordinates = ";".join(f"{lon},{lat}" for lon, lat in coords)
  171. url = urls[profile] + "/route/v1/driving/" + coordinates + "?overview=full&geometries=geojson&radiuses=10;10"
  172. try:
  173. data = read_url(url)
  174. except urllib.error.HTTPError as error:
  175. data = json.load(error)
  176. data["test_profile"] = profile
  177. return data
  178. def check(name, condition, observations, kind="assertion"):
  179. row = {"name": name, "kind": kind, "passed": bool(condition), "observations": observations}
  180. result["cases"].append(row)
  181. progress(f"{name}: {'PASS' if condition else 'FAIL'}")
  182. def metric(data):
  183. if data.get("code") != "Ok":
  184. return {"code": data.get("code")}
  185. return {k: data["routes"][0][k] for k in ("distance", "duration", "weight")}
  186. def distance(data):
  187. return data["routes"][0]["distance"] if data.get("code") == "Ok" else math.inf
  188. try:
  189. with tempfile.TemporaryDirectory(prefix="foodie-model-eval-", dir="/tmp") as temp:
  190. workspace = Path(temp)
  191. result["temporary_workspace"] = str(workspace)
  192. for profile, lua in (("car", None), ("baseline", baseline), ("candidate", candidate)):
  193. folder = workspace / profile
  194. folder.mkdir()
  195. (folder / "fixture.osm").write_bytes(fixture)
  196. if lua:
  197. (folder / "model.lua").write_text(lua)
  198. profile_path = "/data/model.lua" if lua else "/opt/car.lua"
  199. base_args = ["docker", "run", "--rm", "--cpus=0.5", "--memory=256m",
  200. "-v", f"{folder}:/data", "--entrypoint", "sh", image]
  201. progress("Building isolated " + profile)
  202. build = "osrm-extract --threads 1 -p " + profile_path + " /data/fixture.osm && osrm-partition --threads 1 /data/fixture.osrm && osrm-customize --threads 1 /data/fixture.osrm"
  203. command(base_args[:3] + ["--network", "none"] + base_args[3:] + ["-c", build])
  204. identifier = command(["docker", "run", "-d", "--rm", "--cpus=0.5", "--memory=256m",
  205. "-p", "127.0.0.1::5000", "-v", f"{folder}:/data:ro", image,
  206. "osrm-routed", "--algorithm", "mld", "--threads", "1", "/data/fixture.osrm"]).decode().strip()
  207. containers.append(identifier)
  208. info = json.loads(command(["docker", "inspect", identifier]))[0]
  209. port = info["NetworkSettings"]["Ports"]["5000/tcp"][0]["HostPort"]
  210. urls[profile] = "http://127.0.0.1:" + port
  211. for _ in range(30):
  212. try:
  213. read_url(urls[profile] + "/nearest/v1/driving/121.5,25.1")
  214. break
  215. except (urllib.error.URLError, TimeoutError):
  216. time.sleep(0.2)
  217. else:
  218. raise RuntimeError("Isolated engine did not start")
  219. for case, blocked in (("motorcycle_no", True), ("motorcar_no", False),
  220. ("motorway", True), ("motorway_link", True), ("trunk", True),
  221. ("trunk_permitted", False), ("motorroad", True),
  222. ("motorroad_permitted", False)):
  223. for profile in ("baseline", "candidate"):
  224. data = route(profile, case)
  225. ok = data.get("code") == "Ok" and (distance(data) > 700 if blocked else distance(data) < 300)
  226. check(profile + ":" + case, ok, metric(data))
  227. car, moto = route("car", "motorcycle_no"), route("baseline", "motorcycle_no")
  228. check("car_vs_motorcycle_access", distance(car) < 300 < distance(moto),
  229. {"car": metric(car), "motorcycle": metric(moto)})
  230. for profile in ("baseline", "candidate"):
  231. forward, backward = route(profile, "oneway"), route(profile, "oneway", True)
  232. check(profile + ":oneway", distance(forward) < 300 and distance(backward) > 700,
  233. {"forward": metric(forward), "reverse": metric(backward)})
  234. override = route(profile, "oneway_override", True)
  235. check(profile + ":oneway_motorcycle_override", distance(override) < 300, metric(override))
  236. for turn in ("motorcycle_turn", "generic_turn"):
  237. data = route(profile, turn)
  238. check(profile + ":" + turn, distance(data) > 500 and data.get("code") == "Ok", metric(data))
  239. speed_car, speed_moto = route("car", "speed_override"), route("baseline", "speed_override")
  240. check("motorcycle_speed_limit", metric(speed_moto).get("duration", 0) > metric(speed_car).get("duration", 0),
  241. {"car": metric(speed_car), "motorcycle": metric(speed_moto)})
  242. for case in ("gravel_forced", "signals", "unrestricted_turn"):
  243. base, proposed = route("baseline", case), route("candidate", case)
  244. base_metric, proposed_metric = metric(base), metric(proposed)
  245. ok = (base.get("code") == proposed.get("code") == "Ok" and
  246. abs(base_metric["duration"] - proposed_metric["duration"]) < 0.2 and
  247. proposed_metric["weight"] > base_metric["weight"])
  248. check(case + ":weight_not_eta", ok, {"baseline": base_metric, "candidate": proposed_metric})
  249. base, proposed = route("baseline", "gravel_choice"), route("candidate", "gravel_choice")
  250. check("gravel_preference_changes_path", distance(base) < 300 and distance(proposed) > 350,
  251. {"baseline": metric(base), "candidate": metric(proposed)})
  252. for profile in ("baseline", "candidate"):
  253. for case, expected in (("offroad", "NoSegment"), ("disconnected", "NoRoute")):
  254. data = route(profile, case)
  255. check(profile + ":" + case, data.get("code") == expected, metric(data))
  256. conditional = route("baseline", "conditional_access")
  257. # A diagnostic, not a hidden assertion that unsupported time rules work.
  258. check("conditional_access_not_enforced", distance(conditional) < 300, metric(conditional), "limitation_probe")
  259. repeated = [*samples["motorcycle_no"], samples["motorcycle_no"][0]]
  260. coords = ";".join(f"{lon},{lat}" for lon, lat in repeated)
  261. multi = read_url(urls["baseline"] + "/route/v1/driving/" + coords + "?overview=false")
  262. check("ordered_three_stop_route", multi.get("code") == "Ok" and len(multi["routes"][0]["legs"]) == 2,
  263. {"legs": len(multi.get("routes", [{}])[0].get("legs", [])), **metric(multi)})
  264. except Exception as error:
  265. result["errors"].append(str(error))
  266. finally:
  267. for identifier in containers:
  268. # Only identifiers created by this run are stopped; no Compose
  269. # lifecycle command can accidentally target a production service.
  270. subprocess.run(["docker", "stop", "--time", "1", identifier], capture_output=True, timeout=15)
  271. result["after"] = fingerprints()
  272. result["production_files_unchanged"] = result["before"] == result["after"]
  273. result["production_containers_running"] = all(
  274. command(["docker", "inspect", "--format", "{{.State.Running}}", name]).decode().strip() == "true"
  275. for name in production)
  276. result["temporary_containers_removed"] = all(
  277. subprocess.run(["docker", "inspect", identifier], capture_output=True).returncode != 0
  278. for identifier in containers)
  279. result["temporary_workspace_removed"] = not Path(result.get("temporary_workspace", "/nonexistent")).exists()
  280. print(json.dumps(result, ensure_ascii=False))
  281. def main():
  282. import inspect
  283. sources = {}
  284. for path in ("src/sif/motorcyclecost.cc", "src/sif/motorscootercost.cc", "LICENSE.md", "COPYING"):
  285. url = f"https://raw.githubusercontent.com/valhalla/valhalla/{VALHALLA_SHA}/{path}"
  286. with urllib.request.urlopen(url, timeout=20) as response:
  287. data = response.read()
  288. sources[path] = {"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
  289. refs = {}
  290. for ref in ("HEAD", "origin/taiwan-map-profiles"):
  291. refs[ref] = subprocess.check_output(["git", "rev-parse", ref], cwd=MAP_REPO).decode().strip()
  292. lua = subprocess.check_output(["git", "show", "origin/taiwan-map-profiles:docker/osrm/profiles/motorcycle.lua"], cwd=MAP_REPO)
  293. branch_files = {}
  294. for ref in refs:
  295. branch_files[ref] = {}
  296. for path in ("map_api/settings.py", "map_api/routing.py", "docker/osrm/profiles/motorcycle.lua"):
  297. snapshot = subprocess.run(["git", "show", ref + ":" + path], cwd=MAP_REPO,
  298. stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
  299. branch_files[ref][path] = hashlib.sha256(snapshot.stdout).hexdigest() if snapshot.returncode == 0 else None
  300. references = {"valhalla_commit": VALHALLA_SHA, "sources": sources, "map_refs": refs, "branch_files": branch_files,
  301. "branch_lua_sha256": hashlib.sha256(lua).hexdigest()}
  302. source = "import hashlib,json,subprocess,sys,urllib.request\nfrom pathlib import Path\n" + inspect.getsource(remote_probe) + "\nremote_probe()\n"
  303. process = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "tokyo-map", "python3 -"],
  304. input=source, text=True, encoding="utf-8", stdout=subprocess.PIPE, timeout=360)
  305. if process.returncode:
  306. raise RuntimeError("Remote assessment failed before producing a complete result")
  307. result = json.loads(process.stdout)
  308. result["references"] = references
  309. result["branch_lua_matches_deployment"] = references["branch_lua_sha256"] == result["before"]["docker/osrm/profiles/motorcycle.lua"]
  310. # Generated machine evidence, not a manually edited source/configuration.
  311. OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  312. failures = [row["name"] for row in result["cases"] if not row["passed"]]
  313. print(json.dumps({"cases": len(result["cases"]), "failures": failures, "errors": result["errors"],
  314. "files_unchanged": result["production_files_unchanged"], "evidence": str(OUTPUT)}, ensure_ascii=False))
  315. if failures or result["errors"] or not all(result[key] for key in
  316. ("combined_profile_matches_graph", "branch_lua_matches_deployment", "production_files_unchanged",
  317. "production_containers_running", "temporary_containers_removed", "temporary_workspace_removed")):
  318. sys.exit(1)
  319. if __name__ == "__main__":
  320. main()