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
tan/model/check.py ADDED
@@ -0,0 +1,682 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan model check`'s engine glue -- which backends a SKU screens against,
3
+ and the opportunistic `--exact` upgrade (ADR-0028 amendment, tan-cli#782
4
+ Tasks 4/6).
5
+
6
+ `tan.model.analyze` is the pure static-eligibility screen and deliberately
7
+ knows nothing about a SKU's *actual* NPU backends or about a real compiler --
8
+ its own docstring says so ("no click/typer, no envelope construction").
9
+ This module is the layer above it that a `check` run actually calls:
10
+
11
+ * [`resolve_check_backends`] -- which non-cpu backends @sku's SoM declares
12
+ at all (`tan.model.targets.resolve_targets`, the SAME resolver
13
+ `build_model` uses to decide what to compile), so `check` never screens
14
+ a model against hardware the board doesn't have -- an Ethos-U row on a
15
+ V2N board would misreport hardware that isn't there.
16
+ * [`check_model_backends`] -- one `BackendReport` per resolved backend for
17
+ one model source, walking its ops once (`tensorio.extract_ops`) and
18
+ reusing them across every backend.
19
+ * The `--exact` upgrade: Vela is a free, un-gated `pip install`
20
+ (`ethos-u-vela`), so for `ethos_u` the "offline, no toolchain" constraint
21
+ the static screen exists for is soft. When `--exact` is set and `vela` is
22
+ on PATH, [`_exact_ethos_u`] runs the REAL compiler; [`_report_from_
23
+ vela_compile`] then reads what vela ACTUALLY placed on the NPU (`Blob.
24
+ npu_op_count`/`cpu_op_count`, sourced from vela's own "NPU operators = N
25
+ (P%)" summary line) and only returns `basis: "compiled"` / `npu_coverage:
26
+ "fits"` -- the only basis allowed to say `"fits"`
27
+ (`analyze.BackendReport.npu_coverage`'s own comment) -- at 100% NPU
28
+ placement. A clean vela exit is NOT proof of that: vela exits 0 on a full
29
+ CPU fallback by design (measured: a float32 FULLY_CONNECTED model prints
30
+ "NPU operators = 0 (0.0%)" and still exits 0), so partial/zero placement
31
+ reports the real split (`npu_coverage: "partial"`/`"cpu-only"`) and keeps
32
+ the static per-op verdicts (`report.ops`) rather than discarding them.
33
+ `drpai`/`deepx_dxm1` stay opportunistic-only: both toolchains are
34
+ license-gated (`dxcom`, the DRP-AI TVM checkout), so `--exact` never
35
+ attempts to spawn either here -- [`_license_gated_exact_note`] reports
36
+ that as a reason, not a crash, exactly like `model doctor`'s own narrower
37
+ probes do for the same two backends.
38
+ * The tier-2 upgrade: [`tan.model.perf_apply.apply_perf_point`] swaps in a
39
+ bench-measured `metadata/model_perf/` point (`tan.model.perf` reads it,
40
+ `tan.model.perf_apply` matches + applies it) when alp-sdk publishes one
41
+ for this EXACT identity -- the `basis: "bench"` half of the resolution
42
+ order below. Split into its own module (tan-cli#791 review NIT (c)): it is
43
+ pure string/decision logic with no subprocess in it, and this file was
44
+ against the house module-size cap.
45
+
46
+ RESOLUTION ORDER IS **precomputed -> exact-if-toolchain -> static**, and it is
47
+ not the order the code RUNS in. `check_model_backends` computes the static
48
+ screen first because everything else is built on top of it, then runs
49
+ `--exact` (only when the customer asked, and only when vela is really there),
50
+ then consults the perf point LAST -- but the point is what WINS, because it is
51
+ a measurement off real silicon rather than a screen or a host compile. The one
52
+ exception is decision 1 of the tier-2 plan, and it is deliberate: when the
53
+ customer really does hold the toolchain and their own compile DISAGREES with
54
+ our bench point, `--exact` wins, because their profile may not be ours and the
55
+ number they can act on is the one their machine produces
56
+ (`tan.model.perf_apply._perf_disagreements` defines "disagrees" concretely, on
57
+ the three figures both sides actually report).
58
+
59
+ Does its own IO (reads `metadata/**` through `resolve_targets`/
60
+ `resolve_ethos_u_variant`, and `--exact` shells `vela`) -- the same stance
61
+ `tan.model.build`/`tan.model.targets` already take: IO lives in `tan.model`,
62
+ never in `tan.core` (see `tan.core.model_check`, which is the pure
63
+ serialisation/rendering half `tan.commands.model_cmd` calls alongside this).
64
+ """
65
+ from __future__ import annotations
66
+
67
+ import shutil
68
+ import tempfile
69
+ from dataclasses import replace
70
+ from pathlib import Path
71
+
72
+ from .adapters import Blob
73
+ from .adapters.ethos_u import VelaAdapter, VelaFootprintRefused
74
+ from .build import _placed_nothing_on_accelerator
75
+ from .analyze import BackendReport, analyze_backend, resolve_ethos_u_variant
76
+ from .perf import has_perf_points
77
+ from .perf_apply import (
78
+ _model_sha256,
79
+ _placement_outcome,
80
+ _resolve_hw_rev,
81
+ apply_perf_point,
82
+ )
83
+ from .sram_fit import SramFit, evaluate_sram_fit
84
+ from .targets import TargetSpec, resolve_targets
85
+ from .tensorio import extract_ops, tflite_reader_available
86
+
87
+
88
+ def resolve_check_backends(sku: str, *, metadata_root: Path) -> list[str]:
89
+ """Distinct non-`cpu` backend names @sku's SoM actually declares, in
90
+ `resolve_targets`' own first-seen order -- the exact set `check` screens
91
+ a model against. `cpu` is excluded: "how much of this model can target
92
+ the NPU" has nothing to say about the fallback CPU path itself.
93
+
94
+ This exclusion is also why tier 2 (`tan.model.perf_apply._PERF_TOOLCHAIN`)
95
+ never asks for a `cpu` perf point either, even though alp-sdk's schema
96
+ allows publishing one (tan-cli#791 review item 6): `check_model_backends`
97
+ only ever iterates the backends THIS function returns, so a `cpu` point
98
+ would have no caller to be handed to regardless of what
99
+ `_PERF_TOOLCHAIN` said. See that dict's own comment for the fuller
100
+ campaign-planner-facing statement of the decision.
101
+
102
+ Raises exactly what `resolve_targets` raises (`FileNotFoundError` for no
103
+ SoM preset or no SoC spec, `ValueError` for a malformed `silicon:` ref) --
104
+ a board-level fact about the SKU, not a per-model one, so the caller
105
+ reports it once for the whole run rather than once per declared model.
106
+ """
107
+ seen: list[str] = []
108
+ for spec in resolve_targets(sku, metadata_root=metadata_root):
109
+ if spec.backend != "cpu" and spec.backend not in seen:
110
+ seen.append(spec.backend)
111
+ return seen
112
+
113
+
114
+ # The perf-point step below is gated on `has_perf_points` and its digest is
115
+ # computed LAZILY behind that gate, once for all backends: no alp-sdk has
116
+ # published a `metadata/model_perf/` tree yet, and hashing every model source on
117
+ # every `check` to look inside a directory that does not exist would be pure cost
118
+ # for every customer alive today.
119
+ #
120
+ # `hw_rev` is the customer's own `board.yaml` `som.hw_rev`, or `None` when they
121
+ # declared none -- in which case `tan.model.perf_apply._resolve_hw_rev` falls
122
+ # back to the SKU preset's `default_hw_rev`, which is what the SDK itself does
123
+ # for an omitted key. It reaches ONLY the perf-point step: a bench measurement
124
+ # is pinned to the module revision it ran on, and nothing else in `check` is.
125
+ def check_model_backends(*, backends: list[str], sku: str, source: Path,
126
+ metadata_root: Path, exact: bool,
127
+ hw_rev: str | None = None,
128
+ board_doc: dict | None = None) -> list[BackendReport]:
129
+ """One `BackendReport` per @backends entry for @source -- the ops are
130
+ walked ONCE (`extract_ops`, best-effort: `[]` for a non-.tflite source,
131
+ same contract every ONNX-ingesting backend already treats as
132
+ `undetermined` via `analyze_backend`'s own format gate) and reused across
133
+ every backend, mirroring `build_model`'s own single-read-many-adapters
134
+ shape.
135
+
136
+ A `.tflite` @source that extracts to `[]` is ambiguous on its own -- a
137
+ genuinely op-free/malformed model and a missing `tflite` reader (the
138
+ `model-io` extra) both look identical to `analyze_backend`. Checked here,
139
+ once, rather than inside `analyze_backend` (which stays format-agnostic
140
+ and knows nothing about `model-io`): when the reader is the actual cause,
141
+ the resulting `undetermined` report's note is swapped for one that names
142
+ the fix (`_reader_missing_report`), never left as the generic "nothing to
143
+ score" text that gives a `pip install tan-cli` customer -- exactly the
144
+ shape `ci.yml` itself installs, no `model-io` extra -- no hint at all
145
+ (MAJOR 2 review).
146
+
147
+ See the block comment above for the perf-point step's two properties: the
148
+ lazy digest behind `has_perf_points`, and what @hw_rev is.
149
+
150
+ @board_doc (tan-cli#1288) is `board.yaml` itself, or `None` -- threaded
151
+ down to `_exact_ethos_u`'s SRAM-fit check, which reads
152
+ `cores.<id>.inference.default_arena_kib` off it. `None` (the default) is
153
+ not a refusal: `resolve_arena_kib` falls back to the schema default."""
154
+ ops = extract_ops(source)
155
+ src_format = source.suffix.lstrip(".").lower()
156
+ reader_missing = src_format == "tflite" and not ops and not tflite_reader_available()
157
+ perf_published = has_perf_points(metadata_root)
158
+ resolved_hw_rev = (_resolve_hw_rev(sku, metadata_root, hw_rev)
159
+ if perf_published else None)
160
+ model_sha256: str | None = None
161
+ reports: list[BackendReport] = []
162
+ for backend in backends:
163
+ variant = (resolve_ethos_u_variant(sku, metadata_root=metadata_root)
164
+ if backend == "ethos_u" else None)
165
+ report = analyze_backend(backend=backend, src_format=src_format, ops=ops,
166
+ metadata_root=metadata_root, variant=variant)
167
+ if reader_missing and report.table is not None and not report.ops:
168
+ report = _reader_missing_report(report)
169
+ exact_target: TargetSpec | None = None
170
+ if exact:
171
+ report, exact_target = _apply_exact(report, backend, source, sku,
172
+ metadata_root, board_doc)
173
+ if perf_published:
174
+ if model_sha256 is None:
175
+ model_sha256 = _model_sha256(source)
176
+ # `target` is resolved here, once per backend, and handed to
177
+ # `apply_perf_point` rather than let it re-resolve internally --
178
+ # `tan.model.perf_apply` must never import `_headline_ethos_u_
179
+ # target` back out of this module (that would be the cycle this
180
+ # split exists to avoid), so the caller that already has this
181
+ # function is the one that calls it.
182
+ # Under `--exact` it is the target the reported compile came from
183
+ # (tan-cli#1497: not always the headline one), so a matched bench
184
+ # point re-bases the SAME target's figures.
185
+ target = ((exact_target or _headline_ethos_u_target(sku, metadata_root))
186
+ if backend == "ethos_u" else None)
187
+ report = apply_perf_point(report, backend=backend, sku=sku,
188
+ model_sha256=model_sha256,
189
+ hw_rev=resolved_hw_rev,
190
+ metadata_root=metadata_root,
191
+ target=target)
192
+ reports.append(report)
193
+ return reports
194
+
195
+
196
+ def _reader_missing_report(report: BackendReport) -> BackendReport:
197
+ """Swap `_empty_ops_report`'s generic "nothing was extracted" note for one
198
+ that names the actual, fixable cause -- the `tflite` reader (the
199
+ `model-io` extra) is not installed on this host -- without touching
200
+ `npu_coverage`: this is still `undetermined`, never a verdict on the
201
+ model itself. Only called when a resolved table (`report.table is not
202
+ None`) proves the format/backend pairing was fine and ops really did come
203
+ back empty for a reader reason, never for `format-not-accepted` or
204
+ `no-table-for-backend` (both already carry their own honest note and
205
+ leave `report.table` `None`)."""
206
+ return replace(report, notes=[
207
+ "no operators could be extracted: the `tflite` reader is not "
208
+ "installed on this host (pip install tan-cli[model-io]); this is "
209
+ "not a statement about the model itself.",
210
+ ])
211
+
212
+
213
+ def _apply_exact(report: BackendReport, backend: str, source: Path, sku: str,
214
+ metadata_root: Path, board_doc: dict | None = None,
215
+ ) -> tuple[BackendReport, TargetSpec | None]:
216
+ if backend != "ethos_u":
217
+ return _license_gated_exact_note(report, backend), None
218
+ return _exact_ethos_u(report, source, sku, metadata_root, board_doc)
219
+
220
+
221
+ def _license_gated_exact_note(report: BackendReport, backend: str) -> BackendReport:
222
+ """`drpai`/`deepx_dxm1` never attempt a real compile under `--exact` in
223
+ this release -- both toolchains are license-gated (`dxcom`, the DRP-AI
224
+ TVM checkout), so there is no un-gated path the way `vela` is for
225
+ ethos_u. Reported as a note, never a crash -- mirrors `model doctor`'s
226
+ own `_deepx_dxm1_status`/`_drpai_status` treatment of the same two
227
+ backends.
228
+
229
+ TWO SEPARATE NOTES, not one (tan-cli#791 round-2 review item 4): the HOST
230
+ fact ("--exact is not available ...: {tool} is license-gated.") and the
231
+ BASIS clause ("This stays the static screen.") used to be one string, so
232
+ `tan.model.perf_apply._perf_point_report`'s "keep every note starting
233
+ with `--exact`" rule kept the WHOLE thing -- including the basis clause
234
+ -- when a bench point for this exact identity later matched, landing
235
+ "this stays the static screen" alongside an envelope whose `basis` was
236
+ already `"bench"`. Splitting them means the filter's own prefix check
237
+ does the rest: the host note still starts with `--exact` and survives (it
238
+ is still true that `--exact` cannot compile this backend); the basis
239
+ clause does not start with `--exact` and is dropped automatically,
240
+ exactly the way every other basis-describing note already is."""
241
+ if report.ops and report.ops[0].reason == "format-not-accepted":
242
+ return report # wrong ingest format either way; nothing to caveat
243
+ tool = "dxcom" if backend == "deepx_dxm1" else "the DRP-AI TVM toolchain"
244
+ host_note = (f"--exact is not available for {backend} in this release: "
245
+ f"{tool} is license-gated.")
246
+ return replace(report, notes=[*report.notes, host_note,
247
+ "This stays the static screen."])
248
+
249
+
250
+ #: A note is one line in the JSON envelope and the text render; a failed
251
+ #: `vela` subprocess can raise `RuntimeError` wrapping its own raw stderr,
252
+ #: which can be a multi-hundred-character, multi-newline Python traceback
253
+ #: (measured: 750 characters, 9 newlines) that must never land verbatim in
254
+ #: either (MAJOR 2 review). This budget governs FOREIGN text -- whatever
255
+ #: arbitrary thing vela's stderr happened to contain.
256
+ _VELA_ERR_NOTE_BUDGET = 200
257
+
258
+ #: `VelaFootprintRefused` gets a wider one, because it is a different kind of
259
+ #: string: tan AUTHORS it, one line by contract, from a fixed template over a
260
+ #: handful of bounded fields (accel config, two op counts, vela's own memory
261
+ #: areas + resolved profile names). At 200 it was cut at "... Refusing to
262
+ #: report a zero…" -- measured inner length 197 -- so the customer-facing note
263
+ #: in the text report AND the JSON envelope carried the diagnosis with none of
264
+ #: the remediation, which is the whole reason the refusal was reworded
265
+ #: (tan-cli#789 review BLOCKER 2 / MINOR 5). The two numbers below are RE-RUN
266
+ #: against the live template every time it is reworded, never carried forward
267
+ #: with a delta applied: a real `ethos-u85-256` refusal is 623 characters
268
+ #: (measured by compiling `tests/fixtures/models/tiny_int8.tflite` at
269
+ #: `ethos-u85-256` with real `ethos-u-vela` 5.1.0, carrying e8.json's own
270
+ #: `vendor_config_filename` and its no-DRAM answer, then taking
271
+ #: `len(str(exc))`), and a deliberately maximal one (three memory areas at
272
+ #: five significant figures, the longest profile name vela emits, four-digit
273
+ #: op counts, both metadata clauses) is 691 --
274
+ #: `test_the_refusal_note_budget_covers_a_maximal_refusal` builds exactly that
275
+ #: and fails if a future message outgrows this. Both were 3 and 2 characters
276
+ #: over-stated when last edited, because the reword's delta was estimated
277
+ #: rather than the message re-measured (tan-cli#789 review MINOR 1); no gate
278
+ #: reads either figure, so an assumed one rots silently. Still bounded and still
279
+ #: word-boundary-truncated with a trailing marker, so the one-line guarantee
280
+ #: is unchanged; the 750-character, 9-newline traceback stays governed by
281
+ #: `_VELA_ERR_NOTE_BUDGET` above, not by this.
282
+ _VELA_REFUSAL_NOTE_BUDGET = 700
283
+
284
+
285
+ #: Python's own traceback banner. When @err's message wraps a raw traceback (a
286
+ #: failed `vela` subprocess re-raising its stderr verbatim), the FIRST line is
287
+ #: the only one with no diagnostic content in it -- measured on a real run the
288
+ #: note read "--exact compile with vela failed (vela failed for ethos-u85-256:
289
+ #: Traceback (most recent call last):); reporting the static screen instead.",
290
+ #: discarding the actual cause ("RuntimeError: Compilation failed: No networks
291
+ #: defined via GraphAPI") that sat one line further down.
292
+ _TRACEBACK_BANNER = "Traceback (most recent call last):"
293
+
294
+
295
+ def _short_vela_error(err: Exception, *, budget: int = _VELA_ERR_NOTE_BUDGET) -> str:
296
+ """ONE line of @err's message, cut to @budget characters on a word
297
+ boundary with a trailing `…` when it doesn't already fit -- mirrors
298
+ `tan.core.doctor_render._truncate_fix`'s word-boundary-plus-ellipsis
299
+ convention. Never a mid-word cut, never a bare truncation with no marker,
300
+ and never a multi-line traceback.
301
+
302
+ WHICH line: for a wrapped traceback, the LAST non-empty one -- the
303
+ exception line, the only one that says what actually went wrong -- kept
304
+ behind whatever @err's own first line said BEFORE the banner (the accel
305
+ config, in `VelaAdapter.compile`'s "vela failed for ethos-u85-256:"), so
306
+ the note names both the target and the cause. Otherwise the first line,
307
+ unchanged.
308
+
309
+ @budget is a parameter rather than the bare constant because the two
310
+ callers pass genuinely different kinds of string: arbitrary foreign vela
311
+ stderr (`_VELA_ERR_NOTE_BUDGET`) versus tan's own single-line
312
+ `VelaFootprintRefused` template (`_VELA_REFUSAL_NOTE_BUDGET`). Both are
313
+ bounded; only the number differs."""
314
+ text = str(err).strip()
315
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
316
+ if not lines:
317
+ return text
318
+ if _TRACEBACK_BANNER in text and len(lines) > 1:
319
+ prefix = lines[0].split(_TRACEBACK_BANNER, 1)[0].strip()
320
+ line = f"{prefix} {lines[-1]}".strip() if prefix else lines[-1]
321
+ else:
322
+ line = lines[0]
323
+ if len(line) <= budget:
324
+ return line
325
+ cut = line[:budget]
326
+ boundary = cut.rfind(" ")
327
+ if boundary > 0:
328
+ cut = cut[:boundary]
329
+ return f"{cut}…"
330
+
331
+
332
+ def _footprint_refused_note(report: BackendReport, err: Exception) -> BackendReport:
333
+ """`--exact` degrades to the static screen when tan refuses the footprint
334
+ of an otherwise CLEAN vela run -- and says exactly that.
335
+
336
+ NOT "compile with vela failed" (tan-cli#789 review NIT 8): vela exited 0,
337
+ wrote its output and printed a real placement summary. What failed was
338
+ tan's acceptance of the footprint it reported. Wording the two the same
339
+ would send a customer hunting a vela bug that isn't there.
340
+
341
+ The refusal text passes through at `_VELA_REFUSAL_NOTE_BUDGET`, so the
342
+ remediation clause at its tail survives into the text report and the JSON
343
+ envelope instead of being cut off mid-diagnosis.
344
+
345
+ TWO NOTES, not one (tan-cli#791 round-2 review item 4): `detail` is the
346
+ HOST/toolchain fact (vela's own refusal, still true of this run whatever
347
+ else happens); "Reporting the static screen instead." is a BASIS clause
348
+ that must not survive a re-base onto a matched bench point. Splitting
349
+ them lets `tan.model.perf_apply._perf_point_report`'s own "keep every
350
+ `--exact`-prefixed note" filter drop the second sentence for free -- it
351
+ does not start with `--exact` -- rather than keeping the whole string
352
+ verbatim next to a `basis: "bench"` envelope."""
353
+ detail = _short_vela_error(err, budget=_VELA_REFUSAL_NOTE_BUDGET)
354
+ return replace(report, notes=[*report.notes, f"--exact: {detail}",
355
+ "Reporting the static screen instead."])
356
+
357
+
358
+ def _exact_ethos_u(report: BackendReport, source: Path, sku: str,
359
+ metadata_root: Path, board_doc: dict | None = None,
360
+ ) -> tuple[BackendReport, TargetSpec | None]:
361
+ """Runs the real `vela` compile when it is on PATH; degrades cleanly --
362
+ and SAYS SO, in a note -- back to @report (the static screen) for every
363
+ other case: no vela, no resolvable accelerator config, a vela failure, or
364
+ a footprint tan refused. Never raises. The compile succeeding is handed to
365
+ `_report_from_vela_compile`, which is what actually decides `npu_coverage`
366
+ -- NOT this function, and NOT vela's exit code (0 on a full CPU fallback
367
+ by design).
368
+
369
+ The refusal gets its OWN except-branch ahead of the general one
370
+ (`_footprint_refused_note`): "compile with vela failed" is the wrong
371
+ sentence for a run that exited 0 and printed a real placement summary, and
372
+ the refusal's remediation tail needs the wider note budget to survive.
373
+
374
+ Gated on `VelaAdapter().accepts(...)` directly, NOT on
375
+ `report.ops[0].reason == "format-not-accepted"`: `extract_ops` (tensorio)
376
+ only ever extracts operators from a `.tflite` source, so `report.ops` is
377
+ `[]` for every OTHER format too -- meaning the reason-based check was
378
+ dead code for exactly the case it existed to guard (ethos_u only ingests
379
+ "tflite", so it only ever GETS "format-not-accepted" for a non-.tflite
380
+ source, which is precisely when `ops` is always empty). Left ungated,
381
+ `VelaAdapter().compile()` was invoked on e.g. a `.onnx` source under
382
+ `--exact` on an Ethos-U SKU, and a `vela` failure on unparseable input
383
+ can raise with its own raw, multi-line traceback as the message (MAJOR 2
384
+ review: measured a 750-character, 9-newline traceback landing in
385
+ `notes[1]` of the JSON envelope this way)."""
386
+ if not VelaAdapter().accepts(source.suffix.lstrip(".").lower()):
387
+ return report, None
388
+ if shutil.which("vela") is None:
389
+ # TWO notes, not one (tan-cli#791 round-2 review item 4): "vela is
390
+ # not on PATH" is a HOST fact, still true of this run even when a
391
+ # bench point later matches for this exact identity; "reporting the
392
+ # static screen instead" is a BASIS clause that becomes false the
393
+ # moment it does. Kept as one string, the whole thing used to survive
394
+ # `tan.model.perf_apply._perf_point_report`'s "keep every
395
+ # `--exact`-prefixed note" filter and land next to a `basis: "bench"`
396
+ # envelope. Split, only the host sentence starts with `--exact`; the
397
+ # basis sentence is dropped by that same filter automatically.
398
+ return replace(report, notes=[
399
+ *report.notes,
400
+ "--exact was requested, but vela is not on PATH (pip install "
401
+ "tan-cli[model-compile]).",
402
+ "Reporting the static screen instead.",
403
+ ]), None
404
+ headline = _headline_ethos_u_target(sku, metadata_root)
405
+ if headline is None:
406
+ # NOT split like the sites above (tan-cli#791 round-2 review item 4):
407
+ # `check_model_backends` resolves this SAME `_headline_ethos_u_target`
408
+ # call, with the SAME sku/metadata_root, for the tier-2 step -- a pure
409
+ # function of metadata, so if it is `None` HERE it is `None` there
410
+ # too, and `_resolve_perf_point` refuses outright on `backend ==
411
+ # "ethos_u" and target is None` before it ever looks at a point. This
412
+ # note can therefore never ride alongside a `basis: "bench"` report,
413
+ # so its basis clause is never false.
414
+ note = ("--exact could not resolve an Ethos-U accelerator config for "
415
+ "this SKU; reporting the static screen instead.")
416
+ return replace(report, notes=[*report.notes, note]), None
417
+ # tan-cli#1497: `tan model build` compiles EVERY ethos_u target and refuses
418
+ # the model only when none of them fits, so `--exact` must too -- a
419
+ # headline-only check exited 2 while build shipped the targets that fit.
420
+ # The headline target goes first (it keeps deciding a lone-target SKU's
421
+ # report exactly as before); the first target that compiles AND fits wins;
422
+ # when none does, the headline result stands, so a model no target can
423
+ # run still reports its `model.sram-no-fit`.
424
+ results: list[tuple[TargetSpec, BackendReport, Blob | None]] = []
425
+ for cand in _exact_ethos_u_targets(sku, metadata_root, headline):
426
+ res, blob = _exact_one_target(report, source, cand, sku, metadata_root,
427
+ board_doc)
428
+ results.append((cand, res, blob))
429
+ chosen_target, chosen, _ = results[0]
430
+ for cand, res, blob in results:
431
+ # Same skip `build` applies: a compile that places nothing on the
432
+ # accelerator is not an accelerator target.
433
+ if (blob is not None and res.basis == "compiled"
434
+ and not (res.sram_fit and res.sram_fit.no_fit)
435
+ and not _placed_nothing_on_accelerator("ethos_u", blob)):
436
+ chosen_target, chosen = cand, res
437
+ break
438
+ else:
439
+ return chosen, chosen_target
440
+ if chosen_target == headline:
441
+ return chosen, chosen_target
442
+ extra: list[str] = []
443
+ skipped = [c.accel_config for c, r, _b in results
444
+ if r.sram_fit is not None and r.sram_fit.no_fit]
445
+ if skipped:
446
+ extra.append(
447
+ f"--exact: ethos_u target(s) {', '.join(skipped)} do not fit SRAM; "
448
+ f"`tan model build` skips them and ships {chosen_target.accel_config}.")
449
+ nothing = [c.accel_config for c, _r, b in results
450
+ if b is not None and _placed_nothing_on_accelerator("ethos_u", b)]
451
+ if nothing:
452
+ extra.append(
453
+ f"--exact: ethos_u target(s) {', '.join(nothing)} place no operator on "
454
+ f"the NPU; `tan model build` skips them.")
455
+ # A failed or refused earlier target must not vanish when a later one wins:
456
+ # carry its host-fact notes (the "Reporting the static screen instead"
457
+ # basis clause is false for the winner, so only `--exact` notes survive).
458
+ for cand, res, blob in results:
459
+ if blob is None and cand != chosen_target:
460
+ extra += [f"{n} [target {cand.accel_config}]"
461
+ for n in res.notes[len(report.notes):]
462
+ if n.startswith("--exact")]
463
+ if extra:
464
+ chosen = replace(chosen, notes=[*chosen.notes, *extra])
465
+ return chosen, chosen_target
466
+
467
+
468
+ def _exact_ethos_u_targets(sku: str, metadata_root: Path,
469
+ headline: TargetSpec) -> list[TargetSpec]:
470
+ """Every ethos_u target `tan model build` compiles for @sku, the headline
471
+ one first (tan-cli#1497)."""
472
+ rest = [s for s in resolve_targets(sku, metadata_root=metadata_root)
473
+ if s.backend == "ethos_u" and s != headline]
474
+ return [headline, *rest]
475
+
476
+
477
+ def _screen_for_target(report: BackendReport, source: Path, target: TargetSpec,
478
+ metadata_root: Path) -> BackendReport:
479
+ """tan-cli#1519: the static screen's `variant`/`table`/per-op verdicts come
480
+ from the SoM preset's headline variant; a target of another variant (u55 on
481
+ a u85-headline SKU) must be reported against ITS OWN table, not the
482
+ headline's. Same-variant targets, and any failure to re-screen, keep
483
+ @report unchanged."""
484
+ variant = target.accel_config.split("-")[1] if target.accel_config.count("-") >= 2 else None
485
+ if not variant or report.variant is None or variant == report.variant:
486
+ return report
487
+ try:
488
+ rescreened = analyze_backend(
489
+ backend="ethos_u", src_format=source.suffix.lstrip(".").lower(),
490
+ ops=extract_ops(source), metadata_root=metadata_root, variant=variant)
491
+ except Exception as err: # noqa: BLE001 -- a failed re-screen keeps the headline screen
492
+ # ...but says so: u85 variant/table next to u55 compile figures is the
493
+ # #1519 mislabel, and must not be silent.
494
+ return replace(report, notes=[
495
+ *report.notes,
496
+ f"--exact: could not re-screen against {variant}'s table "
497
+ f"({str(err)[:120]}); variant/table below are the headline's."])
498
+ return replace(
499
+ report, variant=rescreened.variant, table=rescreened.table, ops=rescreened.ops,
500
+ npu_coverage=rescreened.npu_coverage,
501
+ compute_on_npu_pct_max=rescreened.compute_on_npu_pct_max,
502
+ uncosted_cpu_op_count=rescreened.uncosted_cpu_op_count)
503
+
504
+
505
+ def _exact_one_target(report: BackendReport, source: Path, target: TargetSpec,
506
+ sku: str, metadata_root: Path,
507
+ board_doc: dict | None,
508
+ ) -> tuple[BackendReport, Blob | None]:
509
+ """Re-screen @report against @target's own variant, then compile it. A
510
+ successful compile rebuilds `notes` from scratch, so a re-screen-failure
511
+ note (host fact, starts with `--exact`) is re-added on that path."""
512
+ screened = _screen_for_target(report, source, target, metadata_root)
513
+ kept = screened.notes[len(report.notes):]
514
+ out, blob = _exact_compile_target(screened, source, target, sku, metadata_root, board_doc)
515
+ if kept and blob is not None:
516
+ out = replace(out, notes=[*out.notes, *kept])
517
+ return out, blob
518
+
519
+
520
+ def _exact_compile_target(report: BackendReport, source: Path, target: TargetSpec,
521
+ sku: str, metadata_root: Path,
522
+ board_doc: dict | None,
523
+ ) -> tuple[BackendReport, Blob | None]:
524
+ """The real vela compile of ONE target, degrading to a note on @report on
525
+ any failure (blob is then `None`). Never raises."""
526
+ accel_config = target.accel_config
527
+ try:
528
+ with tempfile.TemporaryDirectory() as tmp:
529
+ # Accel-config, memory profile AND the part's diagnostic facts all
530
+ # come off the SAME @target (one of `tan model build`'s ethos_u targets), so
531
+ # `--exact` reports what `tan model build` would actually compile
532
+ # for this SKU -- a screen run against a different memory model than
533
+ # the build uses would be a figure the customer cannot act on, and a
534
+ # refusal here has to read exactly as the build's would.
535
+ blob = VelaAdapter().compile(
536
+ source, accel_config=accel_config, out_dir=Path(tmp),
537
+ vela_memory_mode=target.vela_memory_mode,
538
+ vela_system_config=target.vela_system_config,
539
+ vela_vendor_system_config=target.vela_vendor_system_config,
540
+ vela_vendor_config_filename=target.vela_vendor_config_filename,
541
+ soc_declares_dram=target.soc_declares_dram)
542
+ except VelaFootprintRefused as err:
543
+ return _footprint_refused_note(report, err), None
544
+ except Exception as err: # noqa: BLE001 -- a failed exact compile degrades, it never crashes
545
+ # TWO notes, not one (tan-cli#791 round-2 review item 4) -- same split
546
+ # as the "vela is not on PATH" branch above, for the same reason: the
547
+ # HOST fact (vela really did fail, and why) must survive a re-base
548
+ # onto a matched bench point; the BASIS clause ("reporting the static
549
+ # screen instead") must not.
550
+ return replace(report, notes=[
551
+ *report.notes,
552
+ f"--exact compile with vela failed ({_short_vela_error(err)}).",
553
+ "Reporting the static screen instead.",
554
+ ]), None
555
+ # tan-cli#1288: the SRAM0 residency + arena fit check, off the SAME
556
+ # resolved target/blob a real compile just produced -- `target.paired_core`
557
+ # (which `cores.<id>.inference.default_arena_kib` applies) and
558
+ # `target.vela_memory_mode` (whether the check applies at all) both come
559
+ # from @target (resolved once by the caller), never re-resolved.
560
+ sram_fit = evaluate_sram_fit(
561
+ memory_mode=target.vela_memory_mode, req_sram_kib=blob.req_sram_kib,
562
+ blob_len_bytes=len(blob.payload), board_doc=board_doc,
563
+ paired_core=target.paired_core, sku=sku, metadata_root=metadata_root,
564
+ )
565
+ return _report_from_vela_compile(report, blob, accel_config, sram_fit=sram_fit), blob
566
+
567
+
568
+ def _vela_placement_note(blob: Blob, accel_config: str, total: int, pct: float,
569
+ sizing: str) -> str:
570
+ """The one-line placement summary a `basis: "compiled"` report leads with.
571
+
572
+ `blob.compiler_version` is ALREADY the full string to show -- "vela"
573
+ (degraded) or "vela X.Y.Z" (real); wrapping it in a second "vela (...)"
574
+ would read "vela (vela)" on the degraded path."""
575
+ return (f"{blob.compiler_version} compiled for {accel_config}: "
576
+ f"{blob.npu_op_count}/{total} operators placed on the NPU "
577
+ f"({pct:.0f}%); {sizing}.")
578
+
579
+
580
+ # `arena_bytes`/`req_sram_kib` are carried as REPORT FIELDS as well as inside
581
+ # `sizing`'s prose below: `_perf_disagreements` compares a bench point against
582
+ # a compiled report figure-by-figure, and parsing tan's own sentence back to do
583
+ # that would be a second, silently-drifting spelling of the same two numbers.
584
+ def _report_from_vela_compile(report: BackendReport, blob: Blob, accel_config: str,
585
+ sram_fit: SramFit | None = None) -> BackendReport:
586
+ """Turn a REAL, successful vela compile into a `BackendReport` driven by
587
+ what vela actually placed on the NPU -- never by the fact that compile()
588
+ didn't raise. **Vela exits 0 on a full CPU fallback by design** (measured:
589
+ a float32 FULLY_CONNECTED model compiled for `ethos-u85-256` prints "NPU
590
+ operators = 0 (0.0%)" and still exits 0), so "it didn't crash" is not
591
+ proof of NPU placement -- that overclaim is exactly what this function
592
+ replaces (the BLOCKER this fix addresses).
593
+
594
+ Reads `blob.cpu_op_count`/`npu_op_count` (vela's own placement verdict):
595
+ 100% NPU (no CPU ops, at least one op ran) is the only case that reports
596
+ `"fits"`, the only `npu_coverage` this module ever emits at `basis:
597
+ "compiled"` -- `ops` stays `[]`. Partial or 0% NPU reports the REAL split
598
+ (`npu_placement_pct_real`) and KEEPS @report's own per-op verdicts
599
+ (`ops=report.ops`) rather than discarding them -- a 0%-NPU compile must
600
+ never read as a bare, contextless success. An unparseable placement
601
+ summary degrades to `_vela_placement_unreadable`, same as every other
602
+ `_exact_ethos_u` failure path.
603
+
604
+ `pct` -- vela's aggregate CPU/NPU op *count* split -- goes into
605
+ `npu_placement_pct_real`, NEVER into `compute_on_npu_pct_max` (left
606
+ `None` here): the latter's contract is a MAC-weighted figure
607
+ (`analyze.BackendReport`'s own field comment), and vela's placement
608
+ summary carries no per-op MAC data to weight `pct` by -- reusing that
609
+ field for an op-count ratio was the unit mismatch MAJOR 4 review flagged.
610
+
611
+ `blob.caveats` follow the placement note verbatim and are never dropped
612
+ -- today, vela's own "may be invalid or non-optimal" verdict on the
613
+ BUILT-IN default profile it fell back to. A `basis: "compiled"` report
614
+ off a default-profile compile must say so; the figures above it describe
615
+ vela's default memory model, not the module's (see
616
+ `tan.model.adapters.ethos_u`'s module docstring)."""
617
+ sizing = f"arena {blob.arena_bytes} bytes, SRAM {blob.req_sram_kib} KiB"
618
+ outcome = _placement_outcome(report, blob.npu_op_count, blob.cpu_op_count)
619
+ if outcome is None:
620
+ return _vela_placement_unreadable(report, blob, accel_config, sizing)
621
+ total = blob.npu_op_count + blob.cpu_op_count
622
+ pct = 100.0 * blob.npu_op_count / total
623
+ coverage, ops, uncosted = outcome
624
+ note = _vela_placement_note(blob, accel_config, total, pct, sizing)
625
+ return BackendReport(
626
+ backend="ethos_u", variant=report.variant, table=report.table,
627
+ npu_coverage=coverage, compute_on_npu_pct_max=None,
628
+ npu_placement_pct_real=pct, uncosted_cpu_op_count=uncosted, ops=ops,
629
+ basis="compiled", confidence="certain",
630
+ arena_bytes=blob.arena_bytes, req_sram_kib=blob.req_sram_kib,
631
+ notes=[note, *blob.caveats],
632
+ sram_fit=sram_fit,
633
+ )
634
+
635
+
636
+ def _vela_placement_unreadable(report: BackendReport, blob: Blob, accel_config: str,
637
+ sizing: str) -> BackendReport:
638
+ """vela ran and produced output, but `_parse_vela_placement` found no
639
+ "CPU/NPU operators = N" lines in it (an unexpected output shape, e.g. a
640
+ future vela version that changes this text) -- degrade to @report (the
641
+ static screen) exactly like every other `_exact_ethos_u` failure
642
+ path. This function cannot verify a placement it can't read, so it does
643
+ not claim one -- never "0 CPU ops, so it must be all-NPU".
644
+
645
+ TWO notes, not one (tan-cli#791 round-2 review item 4) -- the same split
646
+ as every other degrade note in this module: the HOST fact (vela compiled,
647
+ but its placement summary could not be read) survives a re-base onto a
648
+ matched bench point; the BASIS clause ("reporting the static screen
649
+ instead") does not, because it stops being true the moment one exists."""
650
+ note = (f"--exact compiled with {blob.compiler_version} for {accel_config} "
651
+ f"({sizing}), but its operator-placement summary could not be read, "
652
+ f"so NPU placement cannot be verified.")
653
+ return replace(report, notes=[*report.notes, note,
654
+ "Reporting the static screen instead.", *blob.caveats])
655
+
656
+
657
+ def _headline_ethos_u_target(sku: str, metadata_root: Path) -> TargetSpec | None:
658
+ """The target `--exact` hands `vela`: `resolve_ethos_u_variant`'s own
659
+ PRIMARY variant, at the highest `mac_per_cycle` config resolved for it --
660
+ an arbitrary but deterministic tiebreak for a SoM shipping more than one
661
+ NPU at the same variant (E1M-AEN701's `alif:ensemble:e7` lists a
662
+ high-perf U55 at 256 MAC/cycle alongside a high-efficiency U55 at 128,
663
+ `metadata/socs/alif/ensemble/e7.json`'s own `npus[]`). There is no
664
+ per-SKU "preferred accel_config" field in the metadata this could read
665
+ instead of guessing; None when the variant itself doesn't resolve
666
+ (drpai/deepx_dxm1 SoMs) or no target matches it.
667
+
668
+ Returns the whole `TargetSpec` rather than just its `accel_config` so the
669
+ caller can hand the adapter this part's memory profile and its diagnostic
670
+ facts alongside it (tan-cli#789 review (g)): all of them come off the SAME
671
+ resolved target, so `--exact` can never describe one target's accelerator
672
+ with another's memory model or another part's vendor `.ini`."""
673
+ variant = resolve_ethos_u_variant(sku, metadata_root=metadata_root)
674
+ if variant is None:
675
+ return None
676
+ prefix = f"ethos-{variant}-"
677
+ candidates = [s for s in resolve_targets(sku, metadata_root=metadata_root)
678
+ if s.backend == "ethos_u" and s.accel_config.startswith(prefix)]
679
+ if not candidates:
680
+ return None
681
+ return max(candidates, key=lambda s: int(s.accel_config.rsplit("-", 1)[-1]))
682
+