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/analyze.py ADDED
@@ -0,0 +1,694 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Static NPU-eligibility screen for a packaged model (ADR-0028 amendment).
3
+
4
+ Answers, offline and with no NPU toolchain installed, "how much of this model
5
+ can target the NPU on this SoM, and what definitely cannot?" -- with claims
6
+ the evidence actually supports. Supersedes the retired `fits | cpu-fallback |
7
+ no-fit` vocabulary: no backend can deliver `fits` statically (Vela attaches
8
+ Generic constraints -- quantization, per-axis quant, dtype, zero-point, shape
9
+ -- to every operator and Specific ones to 30 of 70; DRP-AI gates acceptance on
10
+ enumerated kernel x stride x padding x dilation x groups, so the same operator
11
+ name is accepted or rejected on tensor shape alone). So:
12
+
13
+ * **Negatives are sound.** An operator absent from the resolved table is
14
+ certainly CPU (`cpu-certain`).
15
+ * **Positives are capped at "eligible", never "will run".** An operator
16
+ present in the table is `npu-eligible` -- a static screen cannot see the
17
+ shape/quantization decision a real compile makes.
18
+ * **A missing source-format match, or a missing support table, is
19
+ `undetermined`, never `cpu-only`.** Both read to a customer as "won't
20
+ run", which is false: every backend degrades to silent CPU fallback
21
+ rather than refusing. `deepx_dxm1` ships no table by decision -- DEEPX is
22
+ the headline feature of V2M, so a fabricated negative there is the worst
23
+ outcome this module exists to prevent.
24
+
25
+ The word "fits" is reserved for `basis: compiled` or `basis: bench`; this
26
+ module's `basis` is always `"static-screen"` and must never emit it.
27
+
28
+ Pure engine: no click/typer, no envelope construction, no file writes. Every
29
+ public function takes `metadata_root` explicitly rather than resolving an
30
+ ambient SDK checkout -- `tan.model` stays self-contained
31
+ (`tests/model/test_model_package_imports.py`)."""
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ from collections.abc import Sequence
37
+ from dataclasses import dataclass, field, replace
38
+ from pathlib import Path
39
+
40
+ from tan.core.shapes import matches_glob_suffix
41
+
42
+ from .adapters import CompilerAdapter
43
+ from .adapters.cpu import CpuAdapter
44
+ from .adapters.deepx import DeepxAdapter
45
+ from .adapters.drpai import DrpaiAdapter
46
+ from .adapters.ethos_u import VelaAdapter
47
+ from .adapters.executorch import ExecutorchAdapter
48
+ from .npu_ops_validate import TableValidator
49
+ from .sram_fit import SramFit
50
+ from .tensorio import OpDesc
51
+
52
+ # One adapter instance per source format a backend can ingest -- mirrors
53
+ # `build.py`'s own `_ADAPTERS` registry (deliberately a second, independent
54
+ # instantiation: these are stateless, side-effect-free objects, and importing
55
+ # `build.py`'s registry directly would reach into another module's private
56
+ # (`_`-prefixed) name for no benefit). A `backend` string maps to >= 1
57
+ # adapter -- e.g. "cpu" carries both `CpuAdapter` (.tflite) and
58
+ # `ExecutorchAdapter` (.pte) -- so the format gate below asks "does ANY
59
+ # adapter registered for this backend accept this format", the same question
60
+ # `build_model()` asks per model.
61
+ _ADAPTERS: list[CompilerAdapter] = [
62
+ CpuAdapter(), VelaAdapter(), DrpaiAdapter(), DeepxAdapter(), ExecutorchAdapter(),
63
+ ]
64
+
65
+
66
+ def _adapters_by_backend() -> dict[str, list[CompilerAdapter]]:
67
+ by_backend: dict[str, list[CompilerAdapter]] = {}
68
+ for a in _ADAPTERS:
69
+ by_backend.setdefault(a.backend, []).append(a)
70
+ return by_backend
71
+
72
+
73
+ _ADAPTERS_BY_BACKEND = _adapters_by_backend()
74
+
75
+
76
+ def _known_backend(backend: str) -> bool:
77
+ return backend in _ADAPTERS_BY_BACKEND
78
+
79
+
80
+ def _accepts(backend: str, src_format: str) -> bool:
81
+ return any(a.accepts(src_format) for a in _ADAPTERS_BY_BACKEND.get(backend, []))
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class OpVerdict:
86
+ """One operator's static verdict against a resolved support table."""
87
+
88
+ op: str # as spelled in the model's own vocabulary
89
+ status: str # "npu-eligible" | "cpu-certain" | "unknown"
90
+ reason: str # "op-not-in-table" | "constraint-unchecked"
91
+ # | "no-table-for-backend" | "format-not-accepted"
92
+ macs: int = 0 # 0 when not computable
93
+
94
+
95
+ #: The `npu_coverage` word a report states when it has no placement verdict of
96
+ #: its own to state. It means TWO different things depending on `basis` -- at
97
+ #: `"static-screen"`, "nothing was screened at all" (`_format_not_accepted_
98
+ #: report`/`_no_table_report`/`_empty_ops_report` below); at `"bench"`,
99
+ #: "a real measurement rebased this report but carried no placement, so the
100
+ #: coverage word is WITHHELD rather than borrowed from the static screen
101
+ #: underneath" (`tan.model.perf_apply._perf_point_report`). Named rather than
102
+ #: spelled inline at those four sites so the withholding rule and the
103
+ #: `"bench"` entry of `LEGITIMATE_COVERAGE_BY_BASIS` below cannot drift apart:
104
+ #: they are the same decision written once.
105
+ COVERAGE_WITHHELD = "undetermined"
106
+
107
+ #: `npu_coverage` at `basis: "compiled"` -- exactly what `tan.model.perf.
108
+ #: coverage_from_placement` can return off a REAL per-operator placement, the
109
+ #: one function in tan allowed to return `"fits"`. `None` from that function
110
+ #: (an unreadable placement summary) is not a fourth word: it degrades to the
111
+ #: static screen with its basis unchanged (`tan.model.check.
112
+ #: _vela_placement_unreadable`), so it contributes no `"compiled"` pair.
113
+ _COVERAGE_AT_COMPILED = frozenset({"fits", "partial", "cpu-only"})
114
+
115
+ #: THE ENUMERATION: `basis` -> every `npu_coverage` that is legitimate at it.
116
+ #:
117
+ #: `BackendReport.__post_init__` enforces this on EVERY construction, so an
118
+ #: illegitimate pair cannot reach an envelope, and
119
+ #: `tests/gates/test_model_check_doc_coverage_table.py` holds
120
+ #: `docs/model-check-static-screen.md`'s own `(npuCoverage, basis)` table to
121
+ #: it in both directions (tan-cli#1135 -- that page went false at five lines
122
+ #: on tan-cli#1115 with nothing to catch it).
123
+ #:
124
+ #: Each entry is derived from the code path that produces it, not restated
125
+ #: from prose:
126
+ #:
127
+ #: `"static-screen"` `_coverage_label` over a non-empty verdict list --
128
+ #: `"full-eligible"` / `"partial"` / `"cpu-only"` -- plus
129
+ #: `COVERAGE_WITHHELD` from the three no-verdict reports.
130
+ #: This module never emits any other basis.
131
+ #: `"compiled"` `_COVERAGE_AT_COMPILED`, above.
132
+ #: `"bench"` `_perf_point_report` CARRIES a `"compiled"` report's
133
+ #: own already-measured verdict, or withholds. So it is
134
+ #: the compiled set plus `COVERAGE_WITHHELD` BY
135
+ #: CONSTRUCTION here, not by a second hand-written list:
136
+ #: a new word reachable at `"compiled"` is reachable at
137
+ #: `"bench"` for free, exactly as the carry branch says.
138
+ LEGITIMATE_COVERAGE_BY_BASIS: dict[str, frozenset[str]] = {
139
+ "static-screen": frozenset({"full-eligible", "partial", "cpu-only", COVERAGE_WITHHELD}),
140
+ "compiled": _COVERAGE_AT_COMPILED,
141
+ "bench": _COVERAGE_AT_COMPILED | {COVERAGE_WITHHELD},
142
+ }
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class BackendReport:
147
+ """One backend's static-screen partition report for a model."""
148
+
149
+ backend: str # cpu | ethos_u | drpai | deepx_dxm1
150
+ variant: str | None # u85 | u55 | None
151
+ table: str | None # the table file that answered, or None
152
+ # WHICH words are legitimate here is NOT restated in this comment -- read
153
+ # `LEGITIMATE_COVERAGE_BY_BASIS` above, keyed by this report's own `basis`.
154
+ # It is the source of truth, `__post_init__` enforces it on construction,
155
+ # and a prose second copy of that enumeration in this exact spot is what
156
+ # drifted and cost tan-cli#1135. What stays here is only WHY the vocabulary
157
+ # is basis-dependent, which no dict can say: `tan.model.perf.coverage_from_
158
+ # placement` is the one function in tan allowed to return "fits", off a
159
+ # real vela compile's own placement summary, so only `basis: "compiled"`
160
+ # ever DERIVES a verdict. A `basis: "bench"` report derives nothing --
161
+ # alp-sdk's real model-perf schema carries no bench-measured placement
162
+ # split (tan-cli#1115) -- so `tan.model.perf_apply._perf_point_report`
163
+ # CARRIES this field from the report it is rebasing only when that
164
+ # report's basis is already "compiled", and otherwise WITHHOLDS it to
165
+ # `COVERAGE_WITHHELD` rather than promoting the static screen's name-level
166
+ # estimate to `confidence: "certain"`. analyze_backend() itself never
167
+ # emits "fits" -- `basis` stays "static-screen" in this module always.
168
+ npu_coverage: str
169
+ # MAC-weighted UPPER bound, 0-100 -- ONLY at `basis: "static-screen"`
170
+ # (this module's own `_score_ops`/`eligible_macs / total_macs`). Always
171
+ # `None` at `basis: "compiled"` (tan.model.check's `--exact` path):
172
+ # vela's own placement summary is an aggregate CPU/NPU *op count*, not a
173
+ # per-op MAC breakdown, so there is no real MAC-weighted figure to put
174
+ # here for a compile -- `npu_placement_pct_real` below carries the real
175
+ # (but op-count, not MAC-weighted) placement instead (MAJOR 4 review: a
176
+ # `basis: "compiled"` report used to overload THIS field with that
177
+ # op-count ratio, under the exact MAC-weighted-upper-bound contract this
178
+ # comment documents -- measured a static report with one 1000-MAC
179
+ # eligible op and two 0-MAC CPU ops emitting `66.666...` here on the
180
+ # compiled path, precisely the op-count distortion MAC weighting exists
181
+ # to eliminate).
182
+ compute_on_npu_pct_max: float | None
183
+ # The REAL NPU-vs-CPU op-count placement ratio, 0-100 -- set only where a
184
+ # real placement was MEASURED: `basis: "compiled"` (`tan.model.check.
185
+ # _report_from_vela_compile`, from vela's own "CPU/NPU operators = N (P%)"
186
+ # summary counts), OR `basis: "bench"` in the ONE case that also counts as
187
+ # measured -- a bench point rebasing a report whose basis was ALREADY
188
+ # "compiled" (a real `--exact` compile ran alongside it and the two
189
+ # agreed; `tan.model.perf_apply._perf_point_report` carries this field
190
+ # from that pre-existing real placement, since a bench point has no
191
+ # per-operator placement of its own to contribute -- alp-sdk's real
192
+ # `metadata/model_perf/` schema carries none, tan-cli#1115, see
193
+ # `tan.model.perf`'s module docstring, "DEAD FIELDS"). `None` at
194
+ # `basis: "static-screen"` (no real compile to report a placement for)
195
+ # AND at `basis: "bench"` whenever it is NOT corroborating an already-
196
+ # "compiled" report -- the ordinary case, no `--exact` run at all -- a
197
+ # bench point alone never earns a non-`None` value here. Deliberately a
198
+ # SEPARATE field from `compute_on_npu_pct_max` above, not a reuse of it
199
+ # under a different meaning: an op-count ratio and a MAC-weighted ratio
200
+ # answer different questions and must never share one key a consumer could
201
+ # read as either.
202
+ npu_placement_pct_real: float | None = None
203
+ # cpu-certain ops with an unpriced (macs=0) MAC estimate -- excluded from
204
+ # compute_on_npu_pct_max's denominator, so a nonzero count here is a
205
+ # machine-readable caveat on that percentage: it can read 100.0 while real,
206
+ # uncosted CPU compute exists. Structured so the caveat survives into an
207
+ # envelope consumer that reads compute_on_npu_pct_max but doesn't render
208
+ # `notes` (prose). 0 whenever no such op exists, including every report
209
+ # variant that never reached scoring (format-not-accepted, no-table,
210
+ # empty-ops) -- there, no cpu-certain verdict was determined at all -- AND
211
+ # a `basis: "bench"` report rebased with no corroborating real placement
212
+ # (tan-cli#1115 PR review nit: this field is 0 there too, not because zero
213
+ # such ops were found but because `npu_coverage` itself was withheld to
214
+ # "undetermined" and nothing about placement was determined at all). A
215
+ # consumer CANNOT distinguish "zero uncosted CPU ops really exist" from
216
+ # "withheld" by this field alone -- read `npu_coverage`/`basis` first:
217
+ # `uncosted_cpu_op_count` only means what it says when `npu_coverage` is
218
+ # not `"undetermined"`.
219
+ uncosted_cpu_op_count: int = 0
220
+ ops: list[OpVerdict] = field(default_factory=list)
221
+ basis: str = "static-screen" # the only basis this module ever emits
222
+ confidence: str = "screening" # "certain" | "screening"
223
+ # THE FOOTPRINT AND LATENCY FIELDS, and what each `None` means.
224
+ #
225
+ # `None` is "not measured", never zero: a zero here is a measured zero, the
226
+ # same reading `metadata/model_perf/`'s own schema gives an omitted key.
227
+ # All six stay `None` at `basis: "static-screen"` -- a screen that walks
228
+ # operator NAMES against a table has no footprint to report and inventing
229
+ # one from it would be the estimate this whole vocabulary exists to keep
230
+ # separate from a measurement.
231
+ #
232
+ # `arena_bytes` / `req_sram_kib` are set at `basis: "compiled"` (from the
233
+ # `Blob` a real vela compile returned) and at `basis: "bench"` (from a
234
+ # matched perf point). Carrying them as FIELDS rather than only inside the
235
+ # prose note is what lets `tan.model.check` compare a customer's own
236
+ # compile against a bench point figure-by-figure instead of by parsing its
237
+ # own sentence back.
238
+ #
239
+ # The three latency fields and `perf_ref` are set at `basis: "bench"` --
240
+ # a compile reports no wall-clock, and only a bench point names the bench
241
+ # it was measured on (`capture.bench_id`, e.g. `"e1m-aen-evk-01"` -- a rig
242
+ # identifier, not a capture artefact's own citation) -- OR, informationally,
243
+ # on a `basis: "compiled"` report that names
244
+ # a disagreeing bench point in its notes
245
+ # (`tan.model.perf_apply.apply_perf_point`'s Decision 1: the customer's
246
+ # own compile still wins on coverage/arena/SRAM, but Alp Lab's own
247
+ # measured latency and traceable capture have no compiled counterpart to
248
+ # disagree WITH, so they ride alongside rather than being discarded --
249
+ # tan-cli#791 review item 3). A latency without `latency_runs` is a single
250
+ # shot rather than a measurement, which is why the count travels with the
251
+ # figures.
252
+ arena_bytes: int | None = None
253
+ req_sram_kib: int | None = None
254
+ latency_ms_mean: float | None = None
255
+ latency_ms_p95: float | None = None
256
+ latency_runs: int | None = None
257
+ perf_ref: str | None = None
258
+ notes: list[str] = field(default_factory=list)
259
+ # `Sram_Only` residency + arena fit against `board.yaml` (tan-cli#1288) --
260
+ # set by `tan.model.check._report_from_vela_compile` at `basis:
261
+ # "compiled"` (a real vela compile actually produced a blob to measure),
262
+ # and CARRIED FORWARD by `tan.model.perf_apply._perf_point_report`'s own
263
+ # `real_placement` rebase -- exactly the same gate that carries `ops`/
264
+ # `npu_placement_pct_real` -- when a matched bench point corroborates an
265
+ # already-`"compiled"` report. `None` at every construction site in THIS
266
+ # module (`basis: "static-screen"` -- no compiled blob exists to check)
267
+ # and at an UNCORROBORATED bench rebase (no alp-sdk has published a
268
+ # `model_perf/` tree yet, so this path is dormant today). See
269
+ # `tan.model.sram_fit` for the whole check.
270
+ sram_fit: SramFit | None = None
271
+
272
+ def __post_init__(self) -> None:
273
+ """Refuse an illegitimate `(npu_coverage, basis)` pair at construction.
274
+
275
+ This is what makes `LEGITIMATE_COVERAGE_BY_BASIS` a constant
276
+ PRODUCTION reads rather than a list a test mirrors: all six
277
+ production construction sites (`analyze.py` x4, `tan.model.check.
278
+ _report_from_vela_compile`, `tan.model.perf_apply._perf_point_report`)
279
+ and every `dataclasses.replace` of a report run through here, so a new
280
+ branch that invents a word at a basis it does not belong to cannot
281
+ reach an envelope consumer that matches exhaustively on the pair
282
+ (`tan.core.model_check`, and the customers
283
+ `docs/model-check-static-screen.md` tells to do exactly that).
284
+
285
+ THE BLAST RADIUS, stated rather than left to "fails loudly": this is
286
+ not a traceback at the user. `tan model check` wraps each model in
287
+ `tan.commands.model_cmd._model_check_block`'s `except Exception`, so a
288
+ raise here becomes that ONE model's coded Issue and `exitCode: 1` --
289
+ the other models in the same `board.yaml` still report. Measured, on a
290
+ two-model board.yaml with the first model forced to an illegitimate
291
+ pair: `"ok": false`, `"exitCode": 1`, `issues[0]` =
292
+ `model.check-failed` / `model 'm': ValueError: illegitimate
293
+ (npu_coverage, basis) pair ('fits', 'static-screen') -- ...`, and
294
+ `data.models` still carrying the second model's full report.
295
+
296
+ That is the right trade for a false negative in the enumeration (one
297
+ report dropped, loudly, with the offending pair named) against the
298
+ alternative (a word the documented vocabulary does not contain,
299
+ shipped in a `data.models[].backends[]` block a consumer is told it
300
+ may match exhaustively). If the
301
+ enumeration is ever wrong in the OTHER direction -- too narrow for a
302
+ pair production really produces -- it costs a report, so widen it
303
+ here and document the row rather than deleting the check.
304
+
305
+ Deliberately validates the pair, NOT each field alone -- the whole
306
+ point is that `"fits"` is legitimate and `"full-eligible"` is not at
307
+ `basis: "compiled"`, while the reverse holds at `"static-screen"`.
308
+ Says nothing about MEANING -- that `undetermined` reads differently at
309
+ `"bench"` than at `"static-screen"` is prose, and stays prose."""
310
+ legitimate = LEGITIMATE_COVERAGE_BY_BASIS.get(self.basis)
311
+ if legitimate is None:
312
+ raise ValueError(
313
+ f"unknown basis {self.basis!r} -- BackendReport bases are "
314
+ f"{sorted(LEGITIMATE_COVERAGE_BY_BASIS)}")
315
+ if self.npu_coverage not in legitimate:
316
+ raise ValueError(
317
+ f"illegitimate (npu_coverage, basis) pair "
318
+ f"({self.npu_coverage!r}, {self.basis!r}) -- legitimate "
319
+ f"npu_coverage at basis {self.basis!r}: {sorted(legitimate)}")
320
+
321
+
322
+ def resolve_ethos_u_variant(sku: str, *, metadata_root: Path) -> str | None:
323
+ """Read `inference.ethos_u_variant` off the SoM preset for @sku. None for
324
+ a SoM preset with no such field (non-Ethos-U SoMs -- drpai/deepx_dxm1
325
+ presets carry no per-instance Ethos-U variant), or for a preset that
326
+ doesn't exist on disk (callers that need a hard failure for a bad SKU
327
+ should resolve targets first; this function stays soft-fail, matching
328
+ `tan.soc_ref.resolve_soc_path`'s stance). Also soft-fails (None, never a
329
+ raise) for a preset document, `inference` block, or `ethos_u_variant`
330
+ value that parses to the wrong shape -- live on `tan model check`
331
+ (`check.py:155`, `check.py:537`), same #957/#965/#969 defect class,
332
+ tan-cli#979 review finding 2: `inference: 7` and a scalar top-level
333
+ preset document (e.g. a YAML file containing only `7`) both raised
334
+ `AttributeError: 'int' object has no attribute 'get'` (measured), and
335
+ `ethos_u_variant: 7` returned the int straight into
336
+ `analyze_backend(variant: str | None)` uncaught."""
337
+ preset_path = Path(metadata_root) / "e1m_modules" / f"{sku}.yaml"
338
+ if not preset_path.is_file():
339
+ return None
340
+ import yaml # noqa: PLC0415 -- deferred (tan-cli#810); see sdk_cmd's `_releases_opener`
341
+
342
+ preset = yaml.safe_load(preset_path.read_text(encoding="utf-8"))
343
+ # A preset document that doesn't even parse to a dict (a bare scalar or
344
+ # list at the YAML top level) can't be walked by the `.get()`s below --
345
+ # treat it exactly like a missing preset rather than let it reach
346
+ # `.get()` and raise.
347
+ if not isinstance(preset, dict):
348
+ return None
349
+ # `isinstance` check, not a `.get(..., {})` default: a preset carrying an
350
+ # explicit but EMPTY `inference:` block parses to `{"inference": None}`,
351
+ # and the dict-literal default only fires when the key is absent
352
+ # entirely -- `.get("inference", {})` still returns None there, and
353
+ # `.get()` on None raises AttributeError, contradicting this function's
354
+ # own soft-fail docstring. A non-dict, non-null `inference:` (e.g. `7`)
355
+ # needs the same treatment, which a bare `or {}` alone does not give it.
356
+ inference = preset.get("inference")
357
+ if not isinstance(inference, dict):
358
+ return None
359
+ ethos_u_variant = inference.get("ethos_u_variant")
360
+ return ethos_u_variant if isinstance(ethos_u_variant, str) else None
361
+
362
+
363
+ def _load_table(path: Path) -> dict | None:
364
+ try:
365
+ doc = json.loads(path.read_text(encoding="utf-8"))
366
+ except (OSError, ValueError):
367
+ return None
368
+ # A table file that parses but is not a JSON object (e.g. a bare array)
369
+ # must be rejected HERE -- every caller treats a non-None return as
370
+ # something it can `.get()` off, and letting a list or scalar through
371
+ # turns a downstream `.get()` into an uncaught AttributeError instead of
372
+ # the clean "no table" this function promises.
373
+ return doc if isinstance(doc, dict) else None
374
+
375
+
376
+ def _table_variant(doc: dict) -> str:
377
+ """@doc's `applies_to.variant`, normalised to `""` for any shape that
378
+ isn't a plain string reachable through a dict `applies_to`.
379
+
380
+ `metadata/npu_ops/**` has a schema, `npu-ops-v1.schema.json` (alp-sdk
381
+ `dev`, alp-sdk#1470), and `_resolve_table` enforces it on read when the
382
+ bound SDK ships a usable one (tan-cli#1298). This isinstance guard stays
383
+ as defence in depth, for three reasons: it is what protects a bound SDK
384
+ whose schema is absent or cannot be loaded (tan-cli#969 added it before
385
+ any schema existed); `_resolve_table` reads the variant through it BEFORE
386
+ schema-validating the matching table; and tan-cli#965 showed a
387
+ schema-valid document can still be narrower than a consumer assumes.
388
+
389
+ `.get()` off a non-dict `applies_to` (e.g. `applies_to: 7`) raises
390
+ `AttributeError: 'int' object has no attribute 'get'`, and `.split()` on
391
+ the caller's side off a non-string `variant` (e.g. `variant: 7`) raises
392
+ `AttributeError: 'int' object has no attribute 'split'` -- same defect
393
+ class as the #957/#965 family, same `_load_table` precedent immediately
394
+ above (`analyze.py:204-214`) for why a malformed table is skipped rather
395
+ than crashing the whole resolve. `or {}` alone (the pre-#969 guard) only
396
+ covered an explicit-but-null `applies_to:`; it did not cover a non-dict
397
+ scalar, which still reached `.get()`.
398
+
399
+ `""` is the identical sentinel a genuinely-absent field already
400
+ produces: the caller's `variant in _table_variant(doc).split("-")`
401
+ then simply fails to match, the same "undetermined, not a fabricated
402
+ negative" outcome `_resolve_table`'s own docstring already promises for
403
+ "no table covers @variant" -- no fourth behaviour invented."""
404
+ applies_to = doc.get("applies_to")
405
+ if not isinstance(applies_to, dict):
406
+ applies_to = {}
407
+ table_variant = applies_to.get("variant", "")
408
+ return table_variant if isinstance(table_variant, str) else ""
409
+
410
+
411
+ def _table_supported_ops(doc: dict) -> list[str] | None:
412
+ """@doc's `supported_ops`, validated to a list of `str`. `None` for any
413
+ other shape -- the caller (`analyze_backend`) treats that identically to
414
+ a table that failed to resolve at all, never as evidence to score
415
+ against (tan-cli#979 review finding 1, on top of #969's `applies_to`/
416
+ `variant` guards above).
417
+
418
+ `_score_ops` used to read `set(doc.get("supported_ops", []))` straight
419
+ off the same `metadata/npu_ops/**` document `_table_variant` sanitises
420
+ above, a few lines below on the same `analyze_backend` call path (a bound
421
+ SDK whose schema is absent or unusable still reaches this guard
422
+ unvalidated, and the schema does not replace it either, #965).
423
+ `supported_ops: 7` raised `TypeError: 'int' object is not
424
+ iterable`; `supported_ops: null` raised the same as `'NoneType' object
425
+ is not iterable` -- but `supported_ops: "CONV_2D"` raised NOTHING:
426
+ `set("CONV_2D")` silently built a set of seven characters no real
427
+ operator name ever matches, so every op came back `cpu-only`/
428
+ `cpu-certain` -- a fabricated negative manufactured from malformed data,
429
+ exactly the outcome this module's own docstring (`analyze.py:18-23`)
430
+ names as the worst one it exists to prevent. A crash at least announces
431
+ itself; that one would have shipped a wrong verdict silently.
432
+
433
+ A genuinely-absent key defaults to `[]` (a table that legitimately
434
+ supports no operators) and passes straight through -- only a PRESENT but
435
+ wrong-shaped field is rejected."""
436
+ supported_ops = doc.get("supported_ops", [])
437
+ if not isinstance(supported_ops, list):
438
+ return None
439
+ if not all(isinstance(op, str) for op in supported_ops):
440
+ return None
441
+ return supported_ops
442
+
443
+
444
+ # WHY `os.listdir` AND NOT `Path.glob` HERE (tan-cli#1132, the same swap
445
+ # tan-cli#1127 made in `new_som_cmd._known_board_names`, measured the same
446
+ # way -- non-root, on 3.12.3 / 3.13.15 / 3.14.7 side by side).
447
+ #
448
+ # `_resolve_table` used to open with `if not table_dir.is_dir(): return None`
449
+ # and then `sorted(table_dir.glob("*.json"))` with no guard at all, so a
450
+ # permission-denied ANCESTOR of `metadata/npu_ops/<backend>/` raised
451
+ # `PermissionError` straight out of this function on 3.12.3 and 3.13.15 and
452
+ # returned `None` on 3.14.7 -- the docstring's "None when the backend has no
453
+ # table directory at all" held on exactly one of the three. Measured:
454
+ #
455
+ # primitive self-denied ancestor-denied
456
+ # Path.is_dir() True / True / True RAISE / RAISE / False
457
+ # list(Path.glob("*.json")) [] / [] / [] RAISE / [] / []
458
+ # os.listdir() RAISE / RAISE / RAISE RAISE / RAISE / RAISE
459
+ # (3.12.3 / 3.13.15 / 3.14.7)
460
+ #
461
+ # `os.listdir` is the only one of the three that answers the same way on all
462
+ # three interpreters and both shapes, which is what lets ONE `except OSError`
463
+ # below carry the whole contract -- an `except` around `Path.glob` would be
464
+ # dead code on 3.13.15 and 3.14.7 (and, for the self-denied shape, on 3.12.3
465
+ # too). A missing directory and a non-directory in its place come through the
466
+ # same handler: `FileNotFoundError`/`NotADirectoryError` are `OSError`, and
467
+ # all of them mean `None` here.
468
+
469
+
470
+ def _is_table_file(name: str) -> bool:
471
+ """True if @name is a support-table filename -- `.json`, matched
472
+ case-sensitively on POSIX and case-INSENSITIVELY on Windows, exactly as
473
+ the `table_dir.glob("*.json")` this replaced did (tan-cli#1132; see
474
+ `shapes.matches_glob_suffix` for the rule and tan-cli#1127 review round
475
+ 2 for why a plain `endswith` would have silently narrowed it)."""
476
+ return matches_glob_suffix(name, ".json")
477
+
478
+
479
+ def _resolve_table(metadata_root: Path, backend: str, variant: str | None,
480
+ findings: list[str] | None = None) -> tuple[Path, dict] | None:
481
+ """Pick the `metadata/npu_ops/<backend>/<variant>@<toolchain>-<ver>.json`
482
+ whose `applies_to.variant` covers @variant. None when the backend's table
483
+ directory is missing, unreadable, or not a directory at all (e.g.
484
+ `deepx_dxm1`, by decision), or when no table in it covers @variant -- all
485
+ of these are the caller's `undetermined`, never a negative verdict
486
+ manufactured from absent data, and none of them is an error this static
487
+ screen should raise at its caller.
488
+
489
+ @findings, when given, collects user-facing notes for the report
490
+ (tan-cli#1298): one per MATCHING table that failed `npu-ops-v1.schema.json`
491
+ (skipped like a malformed file; the search continues), or one disclosing
492
+ that the schema is absent or unusable so the table about to be used was
493
+ NOT validated. See `npu_ops_validate` for the order and why. It is an
494
+ out-parameter rather than a return value so the `(path, doc) | None`
495
+ shape every caller already unpacks does not change."""
496
+ table_dir = Path(metadata_root) / "npu_ops" / backend
497
+ try:
498
+ entries = os.listdir(table_dir)
499
+ except OSError:
500
+ return None
501
+ candidates = sorted(table_dir / entry for entry in entries if _is_table_file(entry))
502
+ if not candidates:
503
+ return None
504
+ notes = findings if findings is not None else []
505
+ validator = TableValidator(metadata_root, table_dir)
506
+
507
+ if variant is None:
508
+ # No per-instance variant to resolve (e.g. drpai, which ships exactly
509
+ # one table today): unambiguous only when there is exactly one table.
510
+ if len(candidates) == 1:
511
+ doc = _load_table(candidates[0])
512
+ if doc is not None and validator.accepts(doc, candidates[0], notes):
513
+ return candidates[0], doc
514
+ return None
515
+ for path in candidates:
516
+ doc = _load_table(path)
517
+ if doc is None:
518
+ continue
519
+ # The variant is read off a not-yet-validated document -- safe, since
520
+ # `_table_variant` returns "" for any wrong shape, so such a table can
521
+ # only fail to match. Only a MATCHING table is schema-validated; a
522
+ # schema-invalid one is skipped (and noted) and the search goes on.
523
+ if variant in _table_variant(doc).split("-") and validator.accepts(doc, path, notes):
524
+ return path, doc
525
+ return None
526
+
527
+
528
+ def _with_table_notes(report: BackendReport, table_notes: list[str]) -> BackendReport:
529
+ """@report with the resolver's schema findings appended to `notes`. Only
530
+ the static-screen reports built here carry them: the `--exact` compiled
531
+ report (`check._report_from_vela_compile`) and the bench-point report
532
+ (`perf_apply._perf_point_report`) build their own `notes` and drop them."""
533
+ return replace(report, notes=[*report.notes, *table_notes]) if table_notes else report
534
+
535
+
536
+ def _format_not_accepted_report(backend: str, src_format: str, ops: Sequence[OpDesc],
537
+ variant: str | None) -> BackendReport:
538
+ verdicts = [OpVerdict(op=o.op, status="unknown", reason="format-not-accepted")
539
+ for o in ops]
540
+ return BackendReport(
541
+ backend=backend, variant=variant, table=None,
542
+ npu_coverage=COVERAGE_WITHHELD, compute_on_npu_pct_max=None, ops=verdicts,
543
+ notes=[f"{backend} does not ingest {src_format!r} source models; "
544
+ f"no score computed. This is not a verdict on the model, only "
545
+ f"on the format/backend pairing."],
546
+ )
547
+
548
+
549
+ def _no_table_report(backend: str, ops: Sequence[OpDesc], variant: str | None) -> BackendReport:
550
+ verdicts = [OpVerdict(op=o.op, status="unknown", reason="no-table-for-backend",
551
+ macs=o.macs) for o in ops]
552
+ return BackendReport(
553
+ backend=backend, variant=variant, table=None,
554
+ npu_coverage=COVERAGE_WITHHELD, compute_on_npu_pct_max=None, ops=verdicts,
555
+ notes=["no NPU-ops support table for this backend/variant -- absence "
556
+ "of data, not evidence of no support."],
557
+ )
558
+
559
+
560
+ def _empty_ops_report(backend: str, table_path: Path, variant: str | None) -> BackendReport:
561
+ return BackendReport(
562
+ backend=backend, variant=variant, table=str(table_path),
563
+ npu_coverage=COVERAGE_WITHHELD, compute_on_npu_pct_max=None, ops=[],
564
+ notes=["no operators were extracted for this source; nothing to "
565
+ "score, so no coverage verdict is reported."],
566
+ )
567
+
568
+
569
+ def _score_ops(ops: Sequence[OpDesc], doc: dict) -> tuple[list[OpVerdict], int, int]:
570
+ """Per-op npu-eligible/cpu-certain verdicts against @doc's `supported_ops`,
571
+ plus the running total/eligible MAC totals MAC-weighting needs.
572
+
573
+ @doc is assumed pre-validated by `analyze_backend` via
574
+ `_table_supported_ops` before this is ever called -- a malformed
575
+ `supported_ops` is routed to `_no_table_report` instead of reaching
576
+ here (tan-cli#979 review finding 1: `set()` of a non-list, or of a list
577
+ containing a non-str, is exactly the crash/fabricated-negative class
578
+ this module exists to keep out, so this function must never be called
579
+ with an unvalidated @doc)."""
580
+ supported = set(doc.get("supported_ops", []))
581
+ verdicts: list[OpVerdict] = []
582
+ total_macs = 0
583
+ eligible_macs = 0
584
+ for o in ops:
585
+ total_macs += o.macs
586
+ if o.op in supported:
587
+ verdicts.append(OpVerdict(op=o.op, status="npu-eligible",
588
+ reason="constraint-unchecked", macs=o.macs))
589
+ eligible_macs += o.macs
590
+ else:
591
+ verdicts.append(OpVerdict(op=o.op, status="cpu-certain",
592
+ reason="op-not-in-table", macs=o.macs))
593
+ return verdicts, total_macs, eligible_macs
594
+
595
+
596
+ def _coverage_label(verdicts: list[OpVerdict]) -> str:
597
+ all_eligible = all(v.status == "npu-eligible" for v in verdicts)
598
+ any_eligible = any(v.status == "npu-eligible" for v in verdicts)
599
+ if all_eligible:
600
+ return "full-eligible"
601
+ return "partial" if any_eligible else "cpu-only"
602
+
603
+
604
+ def _uncosted_op_count(verdicts: list[OpVerdict]) -> int:
605
+ """Count of cpu-certain ops `_estimate_macs` could not price (macs=0) --
606
+ also the source of the `uncosted_cpu_op_count` report field, so the note
607
+ below and the structured field always agree."""
608
+ return sum(1 for v in verdicts if v.status == "cpu-certain" and v.macs == 0)
609
+
610
+
611
+ def _uncosted_macs_note(uncosted: int, pct: float | None) -> str | None:
612
+ """compute_on_npu_pct_max's denominator only counts ops `_estimate_macs`
613
+ could price (conv/dense); a cpu-certain op outside that set (macs=0)
614
+ leaves the denominator entirely, so the percentage can read 100.0 while
615
+ real, un-priced CPU compute exists and `npu_coverage` still says
616
+ "partial". Never clamp for this -- clamping hides the exact gap this
617
+ module exists to surface -- return a note instead, or None when there is
618
+ nothing uncosted (or no MAC-weighted number was computed at all)."""
619
+ if pct is None or not uncosted:
620
+ return None
621
+ return (f"{uncosted} cpu-certain op(s) carry no static MAC estimate (outside "
622
+ f"the conv/dense-only estimator) and are excluded from "
623
+ f"compute_on_npu_pct_max's denominator -- the reported percentage "
624
+ f"can overstate NPU-bound compute share when those ops are "
625
+ f"significant in practice.")
626
+
627
+
628
+ def analyze_backend(*, backend: str, src_format: str, ops: Sequence[OpDesc],
629
+ metadata_root: Path, variant: str | None = None) -> BackendReport:
630
+ """Static-screen @ops (already extracted from the model source) against
631
+ @backend's resolved support table.
632
+
633
+ Order of operations, deliberately: (1) the format gate -- does @backend
634
+ even ingest @src_format -- before (2) table resolution, before (3) the
635
+ per-op walk. Scoring a format a backend cannot ingest is a category error,
636
+ not a low-confidence answer, so it short-circuits before any table load."""
637
+ if not _known_backend(backend):
638
+ raise ValueError(f"unknown backend {backend!r}")
639
+
640
+ if not _accepts(backend, src_format):
641
+ return _format_not_accepted_report(backend, src_format, ops, variant)
642
+
643
+ table_notes: list[str] = []
644
+ resolved = _resolve_table(metadata_root, backend, variant, table_notes)
645
+ if resolved is not None:
646
+ # The table must speak the SAME operator vocabulary as what will be
647
+ # compared against it -- @ops' own op_namespace when there are ops to
648
+ # compare (never @src_format, which is caller-supplied and
649
+ # independent of what extraction really produced), or, when @ops is
650
+ # empty and there is no extracted vocabulary to check, @src_format as
651
+ # the best available surrogate. Always run this -- even for the
652
+ # empty-ops case, where nothing gets scored either way -- because
653
+ # citing a table whose op_namespace demonstrably disagrees with the
654
+ # only vocabulary evidence available is misleading regardless of
655
+ # whether anything was scored against it.
656
+ actual_namespace = ops[0].op_namespace if ops else src_format
657
+ if resolved[1].get("op_namespace") != actual_namespace:
658
+ resolved = None
659
+ elif _table_supported_ops(resolved[1]) is None:
660
+ # tan-cli#979 review finding 1: a table with a well-formed
661
+ # `applies_to`/`op_namespace` (i.e. one that survived every
662
+ # check above -- exactly the table `_score_ops` would otherwise
663
+ # be handed) but a malformed `supported_ops` is unscoreable, not
664
+ # a table that legitimately supports nothing. Route it through
665
+ # the same "no usable table" outcome as a namespace mismatch --
666
+ # `undetermined`, never a verdict manufactured from the bad
667
+ # field -- rather than reaching `_score_ops` at all.
668
+ resolved = None
669
+
670
+ if resolved is None:
671
+ return _with_table_notes(_no_table_report(backend, ops, variant), table_notes)
672
+
673
+ table_path, doc = resolved
674
+ if not ops:
675
+ return _with_table_notes(_empty_ops_report(backend, table_path, variant), table_notes)
676
+
677
+ verdicts, total_macs, eligible_macs = _score_ops(ops, doc)
678
+ pct = (100.0 * eligible_macs / total_macs) if total_macs > 0 else None
679
+ notes = [f"static screen ({doc.get('stance', 'screening')}): operator-name "
680
+ f"membership against {table_path.name} only. Eligible ops still "
681
+ f"carry unchecked quantization/shape/dtype constraints this check "
682
+ f"cannot verify -- the model will run either way, an unsupported "
683
+ f"op falls back to the CPU silently rather than failing. Only a "
684
+ f"real compile proves NPU execution."]
685
+ uncosted = _uncosted_op_count(verdicts)
686
+ uncosted_note = _uncosted_macs_note(uncosted, pct)
687
+ if uncosted_note:
688
+ notes.append(uncosted_note)
689
+
690
+ return BackendReport(
691
+ backend=backend, variant=variant, table=str(table_path),
692
+ npu_coverage=_coverage_label(verdicts), compute_on_npu_pct_max=pct,
693
+ uncosted_cpu_op_count=uncosted, ops=verdicts, notes=[*notes, *table_notes],
694
+ )