tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1514 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan model build`/`tan model doctor`/`tan model check`/`tan model list` --
3
+ compile + package `board.yaml`'s `models:` block into `.alpmodel` packages,
4
+ report NPU-compiler toolchain availability, statically screen a declared
5
+ model's NPU eligibility against the SoM's own support tables, list what
6
+ is declared next to what is already built, and browse/adopt the SDK's model
7
+ zoo (`zoo`, `add`).
8
+
9
+ Port of `scripts/alp_cli/model.py` (51 lines): the board.yaml discovery,
10
+ per-model source/compile-option path resolution, and the `built <path>`
11
+ summary all move here, in-process, exactly as they read there. Also
12
+ in-process, since ADR-0028: `tan.model.build.build_model` itself -- the
13
+ compiler-adapter engine (CPU/Vela/DRP-AI/DeepX) that does the actual work,
14
+ relocated verbatim from alp-sdk's `scripts/alp_model/` into `tan.model`. This
15
+ command calls it directly, per model, no subprocess involved.
16
+
17
+ Earlier revisions of this file spawned a `python -c` driver under the SDK
18
+ checkout's own interpreter, on the premise that the vendor NPU-compiler
19
+ tooling was only reachable from that checkout's Python environment. That
20
+ premise was never accurate: every adapter resolves its own tool with
21
+ `shutil.which` and spawns an external binary -- `adapters/ethos_u.py`
22
+ (`shutil.which("vela")`, `cmd = ["vela", ...]`), `adapters/deepx.py`
23
+ (`shutil.which("dxcom")`, `cmd = ["dxcom", "-m", ...]`), `adapters/drpai.py`
24
+ likewise -- so what building a model actually needs is `vela`/`dxcom` on
25
+ PATH, a host fact, not a checkout fact. The one genuine Python-environment
26
+ dependency, `adapters/ethos_u.py`'s `_vela_version()`, already degrades
27
+ gracefully (`except PackageNotFoundError: return "vela"`), costing a less
28
+ precise `compiler_version` string and nothing else. **This module still
29
+ resolves the SDK root** (via `resolve_metadata_sdk_root`) -- not for its
30
+ Python, but because `tan.model.build.build_model` reads alp-sdk's
31
+ `metadata/**` at call time (ADR-0017: metadata stays in alp-sdk).
32
+
33
+ This is a REAL implementation, not a forward: it never spawns `python -m
34
+ alp_cli`, so `alp_cli` stops being load-bearing for `tan model` (the point of
35
+ this port -- see `crates/tan-cli/src/commands/sdk_cli.rs`'s module doc for
36
+ what it is replacing).
37
+
38
+ **Deliberate divergence 1 from the oracle survives ADR-0028**: `alp_cli/
39
+ model.py` has no try/except around `build_model()` at all, so a build failure
40
+ (e.g. "no blob compiled for model") tracebacks the whole click command. Every
41
+ command in this port instead resolves to a coded issue, never a traceback
42
+ (the established rule -- see `generate_cmd`'s module doc) -- so a per-model
43
+ failure here is caught around the direct `build_model()` call and reported as
44
+ a `model.build-failed` issue, and the run continues to the next model rather
45
+ than aborting the whole batch.
46
+
47
+ **Deliberate divergence 2 from the oracle is RETIRED with the driver it
48
+ existed for.** It used to guard against a spawned driver that exited 0 having
49
+ silently produced no result for a declared model -- a failure mode only a
50
+ subprocess boundary could produce. With `build_model()` called directly,
51
+ in-process, per model, there is no boundary across which a result can go
52
+ missing: either the call returns a path (recorded as built) or raises
53
+ (caught and reported as `model.build-failed`). There is no third outcome left
54
+ to guard against, so the guard is gone, not silently dropped.
55
+
56
+ **`doctor` is the diagnostic half ADR-0028 makes tan responsible for.** With
57
+ the compiler-adapter engine relocated here, tan is the customer's only
58
+ surface for "why did my compile produce nothing" -- `model doctor` answers
59
+ that ahead of a build by reporting, per registered backend, whether its
60
+ toolchain is installed and, if not, an ACTIONABLE reason (`tan.core.
61
+ model_doctor`). It is READ-ONLY: every adapter's `is_available()` is a
62
+ `shutil.which`/env-var check today, never a spawn, and `_run_doctor` below
63
+ never calls one that isn't. `_run_doctor` does not always take that
64
+ `is_available()` verdict verbatim, though: `deepx_dxm1` and `drpai` each get
65
+ a NARROWER doctor-side probe (`_deepx_dxm1_status`/`_drpai_status` below)
66
+ that checks what `compile()` actually reads/spawns, because each adapter's
67
+ `is_available()` ORs in a signal `compile()` never acts on -- reporting a row
68
+ green there meant the very next `model build` failed anyway. Still read-only,
69
+ still never a spawn. An unavailable toolchain is the expected, common
70
+ case, not a failure -- `_run_doctor` always resolves to `ExitCode.SUCCESS`;
71
+ reporting absence is the feature, not a reason to fail the run. A board.yaml
72
+ and a resolvable alp-sdk checkout are NOT required: this command intends to
73
+ run on the exact host where something else already broke, so an unresolved
74
+ `--sdk-root` is folded into a `model.doctor-sdk-unresolved` WARNING rather
75
+ than the `model.sdk-root-unresolved` ERROR `build` uses for the same
76
+ situation -- the backend rows below it are unaffected by a missing SDK either
77
+ way, since none of them reads `metadata/**`.
78
+
79
+ `doctor` also reports OPTIONAL prerequisites, in `data.optional[]` and in the
80
+ same five-key row shape -- today one: the vendor vela config `.ini` a licensed
81
+ customer names in `ALP_VELA_CONFIG` (`_vela_vendor_config_status`). Kept out of
82
+ `backends[]` deliberately: `available: false` there means tan cannot compile
83
+ for that backend, while an absent vendor `.ini` means the backend works and an
84
+ enhancement is not installed. Without it vela uses Arm's own built-in system
85
+ config, which is what the arena/SRAM figures tan reports already describe, so
86
+ neither the JSON nor the text line may read as a fault.
87
+
88
+ **`check` is the static NPU-eligibility screen ADR-0028's amendment adds
89
+ (tan-cli#782).** `tan.model.check`/`tan.model.analyze` do the actual work
90
+ (resolving @sku's real NPU backends, walking a model's ops, scoring them);
91
+ this module only resolves the board.yaml the same way `build` does, calls
92
+ that engine per declared model, and shapes the envelope. `check`'s exit code
93
+ is ALWAYS `SUCCESS` for a run that completed -- `partial`/`cpu-only`/
94
+ `undetermined` are the feature this command exists to report, never a
95
+ failure. See `tan.model.check`'s own module doc for `--exact`.
96
+
97
+ **`list` is the smallest of the four (tan-cli#674): what `board.yaml`
98
+ declares, next to what `--out` already holds for each one.** It mirrors
99
+ `doctor`'s SDK-root TOLERANCE, not `build`'s `_require_metadata_sdk_root`
100
+ refusal -- naming what is declared and what is already built on disk needs no
101
+ `metadata/**` at all, so `_run_list` itself never resolves, refuses, or warns
102
+ over a *metadata* SDK root the way `_run_build`/`_run_check`/`_run_doctor` do
103
+ (no `--sdk-root`/`--metadata-root` handling inside it, no `model.sdk-root-
104
+ unresolved`/`model.doctor-sdk-unresolved`).
105
+
106
+ **That is narrower than "never resolves or warns about an SDK root" (tan-cli#674
107
+ review MAJOR 2)** -- `list` still goes through the SAME shared project-context
108
+ preamble every subcommand here does (`resolve_project_context`, below), and
109
+ that preamble's own resolution is what populates the envelope's `sdk` block
110
+ and what `sdk_resolution_issues` reads to warn about a broken `.alp/sdk-path`
111
+ pin or a foreign global default. Measured: `tan model list --format json` from
112
+ a workspace with a resolvable alp-sdk checkout emits a real `"sdk":{"root":
113
+ ...,"sourceTier":...}` block, and `test_a_broken_project_pin_is_reported_on_a_
114
+ model_list` (`tests/commands/test_model_list_command.py`) already pins a
115
+ `sdk.project-pin-unresolved` warning on this exact subcommand. `list` opts out
116
+ of only the SECOND, metadata-reading resolution `build`/`check`/`doctor`
117
+ additionally perform -- not the first.
118
+
119
+ It is read-only and spawns nothing: a declared model's `.alpmodel`
120
+ (`tan.model.build.build_model`'s own `{name}.alpmodel` naming) is only ever
121
+ `stat()`-ed and its manifest read back, never compiled. See
122
+ `tan.core.model_list`'s own module doc for the per-model `artifact` shape,
123
+ including `stale` (has `source` changed since the package on disk was built)
124
+ and the `model.artifact-stale-unknown` warning a readback failure there emits.
125
+
126
+ **`zoo` and `add` (tan-cli#1286)** read `<sdk>/metadata/model_zoo/` -- alp-sdk
127
+ owns that data, tan the engine (`tan.core.model_zoo`). Their runners and text
128
+ renderers live in `tan.commands.model_zoo_cmd` (this file is over its size
129
+ budget); both need a resolvable SDK (`model.sdk-root-unresolved`, as `build`/
130
+ `check`), and `add` additionally reads `board.yaml` and appends to its
131
+ `models:` with a comment-preserving text splice (`tan.core.board_yaml_edit`).
132
+
133
+ **`prep` (tan-cli#1287, host tier)** INT8-quantizes an ONNX model against a
134
+ calibration directory and reports the fp32-vs-int8 accuracy delta; it needs the
135
+ optional `model` extra (`tan.core.model_host`) and no SDK, board.yaml or
136
+ hardware. Runner and renderer: `tan.commands.model_host_cmd`.
137
+
138
+ **`run --device` / `ab --device` (tan-cli#1287, on-device tier)** parse a benchmark
139
+ app's console into the same envelope with `tier: device`
140
+ (`tan.commands.model_device_cmd`): from `--capture FILE`, or live -- without
141
+ `--capture` the already-built `diagnostics.link: itcm` project is RAM-run through
142
+ `tan flash --ram`'s Flow C (`--confirm`, never MRAM) and its `ram_console_buf`
143
+ parsed (`tan.commands.model_device_live`, rows carry `source: live`).
144
+
145
+ **`run` / `ab` (tan-cli#1287, host tier)** time an ONNX model on onnxruntime CPU
146
+ (`backend: cpu-host`) and compare two models -- host references, never SoM
147
+ performance; the on-device tier is bench-gated and not yet ported.
148
+ """
149
+
150
+ from __future__ import annotations
151
+
152
+ import os
153
+ import shutil
154
+ import sys
155
+ from pathlib import Path
156
+ from typing import Any
157
+
158
+ import typer
159
+
160
+ from tan.commands.build_output import (
161
+ ProjectContext,
162
+ resolve_metadata_sdk_root,
163
+ resolve_project_context,
164
+ )
165
+ from tan.commands.model_zoo_cmd import (
166
+ add_empty_data,
167
+ render_add_text,
168
+ render_zoo_text,
169
+ run_add,
170
+ run_zoo,
171
+ zoo_empty_data,
172
+ )
173
+ from tan.commands.model_device_cmd import run_device_ab, run_device_run
174
+ from tan.commands.model_device_live import LiveOptions
175
+ from tan.commands.model_host_cmd import (
176
+ ab_empty_data,
177
+ prep_empty_data,
178
+ render_ab_text,
179
+ render_prep_text,
180
+ render_run_text,
181
+ run_ab,
182
+ run_empty_data,
183
+ run_prep,
184
+ run_run,
185
+ )
186
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
187
+ from tan.core.sdk_discovery import with_sdk_search
188
+ from tan.core.global_flags import accept_global_flags
189
+ from tan.core.model_check import backend_report_as_dict, render_check_text
190
+ from tan.core.model_doctor import backend_row, optional_row, registry_backends
191
+ from tan.core.model_list import declared_sku, list_entry, render_list_text
192
+ from tan.core.sdk_discovery import sdk_resolution_issues
193
+ from tan.core.shapes import rejected_sdk_root_message
194
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
195
+ from tan.exit_codes import ExitCode
196
+ from tan.model.adapters.drpai import _compiler_version as _drpai_compiler_version
197
+ from tan.model.adapters.drpai import _tvm_home as _drpai_tvm_home
198
+ from tan.model.adapters.ethos_u import _VELA_CONFIG_ENV, _vela_version, _vendor_config_path
199
+ from tan.model.build import SramNoFitRefused, _ADAPTERS, _NAME_RE, build_model
200
+ from tan.model.check import check_model_backends, resolve_check_backends
201
+ from tan.model.package import read_manifest_file
202
+ from tan.output_format import FORMAT_HELP, OutputFormat
203
+
204
+ #: `data.schemaVersion` for `build`'s payload.
205
+ DATA_SCHEMA_VERSION = "1"
206
+
207
+ #: `data.schemaVersion` for `doctor`'s payload -- versioned independently of
208
+ #: `build`'s (a different `data` shape entirely: `backends[]`, not `built[]`).
209
+ DOCTOR_DATA_SCHEMA_VERSION = "1"
210
+
211
+ #: `data.schemaVersion` for `check`'s payload -- versioned independently of
212
+ #: the other two for the same reason (`models[].backends[]`, a third shape).
213
+ CHECK_DATA_SCHEMA_VERSION = "1"
214
+
215
+ #: `data.schemaVersion` for `list`'s payload -- versioned independently of
216
+ #: the other three (`models[].artifact`, a fourth shape).
217
+ LIST_DATA_SCHEMA_VERSION = "1"
218
+
219
+ #: `SUBCOMMANDS` names every subcommand this command accepts, in the order the
220
+ #: unknown-subcommand refusal lists them.
221
+ SUBCOMMANDS = ("build", "doctor", "check", "list", "zoo", "add", "prep", "run", "ab")
222
+
223
+
224
+ class ModelError(Exception):
225
+ """A refusal whose issue code and exit code are already decided."""
226
+
227
+ def __init__(self, code: str, message: str, exit_code: ExitCode) -> None:
228
+ super().__init__(message)
229
+ self.code = code
230
+ self.message = message
231
+ self.exit_code = exit_code
232
+
233
+
234
+ #: Compile-opt keys that name a filesystem path (resolved relative to
235
+ #: board.yaml). Not every value in a models[].compile.<backend> block is a
236
+ #: path -- e.g. drpai's input_shape ("1,3,224,224"), input_name ("images") and
237
+ #: product ("V2N") are opaque strings that must reach the adapter unchanged
238
+ #: (alp-sdk#1271: resolving them as paths corrupted a genuine shape string into
239
+ #: a filesystem path, which then made the adapter's own shape check misfire).
240
+ _PATH_OPT_KEYS = {"config", "calibration", "images", "spec"}
241
+
242
+
243
+ def _resolve_compile(block: dict | None, base: Path) -> dict | None:
244
+ """Port of `model.py::_resolve_compile`: resolve known path-valued keys
245
+ in each per-backend compile block to an absolute path relative to the
246
+ `board.yaml` dir; every other value (shape strings, node names, product
247
+ ids, ...) passes through unchanged (alp-sdk#1271)."""
248
+ if not block:
249
+ return None
250
+ return {
251
+ backend: {
252
+ k: (str((base / v).resolve()) if k in _PATH_OPT_KEYS and isinstance(v, str) else v)
253
+ for k, v in (opts or {}).items()
254
+ }
255
+ for backend, opts in block.items()
256
+ }
257
+
258
+
259
+ def _load_board(path: Path) -> dict[str, Any]:
260
+ """`board.yaml` as a dict, or a `ModelError` for every way that can fail --
261
+ missing file, bad encoding, not YAML, not a mapping. `yaml.safe_load`,
262
+ matching the oracle's own parse exactly (unlike `system_manifest`'s
263
+ core-schema loader, there is no serde_yaml parity requirement here)."""
264
+ try:
265
+ text = path.read_text(encoding="utf-8")
266
+ except OSError as err:
267
+ raise ModelError(
268
+ "model.board-yaml-missing",
269
+ f"board.yaml not found at {path}: {err}",
270
+ ExitCode.VALIDATION_FAILURE,
271
+ ) from err
272
+ except UnicodeDecodeError as err:
273
+ # tan-cli#396: `UnicodeDecodeError` is a `ValueError`, NOT an
274
+ # `OSError`, so `except OSError` alone could never catch it -- one
275
+ # undecodable byte in the customer's own board.yaml escaped this
276
+ # whole command as a traceback: measured `exitCode: 5` /
277
+ # `model.internal-failure` / "model build failed unexpectedly:
278
+ # UnicodeDecodeError: ...", which tells a script "tan broke" rather
279
+ # than "your board.yaml has a bad byte". The file opened and read
280
+ # fine, so `board-yaml-missing` would send the customer looking for
281
+ # the wrong problem -- this is the same unusable-input class the
282
+ # YAML-syntax and not-a-mapping arms below already land on.
283
+ #
284
+ # `model` has no Rust oracle to defer to here (unlike `kconfig`,
285
+ # which folds this same byte-for-byte case into `board-yaml-missing`
286
+ # to mirror `read_to_string`'s single `io::Error` arm) -- a merge
287
+ # briefly folded this arm into the OSError one above on that other
288
+ # command's precedent (tan-cli#415), which made this whole `except`
289
+ # unreachable dead code and silently reverted the tan-cli#396 fix.
290
+ # Restored as its own arm: this file's own established
291
+ # classification, not an oracle's.
292
+ raise ModelError(
293
+ "model.board-yaml-invalid",
294
+ f"{path}: not valid UTF-8: {err}",
295
+ ExitCode.VALIDATION_FAILURE,
296
+ ) from err
297
+ try:
298
+ import yaml # noqa: PLC0415 (declared dependency, guarded anyway)
299
+ except ImportError as err:
300
+ raise ModelError(
301
+ "model.internal-failure",
302
+ f"no YAML parser available ({err}); install PyYAML (`pip install pyyaml`).",
303
+ ExitCode.INTERNAL_FAILURE,
304
+ ) from err
305
+ try:
306
+ doc = yaml.safe_load(text)
307
+ except Exception as err: # noqa: BLE001 -- any PyYAML failure is bad input
308
+ raise ModelError(
309
+ "model.board-yaml-invalid", f"{path}: {err}", ExitCode.VALIDATION_FAILURE
310
+ ) from err
311
+ if not isinstance(doc, dict):
312
+ raise ModelError(
313
+ "model.board-yaml-invalid",
314
+ f"{path}: expected a YAML mapping at the top level.",
315
+ ExitCode.VALIDATION_FAILURE,
316
+ )
317
+ return doc
318
+
319
+
320
+ def _require_sku(board_doc: dict, board_path: Path) -> str:
321
+ """`board.yaml`'s `som.sku` -- shared by `build` and `check`, both of
322
+ which need a real SKU before they can resolve anything metadata-shaped
323
+ (a compile target, or a NPU support table)."""
324
+ som = board_doc.get("som")
325
+ sku = som.get("sku") if isinstance(som, dict) else None
326
+ if not isinstance(sku, str) or not sku:
327
+ raise ModelError(
328
+ "model.board-yaml-invalid",
329
+ f"{board_path}: som.sku is missing.",
330
+ ExitCode.VALIDATION_FAILURE,
331
+ )
332
+ return sku
333
+
334
+
335
+ def _require_models_list(board_doc: dict, board_path: Path) -> list:
336
+ """`board.yaml`'s `models:` -- `[]` when absent (a board that declares no
337
+ models is valid, not an error), a `ModelError` when present but not a
338
+ list."""
339
+ models = board_doc.get("models") or []
340
+ if not isinstance(models, list):
341
+ raise ModelError(
342
+ "model.board-yaml-invalid",
343
+ f"{board_path}: `models:` must be a list.",
344
+ ExitCode.VALIDATION_FAILURE,
345
+ )
346
+ return models
347
+
348
+
349
+ def _require_model_entry(m: Any, board_path: Path) -> None:
350
+ """Every `models:` entry needs `name` and `source` -- `build`'s own
351
+ per-model `compile:` block is optional and stays that command's own
352
+ concern; this is the shape floor both `build` and `check` share."""
353
+ if not isinstance(m, dict) or "name" not in m or "source" not in m:
354
+ raise ModelError(
355
+ "model.board-yaml-invalid",
356
+ f"{board_path}: every `models:` entry needs `name` and `source`.",
357
+ ExitCode.VALIDATION_FAILURE,
358
+ )
359
+ # tan-cli#1486: type-check at the shape floor so a malformed entry is the
360
+ # user's board.yaml's fault (exit 2), not `model.internal-failure`, and a
361
+ # traversal name never reaches `list`'s `<out>/<name>.alpmodel` stat.
362
+ name, source, compile_block = m["name"], m["source"], m.get("compile")
363
+ if not isinstance(name, str) or not _NAME_RE.fullmatch(name):
364
+ raise ModelError(
365
+ "model.board-yaml-invalid",
366
+ f"{board_path}: `models:` entry name {name!r} must be a string matching "
367
+ f"{_NAME_RE.pattern!r}.",
368
+ ExitCode.VALIDATION_FAILURE,
369
+ )
370
+ if not isinstance(source, str) or not source:
371
+ raise ModelError(
372
+ "model.board-yaml-invalid",
373
+ f"{board_path}: `models:` entry '{name}': `source` must be a non-empty path string.",
374
+ ExitCode.VALIDATION_FAILURE,
375
+ )
376
+ if compile_block is not None and (
377
+ not isinstance(compile_block, dict)
378
+ or any(o is not None and not isinstance(o, dict) for o in compile_block.values())
379
+ ):
380
+ raise ModelError(
381
+ "model.board-yaml-invalid",
382
+ f"{board_path}: `models:` entry '{name}': `compile:` must be a mapping of "
383
+ "backend -> options mapping.",
384
+ ExitCode.VALIDATION_FAILURE,
385
+ )
386
+
387
+
388
+ def _require_metadata_sdk_root(sdk_root: str | None, workspace_root: str, no_models_msg: str) -> Path:
389
+ """`resolve_metadata_sdk_root`, or a coded `model.sdk-root-unresolved`
390
+ refusal -- shared by `build` and `check`, whose only difference is the
391
+ trailing "no models were {built,checked}" clause (tan-cli#497 defect 7:
392
+ a REJECTED `--sdk-root` names the value it rejected)."""
393
+ resolved_sdk = resolve_metadata_sdk_root(sdk_root, workspace_root)
394
+ if resolved_sdk is None:
395
+ raise ModelError(
396
+ "model.sdk-root-unresolved",
397
+ rejected_sdk_root_message(sdk_root, no_models_msg)
398
+ if sdk_root
399
+ # `tan sdk switch` refuses in this build (tan-cli#305) -- kept the
400
+ # two mechanisms that actually work here (`--sdk-root`, placing
401
+ # the project near a checkout) and swapped the third for
402
+ # NO_SDK_NEXT_STEPS's honest "how to get one at all".
403
+ else with_sdk_search(
404
+ "alp-sdk root is unresolved. Use --sdk-root, place the project near an "
405
+ f"alp-sdk checkout, or {NO_SDK_NEXT_STEPS}.",
406
+ workspace_root,
407
+ ),
408
+ ExitCode.VALIDATION_FAILURE,
409
+ )
410
+ return resolved_sdk
411
+
412
+
413
+ def _resolve_metadata_dir(metadata_root: str | None, resolved_sdk: Path, workspace_root: Path) -> Path:
414
+ """`<sdk-root>/metadata` unless `--metadata-root` overrides it -- shared
415
+ by `build` and `check`, both of which read `metadata/**` for the same
416
+ resolved SKU."""
417
+ metadata_dir = Path(metadata_root) if metadata_root else resolved_sdk / "metadata"
418
+ if metadata_root and not metadata_dir.is_absolute():
419
+ metadata_dir = workspace_root / metadata_dir
420
+ return metadata_dir
421
+
422
+
423
+ def _shipped_caveat_issues(name: str, out_path: Path) -> list[Issue]:
424
+ """One `model.target-caveat` WARNING per caveated target in the package
425
+ just written -- read back OUT OF THE FILE, not out of the in-memory build.
426
+
427
+ `tan model check --exact` already surfaced the compiler's own caveats, but
428
+ `check` ships nothing; `build` is the path that puts bytes on a board, and
429
+ it dropped them. A vela blob compiled against vela's BUILT-IN default
430
+ profile carries an `arena`/`sram_kib` pair describing THAT memory model,
431
+ and those are the very figures alp-sdk's on-device selector gates on
432
+ (`src/backends/inference/alp_model_select.c`), so the operator who runs
433
+ `tan model build` has to be told -- once per target, verbatim, at the
434
+ moment the package is produced.
435
+
436
+ Reported from `package.read_manifest_file` so the line describes the
437
+ ARTIFACT: a caveat that never reached the file cannot be narrated here.
438
+ A readback that fails is itself a warning (`model.caveat-readback-
439
+ failed`), never an error -- the package IS written, and downgrading a
440
+ successful build to a failure over a diagnostic read would be the wrong
441
+ trade. It is not swallowed either: silence would be indistinguishable
442
+ from "no caveats"."""
443
+ try:
444
+ mft = read_manifest_file(out_path)
445
+ except Exception as err: # noqa: BLE001 -- a diagnostic readback, not the build
446
+ return [Issue("model.caveat-readback-failed", "warning",
447
+ f"model '{name}': wrote {out_path} but could not read its manifest "
448
+ f"back to report compiler caveats: {type(err).__name__}: {err}")]
449
+ out: list[Issue] = []
450
+ for target in mft.targets:
451
+ label = f"{target.backend} {target.accel_config}".strip()
452
+ for caveat in target.caveats:
453
+ out.append(Issue(
454
+ "model.target-caveat", "warning",
455
+ f"model '{name}': the {label} target in {out_path.name} ships with a "
456
+ f"compiler caveat -- {caveat}"))
457
+ return out
458
+
459
+
460
+ def _run_build(
461
+ *,
462
+ context: ProjectContext,
463
+ out: str,
464
+ metadata_root: str | None,
465
+ sdk_root: str | None,
466
+ ) -> tuple[Project, SdkInfo | None, dict, list[Issue], ExitCode]:
467
+ """`--board` plays the role `--board-yaml` does everywhere else, so the
468
+ SAME project-context resolution applies (I-31); the envelope's `project`
469
+ and `sdk` fields come from that ONE resolution, matching `size`/`image`
470
+ (`build_output.resolve_project_context`'s own doc: the envelope's `sdk`
471
+ block must be what THAT resolution produced, never a second lookup).
472
+
473
+ tan-cli#497 defect 3: the `resolve_project_context` call used to live at
474
+ the top of this function, which meant its `broken_project_pin` /
475
+ `foreign_global_default_for` were unreachable from `model`'s own
476
+ `except ModelError` handler -- so the pin warning was dropped on the
477
+ refusal paths as well as the happy one. It is now resolved by the caller
478
+ and handed in, so the ONE resolution feeds every exit."""
479
+ workspace_root = Path(context.workspace_root)
480
+ board_path = Path(context.board_yaml)
481
+ reported_project = context.project()
482
+ sdk_info = context.sdk
483
+
484
+ # The metadata-reading resolution is DELIBERATELY separate and wider
485
+ # (`build_output.resolve_metadata_sdk_root`'s own doc): a child/sibling
486
+ # checkout the project-context tier chain does not consider can still
487
+ # supply `metadata/`, in which case `sdk_info` stays absent while the
488
+ # build still runs against it -- same divergence `tan size`'s budget
489
+ # resolution already allows.
490
+ resolved_sdk = _require_metadata_sdk_root(sdk_root, context.workspace_root, "No models were built.")
491
+
492
+ board_doc = _load_board(board_path)
493
+ sku = _require_sku(board_doc, board_path)
494
+ models = _require_models_list(board_doc, board_path)
495
+
496
+ data: dict[str, Any] = {"schemaVersion": DATA_SCHEMA_VERSION, "sku": sku, "built": []}
497
+ if not models:
498
+ return reported_project, sdk_info, data, [], ExitCode.SUCCESS
499
+
500
+ base = board_path.parent
501
+ out_dir = Path(out)
502
+ if not out_dir.is_absolute():
503
+ out_dir = workspace_root / out_dir
504
+ metadata_dir = _resolve_metadata_dir(metadata_root, resolved_sdk, workspace_root)
505
+
506
+ # Validate + prepare every model BEFORE building any of them -- an invalid
507
+ # `models:` entry refuses the whole run with no partial build, matching
508
+ # the pre-ADR-0028 shape where this same loop only assembled the driver
509
+ # payload and never itself built anything.
510
+ prepared: list[tuple[str, Path, dict | None]] = []
511
+ for m in models:
512
+ _require_model_entry(m, board_path)
513
+ source = (base / m["source"]).resolve()
514
+ prepared.append((m["name"], source, _resolve_compile(m.get("compile"), base)))
515
+
516
+ # Deliberate divergence 1 from the oracle (see module doc): a per-model
517
+ # `build_model()` failure is caught here and reported as a coded
518
+ # `model.build-failed` issue, and the loop continues to the next model
519
+ # rather than aborting the whole batch.
520
+ issues: list[Issue] = []
521
+ built: list[str] = []
522
+ for name, source, compile_opts in prepared:
523
+ try:
524
+ out_path = build_model(
525
+ sku=sku,
526
+ name=name,
527
+ source=source,
528
+ out_dir=out_dir,
529
+ metadata_root=metadata_dir,
530
+ compile_opts=compile_opts,
531
+ board_doc=board_doc,
532
+ )
533
+ except SramNoFitRefused as err:
534
+ # tan-cli#1288: a certain arena/SRAM0 no-fit under `Sram_Only` --
535
+ # ahead of the generic `except Exception` below so it reports its
536
+ # OWN code/exit, not `model.build-failed`/`WRITE_FAILURE`.
537
+ issues.append(Issue("model.sram-no-fit", "error", f"model '{name}': {err}"))
538
+ except Exception as err: # noqa: BLE001 -- a per-model failure is a coded issue, not a traceback
539
+ issues.append(
540
+ Issue(
541
+ "model.build-failed",
542
+ "error",
543
+ f"model '{name}': {type(err).__name__}: {err}",
544
+ )
545
+ )
546
+ else:
547
+ built.append(str(out_path))
548
+ issues.extend(_shipped_caveat_issues(name, out_path))
549
+ data["built"] = built
550
+ # ERRORS decide the exit code, not `issues` being non-empty. `_shipped_
551
+ # caveat_issues` puts WARNINGS in this list, and a warning about a package
552
+ # that was written must not report it as a write failure -- the same
553
+ # separation `finish()`'s renderer already makes between `warnings` and
554
+ # `errors`, and the same one the caller's `sdk_issues` rely on.
555
+ #
556
+ # `model.sram-no-fit` gets its OWN exit code (tan-cli#1288's binding
557
+ # decision) -- checked first so it is never masked by the generic
558
+ # WRITE_FAILURE a `model.build-failed` alongside it would otherwise pick.
559
+ if any(i.code == "model.sram-no-fit" for i in issues):
560
+ exit_code = ExitCode.VALIDATION_FAILURE
561
+ elif any(i.severity == "error" for i in issues):
562
+ exit_code = ExitCode.WRITE_FAILURE
563
+ else:
564
+ exit_code = ExitCode.SUCCESS
565
+ return reported_project, sdk_info, data, issues, exit_code
566
+
567
+
568
+ def _declared_hw_rev(board_doc: dict, board_path: Path) -> str | None:
569
+ """`board.yaml`'s `som.hw_rev`, or `None` when it declares none.
570
+
571
+ OPTIONAL by `board.schema.json`, which also states the fallback: "the SDK
572
+ falls back to the SKU preset's `default_hw_rev` when omitted". That
573
+ fallback is applied downstream (`tan.model.perf_apply._resolve_hw_rev`),
574
+ where the metadata root is in hand; this only reports what the customer
575
+ wrote. Unlike `som.sku` a missing value is not a refusal -- `check` worked
576
+ without it before a bench measurement existed to pin to a revision, and
577
+ must keep working.
578
+
579
+ A PRESENT-BUT-UNUSABLE value fails CLOSED instead, rather than silently
580
+ falling through to the same `None` a genuinely absent key returns
581
+ (tan-cli#791 review MINOR 5). `board.yaml` is YAML: an unquoted
582
+ `hw_rev: 2` parses to the int `2`, not the string `"r2"` no perf point is
583
+ ever published under. Measured: `{"som": {"hw_rev": 2}} -> None` here
584
+ used to silently defer to the SKU preset's own `default_hw_rev` --
585
+ serving a customer who wrote a real hw_rev a DIFFERENT module revision's
586
+ bench measurement, exactly the "describes a different machine" failure
587
+ `hw_rev` exists to refuse in the first place (alp-sdk `f724d3e4`).
588
+ `_run_check` does not otherwise schema-validate `board.yaml`, so this is
589
+ the one place this particular shape of bad input is ever caught -- raised
590
+ as a `ModelError` (a board-level fact, refusing the WHOLE run, same
591
+ scoping `_require_sku`/`_require_check_backends` already use) rather than
592
+ returned, since a caller that got `None` back has no way to tell "the
593
+ customer said nothing" apart from "the customer said something tan could
594
+ not use"."""
595
+ som = board_doc.get("som")
596
+ if not isinstance(som, dict) or "hw_rev" not in som:
597
+ return None
598
+ hw_rev = som["hw_rev"]
599
+ if isinstance(hw_rev, str) and hw_rev:
600
+ return hw_rev
601
+ raise ModelError(
602
+ "model.board-yaml-invalid",
603
+ f"{board_path}: som.hw_rev must be a non-empty string (e.g. \"r2\"); got {hw_rev!r}.",
604
+ ExitCode.VALIDATION_FAILURE,
605
+ )
606
+
607
+
608
+ def _check_one_model(name: str, source: Path, backends: list[str], sku: str,
609
+ metadata_dir: Path, exact: bool,
610
+ hw_rev: str | None = None,
611
+ board_doc: dict | None = None) -> dict | Issue:
612
+ """One declared model's `check` result: the serialised `{name, source,
613
+ backends}` block on success, or a coded `model.check-failed` Issue on any
614
+ failure (an unreadable/unparseable source, most commonly) -- never a
615
+ traceback, matching `build`'s own per-model `model.build-failed` shape so
616
+ one bad model in a multi-model board.yaml does not abort the batch."""
617
+ try:
618
+ reports = check_model_backends(backends=backends, sku=sku, source=source,
619
+ metadata_root=metadata_dir, exact=exact,
620
+ hw_rev=hw_rev, board_doc=board_doc)
621
+ except Exception as err: # noqa: BLE001 -- a per-model failure is a coded issue, not a traceback
622
+ return Issue("model.check-failed", "error", f"model '{name}': {type(err).__name__}: {err}")
623
+ return {"name": name, "source": str(source),
624
+ "backends": [backend_report_as_dict(r) for r in reports]}
625
+
626
+
627
+ def _sram_no_fit_issues(name: str, backends: list[dict]) -> list[Issue]:
628
+ """One `model.sram-no-fit` Issue per backend whose `sramFit` block (tan-
629
+ cli#1288) reports a certain NO-FIT on either axis -- `check --exact`'s own
630
+ half of the binding decision: the model's serialised report still carries
631
+ the `sramFit` numbers (nothing here hides the report), but the run also
632
+ gets a coded, `error`-severity issue so `--exact` over a `Sram_Only` SKU
633
+ cannot exit 0 on a placement that cannot execute."""
634
+ issues: list[Issue] = []
635
+ for backend in backends:
636
+ fit = backend.get("sramFit")
637
+ if not fit:
638
+ continue
639
+ bad = [axis for axis in ("arena", "sram0") if fit[axis]["verdict"] == "no-fit"]
640
+ if not bad:
641
+ continue
642
+ detail = "; ".join(
643
+ f"{axis} needs {fit[axis]['neededKib']} KiB, only "
644
+ f"{fit[axis]['limitKib']} KiB available"
645
+ for axis in bad
646
+ )
647
+ variant = backend.get("variant")
648
+ label = f"{backend['backend']}" + (f" ({variant})" if variant else "")
649
+ issues.append(Issue("model.sram-no-fit", "error",
650
+ f"model '{name}' {label}: {detail}"))
651
+ return issues
652
+
653
+
654
+ def _require_check_backends(sku: str, metadata_dir: Path, board_path: Path) -> list[str]:
655
+ """`resolve_check_backends`, or a coded `model.check-sku-unresolved`
656
+ refusal -- a board-level fact about `som.sku`, reported once for the
657
+ whole run rather than once per declared model."""
658
+ try:
659
+ return resolve_check_backends(sku, metadata_root=metadata_dir)
660
+ except (FileNotFoundError, ValueError) as err:
661
+ raise ModelError(
662
+ "model.check-sku-unresolved",
663
+ f"{board_path}: could not resolve NPU backends for som.sku {sku!r}: {err}",
664
+ ExitCode.VALIDATION_FAILURE,
665
+ ) from err
666
+
667
+
668
+ def _run_check(
669
+ *,
670
+ context: ProjectContext,
671
+ metadata_root: str | None,
672
+ sdk_root: str | None,
673
+ exact: bool,
674
+ ) -> tuple[Project, SdkInfo | None, dict, list[Issue], ExitCode]:
675
+ """Static NPU-eligibility screen for every model `board.yaml` declares --
676
+ same project-context/SDK-root resolution shape as `_run_build` (see its
677
+ own docstring for why that resolution happens in the caller). `ok` stays
678
+ `True`/exit 0 for a run that completed, whatever the verdicts read --
679
+ reporting `undetermined`/`cpu-only` IS the feature; only a run that could
680
+ not complete (an unresolved SKU, a per-model read failure) is non-zero."""
681
+ workspace_root = Path(context.workspace_root)
682
+ board_path = Path(context.board_yaml)
683
+ reported_project = context.project()
684
+ sdk_info = context.sdk
685
+
686
+ resolved_sdk = _require_metadata_sdk_root(sdk_root, context.workspace_root, "No models were checked.")
687
+
688
+ board_doc = _load_board(board_path)
689
+ sku = _require_sku(board_doc, board_path)
690
+ models = _require_models_list(board_doc, board_path)
691
+
692
+ data: dict[str, Any] = {
693
+ "schemaVersion": CHECK_DATA_SCHEMA_VERSION, "sku": sku, "exact": exact, "models": [],
694
+ }
695
+ if not models:
696
+ return reported_project, sdk_info, data, [], ExitCode.SUCCESS
697
+
698
+ base = board_path.parent
699
+ metadata_dir = _resolve_metadata_dir(metadata_root, resolved_sdk, workspace_root)
700
+ backends = _require_check_backends(sku, metadata_dir, board_path)
701
+ # Board-level, like `sku`/`backends` above: resolved ONCE, ahead of the
702
+ # per-model loop, so an unusable `som.hw_rev` (`ModelError`, see
703
+ # `_declared_hw_rev`) refuses the whole run instead of repeating the same
704
+ # parse -- and the same failure -- once per declared model.
705
+ declared_hw_rev = _declared_hw_rev(board_doc, board_path)
706
+
707
+ issues: list[Issue] = []
708
+ model_reports: list[dict] = []
709
+ for m in models:
710
+ _require_model_entry(m, board_path)
711
+ source = (base / m["source"]).resolve()
712
+ result = _check_one_model(m["name"], source, backends, sku, metadata_dir, exact,
713
+ declared_hw_rev, board_doc)
714
+ if isinstance(result, Issue):
715
+ issues.append(result)
716
+ else:
717
+ model_reports.append(result)
718
+ issues.extend(_sram_no_fit_issues(m["name"], result["backends"]))
719
+ data["models"] = model_reports
720
+ # `model.sram-no-fit` gets its OWN exit code (tan-cli#1288's binding
721
+ # decision), same precedence `_run_build` gives it -- checked first so it
722
+ # is never masked by the generic RUNTIME_FAILURE a `model.check-failed`
723
+ # alongside it would otherwise pick.
724
+ if any(i.code == "model.sram-no-fit" for i in issues):
725
+ exit_code = ExitCode.VALIDATION_FAILURE
726
+ elif issues:
727
+ exit_code = ExitCode.RUNTIME_FAILURE
728
+ else:
729
+ exit_code = ExitCode.SUCCESS
730
+ return reported_project, sdk_info, data, issues, exit_code
731
+
732
+
733
+ def _run_list(
734
+ *,
735
+ context: ProjectContext,
736
+ out: str,
737
+ ) -> tuple[Project, SdkInfo | None, dict, list[Issue], ExitCode]:
738
+ """Every declared model next to what `--out` already holds for it (module
739
+ doc): no `--sdk-root`/`metadata_root` handling at all, unlike `_run_build`/
740
+ `_run_check` above -- `list` reads only `board.yaml` and `out`'s own
741
+ directory, so there is nothing here to resolve a checkout for. Never
742
+ refuses over a missing/invalid `som.sku` either (`declared_sku` degrades
743
+ to `None`): the SoM is not needed to say what is declared and what is
744
+ already on disk."""
745
+ workspace_root = Path(context.workspace_root)
746
+ board_path = Path(context.board_yaml)
747
+ reported_project = context.project()
748
+ sdk_info = context.sdk
749
+
750
+ board_doc = _load_board(board_path)
751
+ sku = declared_sku(board_doc)
752
+ models = _require_models_list(board_doc, board_path)
753
+
754
+ data: dict[str, Any] = {"schemaVersion": LIST_DATA_SCHEMA_VERSION, "sku": sku, "models": []}
755
+ if not models:
756
+ return reported_project, sdk_info, data, [], ExitCode.SUCCESS
757
+
758
+ base = board_path.parent
759
+ out_dir = Path(out)
760
+ if not out_dir.is_absolute():
761
+ out_dir = workspace_root / out_dir
762
+
763
+ entries = []
764
+ issues: list[Issue] = []
765
+ for m in models:
766
+ _require_model_entry(m, board_path)
767
+ source = (base / m["source"]).resolve()
768
+ entry, issue = list_entry(m["name"], source, out_dir)
769
+ entries.append(entry)
770
+ if issue is not None:
771
+ issues.append(issue)
772
+ data["models"] = entries
773
+ return reported_project, sdk_info, data, issues, ExitCode.SUCCESS
774
+
775
+
776
+ def _backend_version(backend: str, *, available: bool) -> str | None:
777
+ """Best-effort compiler-version string for an AVAILABLE backend --
778
+ READ-ONLY, never a spawn (module doc). `None` whenever no non-spawning
779
+ probe exists, or the one that does answers a DEGRADED sentinel rather
780
+ than a real version:
781
+
782
+ * `ethos_u` -- `_vela_version()` reads `importlib.metadata`, no
783
+ subprocess. It returns the bare literal `"vela"` when the
784
+ `ethos-u-vela` distribution's metadata is absent
785
+ (`except PackageNotFoundError`) -- that is a degraded answer, not a
786
+ version, so it is reported here as `None` rather than surfaced as if
787
+ it were one.
788
+ * `drpai` -- `_compiler_version(tvm_home)` reads a version file inside
789
+ the toolchain checkout (`tan/model/adapters/drpai.py`), also no
790
+ subprocess. It returns the bare literal `"drp-ai_tvm"` when none of
791
+ `setup/version` / `version` / `VERSION` is found under `tvm_home` --
792
+ that is the identical degraded-sentinel shape `ethos_u` guards above,
793
+ so it gets the same guard: reported here as `None` rather than
794
+ surfaced as if it were a real version.
795
+ * `deepx_dxm1` -- `_dxcom_version()` is the only version probe DeepxAdapter
796
+ has, and it SPAWNS (`subprocess.run(["dxcom", "-v"], ...)`,
797
+ `tan/model/adapters/deepx.py`). `doctor` must never invoke a compiler,
798
+ so this backend's version is always unknown here, never guessed at.
799
+ * `cpu` -- no external tool at all (`BACKEND_TOOLS["cpu"] is None`), so
800
+ no version to report.
801
+ """
802
+ if not available:
803
+ return None
804
+ if backend == "ethos_u":
805
+ version = _vela_version()
806
+ return None if version == "vela" else version
807
+ if backend == "drpai":
808
+ tvm_home = _drpai_tvm_home()
809
+ if tvm_home is None:
810
+ return None
811
+ version = _drpai_compiler_version(tvm_home)
812
+ return None if version == "drp-ai_tvm" else version
813
+ return None
814
+
815
+
816
+ def _deepx_dxm1_status() -> tuple[bool, str | None]:
817
+ """`deepx_dxm1`'s doctor verdict -- gated on what `DeepxAdapter.compile()`
818
+ actually needs (the bare `dxcom` off PATH: `cmd = ["dxcom", "-m", ...]`,
819
+ `tan/model/adapters/deepx.py`), NOT `DeepxAdapter.is_available()`.
820
+
821
+ That adapter method ORs in a second arm -- `ALP_DEEPX_SDK_HOME` naming a
822
+ directory -- that `compile()` never reads at all, so a row gated on it
823
+ reported `available: true` on a host with the env var pointed at an
824
+ empty directory and no `dxcom` anywhere, and the very next `model build`
825
+ raised `FileNotFoundError: [Errno 2] No such file or directory: 'dxcom'`.
826
+ Deliberately NOT a change to `DeepxAdapter.is_available()` itself --
827
+ `tan.model.build.build_model` uses that method for a different decision
828
+ (whether to attempt this backend at all) that this env-var arm is
829
+ legitimate for; this is doctor's own, narrower "would compile() actually
830
+ run" question, answered without spawning anything.
831
+
832
+ Read-only: `shutil.which` + `os.environ.get` + `Path.is_dir`, the exact
833
+ same primitives `is_available()` itself uses, never a subprocess.
834
+
835
+ Returns `(available, reason)`. `reason` is `None` when either `dxcom` is
836
+ on PATH (row is available, no reason needed) or neither PATH nor the env
837
+ var carries any signal at all (the caller falls back to
838
+ `_UNAVAILABLE_REASONS["deepx_dxm1"]`'s generic reason). When the env var
839
+ IS set to a real directory but `dxcom` still isn't on PATH, `reason`
840
+ carries an explicit caveat -- the var is set, but it is not what
841
+ `compile()` reads -- rather than silently reporting green.
842
+ """
843
+ if shutil.which("dxcom"):
844
+ return True, None
845
+ sdk_home = os.environ.get("ALP_DEEPX_SDK_HOME")
846
+ if sdk_home and Path(sdk_home).is_dir():
847
+ return False, (
848
+ f"ALP_DEEPX_SDK_HOME={sdk_home} is set, but dxcom is not on PATH; "
849
+ "DeepxAdapter.compile() always shells the bare `dxcom` off PATH "
850
+ "and never reads ALP_DEEPX_SDK_HOME, so this environment would "
851
+ "still fail `model build`"
852
+ )
853
+ return False, None
854
+
855
+
856
+ def _drpai_status() -> tuple[bool, str | None]:
857
+ """`drpai`'s doctor verdict -- gated on what `DrpaiAdapter.compile()`
858
+ actually needs under `$ALP_DRPAI_TVM_HOME`: the vendor tutorial script
859
+ `tutorials/compile_onnx_model_quant.py` it spawns AND the `python3`
860
+ interpreter it shells that script with (`tan/model/adapters/drpai.py`'s
861
+ own `cmd = ["python3", str(script), ...]`), NOT just the bare directory
862
+ `DrpaiAdapter.is_available()`/`_tvm_home()` check for.
863
+
864
+ A customer who points `ALP_DRPAI_TVM_HOME` at an unpacked-but-unbuilt
865
+ checkout (the tutorials/ tree not yet present, or laid out differently)
866
+ got `available: true` from the bare directory check, then a vendor-script
867
+ failure on the next real build -- and a host with a BUILT tree but no
868
+ `python3` on PATH hit the exact same false-green class one dependency
869
+ further out, still reading green right up until that same `model build`
870
+ failed. Deliberately NOT a change to `DrpaiAdapter.is_available()`/
871
+ `_tvm_home()` themselves -- same reasoning as `_deepx_dxm1_status` above:
872
+ this is doctor's own, narrower question. `python3` is deliberately NOT
873
+ `BACKEND_TOOLS["drpai"]` (see that dict's own comment: naming an
874
+ interpreter present on essentially every host tells a customer nothing
875
+ actionable) -- it is checked here as a `compile()` prerequisite, not
876
+ reported as *the* tool this row names.
877
+
878
+ Read-only: `_tvm_home()` (env var + `Path.is_dir`) + `Path.is_file` on the
879
+ tutorial script path + `shutil.which("python3")`, never a subprocess.
880
+
881
+ Returns `(available, reason)`, same shape as `_deepx_dxm1_status`: `None`
882
+ reason falls back to `_UNAVAILABLE_REASONS["drpai"]`'s generic reason
883
+ (env var not set at all); an explicit caveat when the var names a real
884
+ directory but the tutorial script isn't under it, or when both are
885
+ present but `python3` is not on PATH.
886
+ """
887
+ tvm_home = _drpai_tvm_home()
888
+ if tvm_home is None:
889
+ return False, None
890
+ script = tvm_home / "tutorials" / "compile_onnx_model_quant.py"
891
+ if not script.is_file():
892
+ return False, (
893
+ f"ALP_DRPAI_TVM_HOME={tvm_home} is set, but tutorials/"
894
+ "compile_onnx_model_quant.py was not found under it -- point it at "
895
+ "a BUILT rzv_drp-ai_tvm install, not an unpacked/incomplete tree"
896
+ )
897
+ if shutil.which("python3") is None:
898
+ return False, (
899
+ f"ALP_DRPAI_TVM_HOME={tvm_home} and tutorials/"
900
+ "compile_onnx_model_quant.py are both present, but python3 is "
901
+ "not on PATH -- DrpaiAdapter.compile() shells `python3 "
902
+ "<script> ...` (tan/model/adapters/drpai.py), so this "
903
+ "environment would still fail `model build`"
904
+ )
905
+ return True, None
906
+
907
+
908
+ def _vela_vendor_config_status() -> tuple[bool, str | None]:
909
+ """The OPTIONAL vendor vela `.ini` row's verdict -- is `ALP_VELA_CONFIG`
910
+ pointing at a file `VelaAdapter.compile()` could pass to `vela --config`?
911
+
912
+ Read-only and non-spawning like every other probe here: `os.environ.get` +
913
+ `Path.is_file`, through the adapter's own `_vendor_config_path()` so doctor
914
+ and the compile answer the same question from one implementation. It reads
915
+ no metadata: WHICH vendor file a given part wants is a per-SoC fact
916
+ (`npu_toolchain.vela.vendor_config_filename`), and doctor reports
917
+ host-toolchain facts that hold whatever project it was run from.
918
+
919
+ Returns `(available, reason)`. The reason MUST make it plain that absence
920
+ is not a fault: without this file vela uses Arm's own built-in system
921
+ config, which is exactly what tan's reported arena/SRAM figures describe --
922
+ the vendor file buys a vendor-tuned bandwidth/latency model, not
923
+ correctness. A customer without a license is complete, not broken.
924
+ """
925
+ raw = os.environ.get(_VELA_CONFIG_ENV)
926
+ if not raw:
927
+ return False, (
928
+ f"OPTIONAL, not a fault: {_VELA_CONFIG_ENV} is not set, so vela uses Arm's "
929
+ "built-in system config -- the arena/SRAM figures tan reports describe that "
930
+ "model and are correct. A licensed customer may set it to their vendor vela "
931
+ "config .ini (e.g. Alif's ensemble_vela.ini) for the vendor-tuned profile"
932
+ )
933
+ if _vendor_config_path() is None:
934
+ return False, (
935
+ f"OPTIONAL, not a fault: {_VELA_CONFIG_ENV}={raw} does not name a readable "
936
+ "file, so it is ignored and vela uses Arm's built-in system config -- point "
937
+ "it at the vendor vela config .ini or unset it"
938
+ )
939
+ return True, None
940
+
941
+
942
+ def _probe_backend(backend: str) -> tuple[bool, str | None]:
943
+ """`(available, reason override)` for one backend row -- `deepx_dxm1` and
944
+ `drpai` each get a NARROWER probe than the adapter's own `is_available()`
945
+ (see `_deepx_dxm1_status`/`_drpai_status`'s own docs): `is_available()`
946
+ reports green on a signal `compile()` doesn't actually act on, so gating
947
+ a row on it can report available when the next `model build` would
948
+ immediately fail. Every other backend keeps the plain `is_available()`
949
+ OR across its registered adapters, with no reason override."""
950
+ if backend == "deepx_dxm1":
951
+ return _deepx_dxm1_status()
952
+ if backend == "drpai":
953
+ return _drpai_status()
954
+ available = any(a.is_available() for a in _ADAPTERS if a.backend == backend)
955
+ return available, None
956
+
957
+
958
+ def _run_doctor(
959
+ *,
960
+ context: ProjectContext,
961
+ sdk_root: str | None,
962
+ ) -> tuple[Project, SdkInfo | None, dict, list[Issue], ExitCode]:
963
+ """One row per registered compiler-backend, per the module doc: READ-ONLY,
964
+ spawns nothing, and never fails the run -- an unavailable toolchain is
965
+ reported, not refused. A board.yaml is not read at all: unlike `build`,
966
+ `doctor` has no per-model work to resolve, only host-toolchain facts that
967
+ hold regardless of which (if any) project this was run from.
968
+
969
+ The SDK root is still resolved, mirroring `_run_build` (module doc), but
970
+ ONLY to decide whether `model.doctor-sdk-unresolved` belongs in `issues`
971
+ -- a WARNING, never the VALIDATION_FAILURE `_run_build` raises for the
972
+ same non-resolution, since nothing below actually needs it: no backend
973
+ row reads `metadata/**`.
974
+ """
975
+ reported_project = context.project()
976
+ sdk_info = context.sdk
977
+
978
+ issues: list[Issue] = []
979
+ resolved_sdk = resolve_metadata_sdk_root(sdk_root, context.workspace_root)
980
+ if resolved_sdk is None:
981
+ issues.append(
982
+ Issue(
983
+ "model.doctor-sdk-unresolved",
984
+ "warning",
985
+ (
986
+ rejected_sdk_root_message(
987
+ sdk_root, "Backend availability below is unaffected."
988
+ )
989
+ if sdk_root
990
+ else with_sdk_search(
991
+ "alp-sdk root is unresolved. Use --sdk-root, place the project "
992
+ f"near an alp-sdk checkout, or {NO_SDK_NEXT_STEPS}. Backend "
993
+ "availability below is unaffected.",
994
+ context.workspace_root,
995
+ )
996
+ ),
997
+ )
998
+ )
999
+
1000
+ rows = []
1001
+ for backend in registry_backends(_ADAPTERS):
1002
+ available, reason = _probe_backend(backend)
1003
+ version = _backend_version(backend, available=available)
1004
+ rows.append(backend_row(backend, available=available, version=version, reason=reason))
1005
+
1006
+ # OPTIONAL prerequisites, reported separately from `backends[]` so an
1007
+ # absent one cannot read as a broken backend (`model_doctor.optional_row`).
1008
+ vendor_config_available, vendor_config_reason = _vela_vendor_config_status()
1009
+ optional_rows = [
1010
+ optional_row("ethos_u", tool=_VELA_CONFIG_ENV,
1011
+ available=vendor_config_available, reason=vendor_config_reason),
1012
+ ]
1013
+
1014
+ data: dict[str, Any] = {
1015
+ "schemaVersion": DOCTOR_DATA_SCHEMA_VERSION,
1016
+ "backends": [row.as_dict() for row in rows],
1017
+ # Additive: `schemaVersion` stays "1". A consumer reading `backends[]`
1018
+ # sees the same list it always did, and one that does not know this key
1019
+ # ignores it -- which is the correct outcome for an enhancement nobody
1020
+ # is required to have.
1021
+ "optional": [row.as_dict() for row in optional_rows],
1022
+ }
1023
+ return reported_project, sdk_info, data, issues, ExitCode.SUCCESS
1024
+
1025
+
1026
+ def _refuse_stray_arguments(
1027
+ subcommand: str,
1028
+ model_id: str | None,
1029
+ sku: str | None,
1030
+ device_flags: tuple[bool, ...] = (),
1031
+ live: LiveOptions | None = None,
1032
+ scoped_flags: dict[str, tuple[bool, tuple[str, ...]]] | None = None,
1033
+ ) -> None:
1034
+ """A positional ID belongs to `add` alone and `--sku` to `zoo`: accepting
1035
+ either elsewhere would silently ignore what the caller typed."""
1036
+ if model_id is not None and subcommand not in ("add", "prep", "run", "ab"):
1037
+ raise ModelError(
1038
+ "model.unexpected-argument",
1039
+ f"`tan model {subcommand}` takes no ID argument (got {model_id}); only `add`, `prep`, `run` and `ab` do.",
1040
+ ExitCode.VALIDATION_FAILURE,
1041
+ )
1042
+ if device_flags:
1043
+ device, capture, against_capture, has_input, has_runs = device_flags
1044
+ if (device or capture or against_capture) and subcommand not in ("run", "ab"):
1045
+ raise ModelError(
1046
+ "model.unexpected-argument",
1047
+ f"`tan model {subcommand}` takes no --device/--capture/--against-capture.",
1048
+ ExitCode.VALIDATION_FAILURE,
1049
+ )
1050
+ if (capture or against_capture) and not device:
1051
+ raise ModelError(
1052
+ "model.unexpected-argument",
1053
+ "--capture/--against-capture need --device.",
1054
+ ExitCode.VALIDATION_FAILURE,
1055
+ )
1056
+ if against_capture and subcommand != "ab":
1057
+ raise ModelError(
1058
+ "model.unexpected-argument",
1059
+ "--against-capture belongs to `ab --device`.",
1060
+ ExitCode.VALIDATION_FAILURE,
1061
+ )
1062
+ if live is not None and live.any_set() and not (device and not (capture or against_capture)):
1063
+ raise ModelError(
1064
+ "model.unexpected-argument",
1065
+ "--core/--wait/--confirm/--probe-*/--jlink/--against-project apply to a live "
1066
+ "`run`/`ab --device` (no --capture).",
1067
+ ExitCode.VALIDATION_FAILURE,
1068
+ )
1069
+ if device and (has_input or has_runs):
1070
+ raise ModelError(
1071
+ "model.unexpected-argument",
1072
+ "--input and --runs apply to host runs; a device run times what the target ran.",
1073
+ ExitCode.VALIDATION_FAILURE,
1074
+ )
1075
+ # tan-cli#1486: subcommand-scoped flags typed on the wrong subcommand were
1076
+ # accepted and dropped, so the caller got no signal the flag did nothing.
1077
+ for flag, (given, owners) in (scoped_flags or {}).items():
1078
+ if given and subcommand not in owners:
1079
+ raise ModelError(
1080
+ "model.unexpected-argument",
1081
+ f"`tan model {subcommand}` takes no {flag}; it applies to "
1082
+ f"{', '.join('`' + o + '`' for o in owners)} only.",
1083
+ ExitCode.VALIDATION_FAILURE,
1084
+ )
1085
+ if sku and subcommand == "add":
1086
+ raise ModelError(
1087
+ "model.unexpected-argument",
1088
+ "`tan model add` takes no --sku; the board's own som.sku is what it checks.",
1089
+ ExitCode.VALIDATION_FAILURE,
1090
+ )
1091
+
1092
+
1093
+ def _empty_data(subcommand: str | None) -> dict[str, Any]:
1094
+ """The `data` shape for a refusal that never reached `_run_build`/
1095
+ `_run_doctor`/`_run_check`/`_run_list` -- each subcommand's OWN empty
1096
+ payload shape, not a generic stand-in, so a consumer parsing
1097
+ `data.backends`/`data.built`/`data.models` off a refusal envelope gets the
1098
+ same shape it would from a run that resolved nothing."""
1099
+ if subcommand == "doctor":
1100
+ return {"schemaVersion": DOCTOR_DATA_SCHEMA_VERSION, "backends": [], "optional": []}
1101
+ if subcommand == "check":
1102
+ return {"schemaVersion": CHECK_DATA_SCHEMA_VERSION, "sku": None, "exact": False, "models": []}
1103
+ if subcommand == "list":
1104
+ return {"schemaVersion": LIST_DATA_SCHEMA_VERSION, "sku": None, "models": []}
1105
+ if subcommand == "zoo":
1106
+ return zoo_empty_data()
1107
+ if subcommand == "add":
1108
+ return add_empty_data()
1109
+ if subcommand == "prep":
1110
+ return prep_empty_data()
1111
+ if subcommand == "run":
1112
+ return run_empty_data()
1113
+ if subcommand == "ab":
1114
+ return ab_empty_data()
1115
+ return {"schemaVersion": DATA_SCHEMA_VERSION, "sku": None, "built": []}
1116
+
1117
+
1118
+ def model(
1119
+ subcommand: str = typer.Argument(
1120
+ None, metavar="SUBCOMMAND", help="build | doctor | check | list | zoo | add | prep | run | ab."
1121
+ ),
1122
+ board: str = typer.Option(
1123
+ # tan-cli#398: `--board-yaml` is a REAL second spelling of this one
1124
+ # option, not a second option -- it is what `build`, `run`, `kconfig`,
1125
+ # `validate`, `generate` and `inspect` all call the board file, and
1126
+ # what alp-sdk-vscode's `docs/CLI.md` "Common flags" tells a caller every command
1127
+ # supports. Declared here so `accept_global_flags` sees it as already
1128
+ # covered and stops injecting an inert twin behind this one: a caller
1129
+ # who used the surface-wide spelling was served `./board.yaml`'s
1130
+ # `som.sku` instead of the file they named, and `som.sku` is what
1131
+ # picks the silicon `build_model` compiles for.
1132
+ "board.yaml", "--board", "--board-yaml", metavar="PATH", help="Path to board.yaml."
1133
+ ),
1134
+ out: str = typer.Option(
1135
+ None, "--out", metavar="PATH", help="Output directory (default build/models; `prep`: build/model-prep)."
1136
+ ),
1137
+ metadata_root: str = typer.Option(
1138
+ None,
1139
+ "--metadata-root",
1140
+ metavar="PATH",
1141
+ help="Path to the metadata/ root (default: <sdk-root>/metadata).",
1142
+ ),
1143
+ project: str = typer.Option(
1144
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1145
+ ),
1146
+ sdk_root: str = typer.Option(
1147
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1148
+ ),
1149
+ exact: bool = typer.Option(
1150
+ False,
1151
+ "--exact",
1152
+ help="With `check`: attempt a real compile (Ethos-U only, via `vela`) "
1153
+ "instead of the static screen. Refused by the other subcommands.",
1154
+ ),
1155
+ sku: str = typer.Option(
1156
+ None,
1157
+ "--sku",
1158
+ metavar="SKU",
1159
+ help="With `zoo`: only entries bench-validated on this SoM SKU. Ignored by the others.",
1160
+ ),
1161
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
1162
+ calibration: str = typer.Option(
1163
+ None, "--calibration", metavar="DIR", help="With `prep`: directory of .npy calibration samples."
1164
+ ),
1165
+ per_channel: bool = typer.Option(
1166
+ False, "--per-channel", help="With `prep`: per-channel weight quantization."
1167
+ ),
1168
+ min_samples: int | None = typer.Option(
1169
+ None, "--min-samples", metavar="N",
1170
+ help="With `prep`: fewest calibration samples accepted (default 8)."
1171
+ ),
1172
+ device: bool = typer.Option(
1173
+ False,
1174
+ "--device",
1175
+ help="With `run`/`ab`: report the on-device tier, from --capture FILE or live (RAM-run the "
1176
+ "built diagnostics.link: itcm project, needs --confirm).",
1177
+ ),
1178
+ core: str = typer.Option(
1179
+ "m55_he", "--core", metavar="CORE_ID", help="With live `--device`: the ITCM-linked slice to RAM-run."
1180
+ ),
1181
+ wait: float = typer.Option(
1182
+ 1.5, "--wait", metavar="SECONDS", min=0.0, max=3600.0,
1183
+ help="With live `--device`: seconds the image runs before the RAM console is read.",
1184
+ ),
1185
+ confirm: bool = typer.Option(
1186
+ False, "--confirm",
1187
+ help="With live `--device`: arm the RAM-run (resets the whole device; never writes MRAM). "
1188
+ "Armed by --confirm, ALP_FLASH_FORCE=1 or the manifest's flash_args.confirm: true.",
1189
+ ),
1190
+ probe_serial: str = typer.Option(None, "--probe-serial", metavar="SN", help="With live `--device`: J-Link serial."),
1191
+ probe_usb_path: str = typer.Option(
1192
+ None, "--probe-usb-path", metavar="BUS-PORT", help="With live `--device`: J-Link USB port path (e.g. 3-4.2)."
1193
+ ),
1194
+ jlink: str = typer.Option(None, "--jlink", metavar="PATH", help="With live `--device`: J-Link Commander binary."),
1195
+ against_project: str = typer.Option(
1196
+ None, "--against-project", metavar="PATH", help="With live `ab --device`: B's project root (A is --project); B uses its own board.yaml."
1197
+ ),
1198
+ capture: str = typer.Option(
1199
+ None, "--capture", metavar="FILE", help="With `--device`: the target's console capture."
1200
+ ),
1201
+ against_capture: str = typer.Option(
1202
+ None, "--against-capture", metavar="FILE", help="With `ab --device`: B's console capture."
1203
+ ),
1204
+ against: str = typer.Option(
1205
+ None, "--against", metavar="PATH", help="With `ab`: the second .onnx model to compare with."
1206
+ ),
1207
+ runs: int = typer.Option(None, "--runs", metavar="N", help="With host `run`/`ab`: timed inferences per model (default 20)."),
1208
+ input_file: str = typer.Option(
1209
+ None, "--input", metavar="PATH", help="With `run`/`ab`: a .npy input sample (default: seeded random)."
1210
+ ),
1211
+ model_id: str = typer.Argument(
1212
+ None,
1213
+ metavar="ID",
1214
+ help="With `add`: the model-zoo entry id (see `zoo`). With `prep`/`run`/`ab`: the .onnx model file.",
1215
+ ),
1216
+ ) -> None:
1217
+ """Compile + package board.yaml `models:` into `.alpmodel` packages
1218
+ (`build`), report NPU-compiler toolchain availability (`doctor`),
1219
+ statically screen a declared model's NPU eligibility (`check`), or list
1220
+ what is declared next to what is already built (`list`), list the SDK's model
1221
+ zoo (`zoo`), add a zoo model to the project (`add <id>`), INT8-quantize an
1222
+ ONNX model with an accuracy report (`prep`), or time a host reference
1223
+ run (`run`) / compare two models (`ab`)."""
1224
+ json_mode = output_format == "json"
1225
+ out = out or ("build/model-prep" if subcommand == "prep" else "build/models")
1226
+
1227
+ def finish(
1228
+ project_: Project,
1229
+ sdk: SdkInfo | None,
1230
+ data: dict,
1231
+ issues: list[Issue],
1232
+ exit_code: ExitCode,
1233
+ ) -> None:
1234
+ if json_mode:
1235
+ emit(Envelope("model", project_, data, issues, exit_code, sdk=sdk))
1236
+ else:
1237
+ # tan-cli#497 defect 3: warnings LEAD. `issues` now carries the
1238
+ # SDK-resolution pair, which is a fact about the whole run --
1239
+ # which checkout answered -- not a per-model outcome, so it must
1240
+ # be readable before the result lines rather than buried under
1241
+ # them. `{severity}: {message}`, the shape `build`/`run`/`kconfig`
1242
+ # print a resolution warning with, so the same workspace reads
1243
+ # the same way whichever command a developer runs.
1244
+ warnings = [i for i in issues if i.severity != "error"]
1245
+ errors = [i for i in issues if i.severity == "error"]
1246
+ for issue in warnings:
1247
+ print(f"{issue.severity}: {issue.message}", file=sys.stderr)
1248
+ if "backends" in data:
1249
+ # `doctor`: one line per backend, in registry order --
1250
+ # unavailable rows carry their reason inline, so a scrollback
1251
+ # answers "why not" without a second command.
1252
+ for row in data["backends"]:
1253
+ tool = row["tool"] or "-"
1254
+ if row["available"]:
1255
+ version = f", version={row['version']}" if row["version"] else ""
1256
+ print(
1257
+ f"{row['backend']}: available (tool={tool}{version})",
1258
+ file=sys.stderr,
1259
+ )
1260
+ else:
1261
+ # A backend with no `_UNAVAILABLE_REASONS` entry (and
1262
+ # no doctor-side caveat) carries `reason: None` --
1263
+ # drop the `-- ...` clause entirely rather than
1264
+ # rendering the Python literal `None` into the line.
1265
+ suffix = f" -- {row['reason']}" if row["reason"] else ""
1266
+ print(
1267
+ f"{row['backend']}: unavailable (tool={tool}){suffix}",
1268
+ file=sys.stderr,
1269
+ )
1270
+ # OPTIONAL prerequisites, after the backend rows and never
1271
+ # spelled `unavailable`: the word this branch leads with is
1272
+ # what stops a licensed-only enhancement reading as a fault
1273
+ # in a scrollback (`tan.core.model_doctor.optional_row`).
1274
+ for row in data.get("optional", []):
1275
+ tool = row["tool"] or "-"
1276
+ if row["available"]:
1277
+ print(
1278
+ f"{row['backend']}: optional (tool={tool}) present",
1279
+ file=sys.stderr,
1280
+ )
1281
+ else:
1282
+ suffix = f" -- {row['reason']}" if row["reason"] else ""
1283
+ print(
1284
+ f"{row['backend']}: optional (tool={tool}) not in use{suffix}",
1285
+ file=sys.stderr,
1286
+ )
1287
+ elif subcommand == "zoo":
1288
+ if not data.get("entries") and not errors:
1289
+ print("model zoo: no matching entries.", file=sys.stderr)
1290
+ for line in render_zoo_text(data):
1291
+ print(line, file=sys.stderr)
1292
+ elif subcommand == "add":
1293
+ for line in render_add_text(data):
1294
+ print(line, file=sys.stderr)
1295
+ elif subcommand in ("prep", "run", "ab"):
1296
+ render = {"prep": render_prep_text, "run": render_run_text, "ab": render_ab_text}
1297
+ for line in render[subcommand](data):
1298
+ print(line, file=sys.stderr)
1299
+ elif subcommand == "list":
1300
+ # `list`: checked by SUBCOMMAND, not by `"models" in data` --
1301
+ # `check`'s payload carries that same key, so shape alone
1302
+ # cannot tell the two apart (tan-cli#674). Rendered from the
1303
+ # SAME serialised `data` dict `--format json` emits
1304
+ # (`tan.core.model_list.render_list_text`), the identical
1305
+ # one-source-two-renderers split `doctor`/`check` above use.
1306
+ if not data.get("models") and not errors:
1307
+ print(
1308
+ "model list: no `models:` declared in board.yaml; nothing to list.",
1309
+ file=sys.stderr,
1310
+ )
1311
+ for line in render_list_text(data):
1312
+ print(line, file=sys.stderr)
1313
+ elif "models" in data:
1314
+ # `check`: rendered from the SAME serialised `data` dict
1315
+ # `--format json` emits (`tan.core.model_check.render_check_text`),
1316
+ # the identical one-source-two-renderers split `doctor`'s own
1317
+ # branch above already uses.
1318
+ if not data.get("models") and not errors:
1319
+ print(
1320
+ "model check: no `models:` declared in board.yaml; nothing to check.",
1321
+ file=sys.stderr,
1322
+ )
1323
+ for line in render_check_text(data):
1324
+ print(line, file=sys.stderr)
1325
+ else:
1326
+ for path in data.get("built", []):
1327
+ print(f"built {path}", file=sys.stderr)
1328
+ # `not issues` before -- the same thing until `issues` started
1329
+ # carrying those warnings. A board with no `models:` and a
1330
+ # broken project pin must still be told nothing was declared;
1331
+ # a warning is not a reason to withhold the only line this
1332
+ # run had to say.
1333
+ if not data.get("built") and not errors:
1334
+ print(
1335
+ "model: no `models:` declared in board.yaml; nothing to build.",
1336
+ file=sys.stderr,
1337
+ )
1338
+ for issue in errors:
1339
+ print(f"model: {issue.message}", file=sys.stderr)
1340
+ raise typer.Exit(int(exit_code))
1341
+
1342
+ if subcommand not in SUBCOMMANDS:
1343
+ finish(
1344
+ Project(root=None, board_yaml=None),
1345
+ None,
1346
+ _empty_data(subcommand),
1347
+ [
1348
+ Issue(
1349
+ "model.unknown-subcommand",
1350
+ "error",
1351
+ f"Unknown model subcommand: {'(none)' if subcommand is None else subcommand}. "
1352
+ f"Available: {', '.join(SUBCOMMANDS)}.",
1353
+ )
1354
+ ],
1355
+ ExitCode.RUNTIME_FAILURE,
1356
+ )
1357
+ return
1358
+
1359
+ # tan-cli#497 defect 3: `model build` was the only `resolve_project_context`
1360
+ # caller that read `.workspace_root`/`.board_yaml`/`.project()`/`.sdk` off
1361
+ # the returned context and NONE of the three resolution facts -- so it
1362
+ # alone dropped BOTH `sdk.project-pin-unresolved` (tan-cli#263) and
1363
+ # `sdk.global-default-foreign-project` (tan-cli#464), in JSON and text
1364
+ # alike, while `size` and `image` reported them from the same directory
1365
+ # through the same resolver. That matters most here: the `.alpmodel`
1366
+ # packages are compiled against `<resolved checkout>/metadata`, i.e.
1367
+ # against that checkout's target/backend table for `som.sku`.
1368
+ #
1369
+ # Resolved HERE rather than inside `_run_build` so the warnings survive a
1370
+ # `ModelError` too -- `project_pin_issue`'s contract is "shared by EVERY
1371
+ # caller of `resolve_sdk_tiered`", not "every caller that got as far as a
1372
+ # successful build".
1373
+ sdk_issues: list[Issue] = []
1374
+ try:
1375
+ context = resolve_project_context(project, board, sdk_root)
1376
+ sdk_issues = sdk_resolution_issues(
1377
+ context.broken_project_pin,
1378
+ context.sdk_source_tier,
1379
+ context.foreign_global_default_for,
1380
+ )
1381
+ live = LiveOptions(
1382
+ core=core if isinstance(core, str) else "m55_he",
1383
+ wait=float(wait) if isinstance(wait, (int, float)) else 1.5,
1384
+ confirm=confirm is True,
1385
+ probe_serial=probe_serial if isinstance(probe_serial, str) else None,
1386
+ probe_usb_path=probe_usb_path if isinstance(probe_usb_path, str) else None,
1387
+ jlink=jlink if isinstance(jlink, str) else None,
1388
+ sdk_root=sdk_root,
1389
+ against_project=against_project if isinstance(against_project, str) else None,
1390
+ board_yaml=board if isinstance(board, str) and board != "board.yaml" else None,
1391
+ )
1392
+ _refuse_stray_arguments(
1393
+ subcommand, model_id, sku, (device, bool(capture), bool(against_capture), input_file is not None, runs is not None),
1394
+ live,
1395
+ {
1396
+ "--against": (against is not None, ("ab",)),
1397
+ "--against-project": (against_project is not None, ("ab",)),
1398
+ "--calibration": (calibration is not None, ("prep",)),
1399
+ "--per-channel": (per_channel is True, ("prep",)),
1400
+ "--min-samples": (min_samples is not None, ("prep",)),
1401
+ "--exact": (exact is True, ("check",)),
1402
+ },
1403
+ )
1404
+ if subcommand == "doctor":
1405
+ project_, sdk, data, issues, exit_code = _run_doctor(
1406
+ context=context,
1407
+ sdk_root=sdk_root,
1408
+ )
1409
+ elif subcommand == "check":
1410
+ project_, sdk, data, issues, exit_code = _run_check(
1411
+ context=context,
1412
+ metadata_root=metadata_root,
1413
+ sdk_root=sdk_root,
1414
+ exact=exact,
1415
+ )
1416
+ elif subcommand == "run" and device:
1417
+ project_, sdk, data, issues, exit_code = run_device_run(
1418
+ context=context, source=model_id, capture=capture, live=live
1419
+ )
1420
+ elif subcommand == "ab" and device:
1421
+ project_, sdk, data, issues, exit_code = run_device_ab(
1422
+ context=context, source=model_id, against=against, capture=capture,
1423
+ against_capture=against_capture, live=live,
1424
+ )
1425
+ elif subcommand == "run":
1426
+ project_, sdk, data, issues, exit_code = run_run(
1427
+ context=context, source=model_id, runs=20 if runs is None else runs, input_file=input_file
1428
+ )
1429
+ elif subcommand == "ab":
1430
+ project_, sdk, data, issues, exit_code = run_ab(
1431
+ context=context, source=model_id, against=against, runs=20 if runs is None else runs, input_file=input_file
1432
+ )
1433
+ elif subcommand == "prep":
1434
+ project_, sdk, data, issues, exit_code = run_prep(
1435
+ context=context,
1436
+ source=model_id,
1437
+ calibration=calibration,
1438
+ out=out,
1439
+ per_channel=per_channel,
1440
+ min_samples=8 if min_samples is None else min_samples,
1441
+ )
1442
+ elif subcommand in ("zoo", "add"):
1443
+ resolved = _require_metadata_sdk_root(sdk_root, context.workspace_root, "No zoo was read.")
1444
+ zoo_dir = _resolve_metadata_dir(metadata_root, resolved, Path(context.workspace_root)) / "model_zoo"
1445
+ if subcommand == "zoo":
1446
+ project_, sdk, data, issues, exit_code = run_zoo(context=context, zoo_dir=zoo_dir, sku=sku)
1447
+ else:
1448
+ board_doc = _load_board(Path(context.board_yaml))
1449
+ models_ = _require_models_list(board_doc, Path(context.board_yaml))
1450
+ declared = {m["name"] for m in models_ if isinstance(m, dict) and "name" in m}
1451
+ project_, sdk, data, issues, exit_code = run_add(
1452
+ context=context,
1453
+ zoo_dir=zoo_dir,
1454
+ model_id=model_id,
1455
+ sku=declared_sku(board_doc),
1456
+ existing_names=declared,
1457
+ )
1458
+ elif subcommand == "list":
1459
+ project_, sdk, data, issues, exit_code = _run_list(
1460
+ context=context,
1461
+ out=out,
1462
+ )
1463
+ else:
1464
+ project_, sdk, data, issues, exit_code = _run_build(
1465
+ context=context,
1466
+ out=out,
1467
+ metadata_root=metadata_root,
1468
+ sdk_root=sdk_root,
1469
+ )
1470
+ except ModelError as err:
1471
+ finish(
1472
+ Project(root=None, board_yaml=None),
1473
+ None,
1474
+ _empty_data(subcommand),
1475
+ [*sdk_issues, Issue(err.code, "error", err.message)],
1476
+ err.exit_code,
1477
+ )
1478
+ return
1479
+ except Exception as err: # noqa: BLE001 -- the envelope IS the error contract
1480
+ finish(
1481
+ Project(root=None, board_yaml=None),
1482
+ None,
1483
+ _empty_data(subcommand),
1484
+ [
1485
+ *sdk_issues,
1486
+ Issue(
1487
+ "model.internal-failure",
1488
+ "error",
1489
+ f"model {subcommand} failed unexpectedly: {type(err).__name__}: {err}",
1490
+ ),
1491
+ ],
1492
+ ExitCode.INTERNAL_FAILURE,
1493
+ )
1494
+ return
1495
+
1496
+ finish(project_, sdk, data, [*sdk_issues, *issues], exit_code)
1497
+
1498
+
1499
+ # tan-cli#261: adds the seven oracle `GlobalArgs` flags this command is
1500
+ # missing entirely (`--all`/`--ci`/`--no-color`/`--non-interactive`/
1501
+ # `--quiet`/`--target`/`--verbose`); see `tan.core.global_flags`.
1502
+ #
1503
+ # `--board-yaml` is NOT among them any more (tan-cli#398). It used to be, and
1504
+ # the note here claimed that was harmless because "`model`'s own `--board`
1505
+ # already plays `--board-yaml`'s role for real" -- but the two spellings were
1506
+ # never wired together, so the injected one was accepted, dropped, and the
1507
+ # default `./board.yaml` packaged instead of the file the caller named, at
1508
+ # `exitCode: 0` with `issues: []`. It is now a second decl on `--board`
1509
+ # itself (above), so both spellings are one option and read one file.
1510
+ #
1511
+ # The six arity-0 flags above stay accepted-and-dropped, and so does the
1512
+ # injected `--target` (the refusal was reverted per tan-cli#403; see
1513
+ # `core/global_flags.py`) -- `model` has no emit target to honour it with.
1514
+ model = accept_global_flags(model)