feat(model): ADR-0028 — relocate the model engine into tan, and compile Ethos-U for the real memory model - #791
Open
alpCaner wants to merge 46 commits into
Open
feat(model): ADR-0028 — relocate the model engine into tan, and compile Ethos-U for the real memory model#791alpCaner wants to merge 46 commits into
alpCaner wants to merge 46 commits into
Conversation
added 30 commits
August 15, 2026 18:33
…#1271)
model_cmd.py's hand-ported _resolve_compile resolved every string value
in a models[].compile.<backend> block to an absolute filesystem path,
even though only config/calibration/images/spec name paths. DRP-AI's
input_shape ("1,3,224,224"), input_name ("images") and product ("V2N")
were corrupted into filesystem paths before reaching the adapter, which
then made the adapter's own shape check misfire. alp-sdk fixed this as
issue #1271; tan's hand-ported copy never received it.
… e2e case, and fix two naming nits (tan-cli#776) Closes four gaps a review of 7d2b42b found in the alp-sdk#1271 port: - `test_resolve_compile_leaves_non_string_path_valued_options_unchanged` pins the `isinstance(v, str)` half of `_resolve_compile`'s guard -- a list/int-valued path key (`images: [a.png, b.png]`, `calibration: 100`) must pass through unchanged rather than raising `TypeError` at `Path.__truediv__`. Verified by temporarily dropping the isinstance check: the new case goes red with exactly that TypeError, then restored. - `test_compile_opts_paths_are_resolved_absolute_relative_to_board_dir`'s docstring no longer instructs the next porter to reintroduce the bug it once described (every string value becomes a path); it now says only `config`/`calibration`/`images`/`spec` do. - That same e2e case was blind to alp-sdk#1271 by construction (its only compile opt was a path key). Extended with DRP-AI's `input_shape`, `input_name` and `product` and asserted they survive verbatim through the `compileOpts` payload key, matching alp-sdk's own end-to-end pin (`test_alp_cli_model.py::test_alp_model_build_only_resolves_path_valued_drpai_opts`). - Filed tan-cli#776 for the shipped bug (changelog.d/781.fixed.md resolved to no real issue) and renamed the fragment to it. - Folded `test_model_cmd.py`'s two tests into `test_model_command.py` (preferred over renaming) -- `test_model_command.py` already exists for this command and all other command test modules are `test_<command>_command.py`. python -m pytest tests -q (isolated venv, python/): 4228 passed, 294 skipped, 1 xfailed, 0 failed.
…DR-0028 Task 2) Copies alp-sdk's scripts/alp_model/ (13 modules, 1,029 lines) into python/tan/model/ verbatim, then repoints its one external import (alp_project_loader.resolve_soc_path) at tan's own tan.planner.som_metadata.resolve_soc_path, which already carries the identical (silicon, metadata_root) -> Path | None signature. Relocates the engine's own test suite into python/tests/model/, with the import prefix rewritten alp_model. -> tan.model. and fixture paths re-anchored on tan's tree. Two consequences the plan flagged as unverified turned out real: tan.model.targets/build now transitively import tan.planner, whose __init__ reads real metadata/registries/* content at import time, so both need a bound, real alp-sdk checkout to even import -- gated the same way the rest of this suite gates planner-dependent tests (skip, loudly, without ALP_SDK_ROOT bound). The two "committed fixture matches generator" tests compare against alp-sdk's own committed C headers (tests/unit/alpmodel_reader/, tests/yocto/), which have no tan-cli equivalent, so they now read from a bound alp-sdk checkout instead of a nonsensical tan-local path. cbor2 joins tan's required dependencies (manifest.py's CBOR round-trip is an unconditional module-scope import); a new optional model-io extra (tflite, flatbuffers) mirrors alp-sdk's own pins for the tensor-I/O reader. test_declared_dependencies.py's IMPORT_TO_DIST gets both new entries so the gate can classify them. Verified against alp-sdk's own test run: 74 passed / 7 skipped in both, identical skip reasons. Full-suite pytest (bare and with ALP_SDK_ROOT bound to origin/dev) produces byte-identical FAILED/ERROR sets against the unmodified base commit in both configurations -- this change adds 93 new tests and zero new failures. module_size_budget.generated.json's function_count_budget moves 258 -> 260: two of the relocated engine's already-existing over-50-line functions move with it (logged in MODULE_SIZE_BUDGET_LOG.md).
…dule tan/model/targets.py imported resolve_soc_path from tan.planner.som_metadata, which is a submodule of the tan.planner PACKAGE -- importing it runs tan/planner/__init__.py first, which transitively reads real metadata/registries/*.json content at IMPORT time (slugs.py's module-scope _PERIPHERAL_KCONFIG = peripheral_kconfig()). So tan.model.targets and tan.model.build could not be imported without a bound, real alp-sdk checkout -- a dependency alp_model.targets never had in alp-sdk. Move resolve_soc_path's implementation, byte for byte, into a new leaf module tan/soc_ref.py that imports nothing from tan.planner. tan/planner/som_metadata.py now imports and re-exports it, so its own four call sites (and every external `from tan.planner.som_metadata import resolve_soc_path`) keep working unchanged. tan.model.targets imports the leaf instead: one definition, two importers, no second copy of the resolution (alp-sdk spent #997/#1004/#1096 collapsing this to one source). tan.model.targets and tan.model.build move into test_model_package_imports.py's _SELF_CONTAINED_MODULES list; the 3 coupling-caused skips (2 parametrized module-import cases + the resolver identity check) drop out, all now asserted unconditionally. test_targets.py / test_build.py blamed the wrong file for the (now former) import-time read -- they said tan/planner_root.py, which reads nothing; the real site was tan/planner/slugs.py:167. Corrected their skip rationale to what's still true post-decoupling: these two files import fine standalone now, but their assertions need real committed SoM presets and SoC JSON that only exist in a bound metadata/ tree, so the skip stays for a different reason. Also drops the stale `# scripts/alp_model/targets.py` path banner (targets.py is not that file any more) and adds the SPDX header test_targets.py and test_build.py were missing, matching every other tan test file.
…own tree _gen_fixture.py's _ROOT = Path(__file__).resolve().parents[2] was not re-anchored by the relocation: in alp-sdk two levels up from scripts/alp_model/_gen_fixture.py landed on the SDK repo root; here the same walk from python/tan/model/_gen_fixture.py lands on <tan-cli>/python, so `python -m tan.model._gen_fixture` mkdir -p'd and wrote three junk trees (tests/fixtures/alpmodel/minimal.alpmodel, tests/unit/alpmodel_reader/src/ fixture.h, tests/yocto/onnx_cpu_fixture.h under python/) while never touching the alp-sdk fixtures it exists to regenerate. test_package.py's failure messages told the reader to run exactly that no-op command. main() now takes an explicit --root (falling back to $ALP_SDK_ROOT, --root winning when both are given) and validates it against the same scripts/alp_project.py marker tan's own SDK discovery uses, refusing a typo'd path instead of silently writing into the wrong tree. The three output paths are computed from that root instead of the stale module-relative _ROOT/_ALPMODEL/_HEADER/_ONNX_CPU_HEADER constants. test_package.py's three "regenerate: ..." messages now say `python -m tan.model._gen_fixture --root <alp-sdk-checkout>`, the command that actually works, and the emitted C-header provenance comment (`/* GENERATED by ... */`) now names tan.model._gen_fixture instead of the alp_model module ADR-0028 Task 6 deletes. Verified: running `python -m tan.model._gen_fixture --root <alp-sdk checkout>` writes into that checkout's tests/fixtures/alpmodel/, tests/unit/ alpmodel_reader/src/ and tests/yocto/, and creates nothing under the tan-cli worktree.
…ROOT pyproject.toml's model-io extra (tflite, flatbuffers) was installed by no workflow: ci.yml's `pip install -e ./python` is deliberately extras-less (load -bearing for test_declared_dependencies.py, left untouched here), and parity.yml's SDK-bound job installed only `-e ".[monitor]"`. Result: tests/model/test_tensorio.py's tflite parse-path tests skip in every tan CI run, with zero coverage once alp-sdk's own scripts/alp_model/ tflite tests are deleted (ADR-0028 Task 6). Add model-io alongside monitor on parity.yml's "install alp-tan[...] + pytest" step -- the job that clones alp-sdk and binds ALP_SDK_ROOT, so it is where the model suite actually has real SoM/SoC metadata to run against.
…+ numbering
tan/model/build.py, tensorio.py, adapters/{__init__,cpu,deepx,drpai,ethos_u,
executorch}.py each carried a leftover `# scripts/alp_model/<name>.py` banner
from the relocation -- none of them live there any more, and no other
relocated module in this package (manifest.py, package.py) carries one.
Dropped, matching the existing convention.
tan/model/__init__.py's docstring said "alp-sdk model packaging"; it's tan's
package now. tensorio.py's docstring cited a `model-compile` extra that does
not exist -- the real one, added alongside this relocation, is `model-io`;
`pip install alp-tan[model-compile]` was erroring.
test_adapters.py, test_manifest.py and test_tensorio.py get the SPDX header
every other tan test file has (test_targets.py, test_build.py and
test_package.py picked theirs up in the two commits that touched them for
other reasons); test_adapters.py and test_tensorio.py also drop the same
stale `# tests/scripts/test_alp_model_*.py` banner the production modules had.
changelog.d/780.added.md renamed to 779.added.md (#780 does not resolve to
either an issue or a PR; #779 is the next free number as of this change) and
reworded to describe what actually shipped: resolve_soc_path moved to a new
leaf module tan.soc_ref rather than being repointed at
tan.planner.som_metadata directly, and the model-io extra is now installed on
CI's SDK-bound job.
…DRIVER tan model build spawned a 30-line `python -c` driver string under the alp-sdk checkout's own interpreter with PYTHONPATH=<sdk>/scripts, so it could import alp_model.build. That engine now lives in this repo as tan.model.build (ADR-0028 Task 2), so the subprocess has nothing left to do. Removed: the _DRIVER constant and the seven-line comment that was the entire specification of the tan-to-driver payload, _run_driver, _python_too_old, the SDK-Python-interpreter resolution (_planner_python, resolve_manifest_python_floor), and the subprocess/os/json imports. model_cmd.py drops from 617 to 460 lines. Kept: resolution of the SDK ROOT. The engine still reads alp-sdk metadata/** at call time -- metadata stays in alp-sdk per ADR-0017. What went away is resolving the SDK's Python and spawning it, not locating the SDK. Deliberate divergence 1 from the oracle is preserved: a per-model failure resolves to a coded model.build-failed issue and the batch continues, rather than tracebacking the whole command. Deliberate divergence 2 is RETIRED with the driver, and the docstring says so rather than dropping a documented behaviour silently. It existed only to catch a driver that exited 0 having produced no result for a declared model -- a failure mode an in-process call cannot have. The module docstring also loses its claim that the engine "needs vendor NPU-compiler tooling only the SDK checkout's own Python environment carries". That was never accurate: every adapter resolves its tool with shutil.which and spawns an external binary, so what is needed is vela/dxcom on PATH, a host fact rather than a checkout fact. contract/issue-codes.json drops model.build-timeout and model.python-too-old. Their only emission sites were _run_driver and _python_too_old, so test_frozen_issue_codes.py::test_every_python_side_registry_entry_is_still_emitted went red until they were removed. Both were `reserved`, never `frozen`, which that gate's own message and contract/README.md define as a free removal.
ADR-0028 moved the .alpmodel compiler-adapter engine into tan, so tan is now
the customer's only diagnostic surface for the NPU compiler toolchains
`tan model build` shells out to. `model doctor` reports one row per
registered backend (cpu/ethos_u/drpai/deepx_dxm1) -- {backend, tool,
available, version, reason} -- built entirely from each adapter's own
is_available(), never a spawn: vela/dxcom PATH checks and the real
ALP_DRPAI_TVM_HOME env var, with an actionable reason surfaced only when a
backend is unavailable. An unavailable toolchain is the expected case, not a
failure -- ok stays true and exit 0 even when every non-cpu backend is
missing. A missing or rejected --sdk-root is folded into a
model.doctor-sdk-unresolved WARNING, never a crash: the backend rows read no
metadata/** and are unaffected either way.
`tan model check` (a fit verdict against a board's declared models and
metadata/npu_ops/) is a separate, not-yet-approved command and is not part
of this change.
…mendment) tan.model.analyze is a pure engine answering, offline and with no NPU toolchain installed, how much of a model can target the NPU on a given SoM and what definitely cannot -- with claims the evidence supports. Supersedes the retired `fits | cpu-fallback | no-fit` vocabulary: no backend can deliver `fits` statically, so negatives are sound (`cpu-certain`) and positives are capped at `npu-eligible`, never a guarantee. `basis` is always `"static-screen"` and the word "fits" must never appear in it. Task 1 -- the format gate runs FIRST, before any table load or op walk: scoring a .tflite model against an ONNX-only backend (drpai, deepx_dxm1) is a category error, not a low-confidence answer, and reports `undetermined` + `format-not-accepted`, never `cpu-only`. Task 2 -- support tables resolve by (backend, ethos_u_variant) from alp-sdk's metadata/npu_ops/<backend>/<variant>@<toolchain>-<ver>.json. E1M-AEN401/601/801 resolve the 70-op u85 table; E1M-AEN301/501/701 and E1M-NX9101 resolve the 53-op u55/u65 table (a 17-op subset). deepx_dxm1 carries no table by decision, and a missing table is `undetermined`, never a fabricated `cpu-only` -- DEEPX is the headline feature of V2M. A resolved table whose op_namespace disagrees with the walked ops' vocabulary is refused rather than compared. Task 3 -- tan.model.tensorio.extract_ops walks a TFLite flatbuffer's operators into OpDesc records with a best-effort per-op MAC estimate (conv/depthwise/dense shapes; 0 elsewhere), mirroring extract_io's never-raise, best-effort contract. Coverage is MAC-weighted rather than op-counted -- "23 of 25 ops eligible" can describe a model that is 4% eligible by compute when a conv backbone carries nearly all of it. compute_on_npu_pct_max is an explicit upper bound, None when not computable. ONNX operator extraction is a follow-on; a .onnx source yields no ops today, so the ONNX-ingesting backends report `undetermined` honestly rather than guessing. No CLI wiring: no click/typer, no envelope construction, no file writes -- `tan model check` lands in a later slice against tan/commands/model_cmd.py. Gate: python -m pytest tests -q from python/, isolated venv, zero failures -- 4312 passed, 332 skipped, 1 xfailed, rc=0 (reference at task time: 4290/324/1). Table-resolution tests against real alp-sdk metadata (u85 vs u55/u65, the 17-op delta, deepx's absent table) are gated on ALP_SDK_ROOT and skip loudly when unbound; all pass bound against alp-sdk-wt-consol. Attribute-To: alpCaner
…alp-sdk (ADR-0028 Task 6) tests/model/test_deepx_yolo_internal.py (a real yolo11n compiled through dxcom) and tests/model/test_vela_yolo_internal.py (a real int8 detector compiled through vela for the E8 Ethos-U85/U55 accel configs) move here from alp-sdk's tests/scripts/. The customer-facing claim they defend -- a real production model compiles for this SoM -- is broken by a change to the model engine, and the engine is tan.model's now (Task 2), so the proof moves with it instead of staying behind in alp-sdk on a different release train. Import prefix rewritten alp_model.adapters. -> tan.model.adapters.; _ROOT re-anchored at parents[3] (model -> tests -> python -> repo root), not alp-sdk's parents[2] -- this file lives one level deeper than the alp-sdk-scripts/tests/scripts/ original, so a naive port of the same depth would have pointed ALP_SDK_INTERNAL's sibling-directory fallback at tan-cli/python instead of tan-cli itself (the same class of bug _gen_fixture.py already had fixed once earlier in this migration). Verified both skip cleanly, not error, with no fixtures bound (`3 skipped`); verified the DEEPX test actually COMPILES against the real yolo11n.onnx bytes and the real dx-com 2.3.0 wheel (`1 passed in 240.95s`, blob starts b"DXNN", >1MB). The Vela test still cannot run for real on this box: alp-sdk-internal has no vendors/alif-ethos-u/sample-models/ directory yet, only deepx-dxm1 and renesas-rzv2n, so no *_int8.tflite fixture exists to compile against -- unrelated to this move (the test's own fixture-glob skip fires identically whichever repo it lives in). This lands ahead of alp-sdk#1471 (the alp-sdk-side deletion of scripts/alp_model/ + scripts/alp_cli/model.py), per ADR-0028 Task 6's ordering: the two repos' Task 6 halves are expected to land and merge together -- alp-sdk deleting its engine without this relocation already landed here would leave those two proofs, and tan model build itself, without any engine to exercise.
…ds, not is_available()
deepx_dxm1 reported available on a bare ALP_DEEPX_SDK_HOME directory even
though DeepxAdapter.compile() never reads that var -- it always shells the
bare `dxcom` off PATH, so the row went green on a host where the very next
`model build` raised FileNotFoundError: 'dxcom'. doctor now gates that row on
shutil.which("dxcom") directly (_deepx_dxm1_status); when the env var is set
but dxcom still isn't on PATH the row stays unavailable and carries a caveat
reason instead of silently clearing it.
drpai had the same shape one level down: _tvm_home() only checked that
ALP_DRPAI_TVM_HOME named a directory, never that the vendor tutorial script
compile() actually spawns (tutorials/compile_onnx_model_quant.py) existed
under it, so an unpacked-but-unbuilt toolchain tree also reported green
(_drpai_status). Also guards drpai's version probe against its own degraded
sentinel ("drp-ai_tvm" with no real version parsed), mirroring the existing
vela guard, and drops the text-mode "-- None" rendering when a backend has
no reason at all. ethos_u's unavailable reason now points at this repo's own
pinned `pip install alp-tan[model-compile]` extra instead of a bare
ethos-u-vela install that can drift from the pin.
_run_doctor's per-backend dispatch is pulled into _probe_backend() to keep
it under the house 50-line/function budget after the new logic.
Files changed:
- python/tan/commands/model_cmd.py: add _deepx_dxm1_status/_drpai_status/
_probe_backend, guard the drpai version sentinel, drop the "-- None" text
line, extend backend_row calls with the new reason override.
- python/tan/core/model_doctor.py: backend_row() takes an optional reason
override; update the ethos_u pip hint and the deepx/drpai reason comments.
- python/tests/commands/test_model_doctor_command.py: rewrite the
ALP_DEEPX_SDK_HOME-only test to expect the caveat instead of a green row,
add the equivalent drpai unbuilt-tree caveat test, add drpai version
sentinel tests, add the subprocess trap MINOR 3 flagged missing on the
drpai-available branch, add the "-- None" text-mode regression test.
- contract/issue-codes.json, changelog.d/781.added.md (renamed from
783.added.md): point model.doctor-sdk-unresolved's note at the real
tan-cli#781 (783 was never filed) and refresh the changelog prose to match
the new gating + pip hint.
Filed #781 to replace the dangling #783 reference.
…amespace defects, harden soft-fail paths (ADR-0028 review) Addresses review findings on 609719d: - tensorio.py: CONV_2D/TRANSPOSE_CONV/DEPTHWISE_CONV_2D MAC arithmetic had zero coverage; pin all three with real extracted OpDescs, not hand-fed macs. _estimate_macs now indexes the filter/weights input POSITIONALLY per TFLite op (kFilterTensor/kWeightsTensor), never "first const input of the matching rank" -- that heuristic let a constant-folded input shadow the real filter and inflate a CONV_2D's MAC estimate 7x. TRANSPOSE_CONV now costs off the input-activation element count, not out_elems -- the filter slides over the input for that op, and the old CONV_2D-shared formula over-counted by stride^2. - tensorio.py: OpDesc carries op_namespace (the vocabulary `op` is spelled in); analyze.py's table-vs-ops namespace guard now compares against that, not against the caller-supplied src_format, closing a fabricated-negative path a mislabelled caller could otherwise trigger. - analyze.py: compute_on_npu_pct_max's denominator can miss every cpu-certain op when none of them are conv/dense (0 MACs each), reading 100% NPU-bound while npu_coverage is still "partial". The number is never clamped for this; a note now says so explicitly. - analyze.py: resolve_ethos_u_variant, _resolve_table and _load_table no longer raise on an explicit-but-null inference:/applies_to: block or a parseable-but-non-dict table document -- all three now match their own documented soft-fail contract. - analyze.py: extracted analyze_backend's three early-return report constructors plus the per-op walk/coverage-label/uncosted-note helpers, bringing it from 91 to 49 lines against the repo's 50-line guideline. - tensorio.py: corrected a comment that mis-described TFLite's TensorType declaration order as the reason for reflecting the dtype map; a naive reading already gives the right codes, so the map is built by reflection for maintainability, not to dodge a nonexistent trap. - Added tests/model coverage for all of the above, including a registry- parity check between analyze.py's and build.py's adapter lists. - Renamed changelog.d/784.added.md to 782.added.md: 784 didn't resolve to a real issue; filed #782 to track the still-open `tan model check` CLI wiring this engine exists for.
…hon3 dep Three nits from a re-review of b671595: 1. test_drpai_available_when_the_env_var_names_a_built_install indexed straight into _rows(envelope(result))["drpai"] before checking the run actually succeeded. A caught subprocess spawn trips model_cmd.py's broad `except Exception`, which swallows the AssertionError the mutation raises and re-emits it as an ok=false envelope with an EMPTY data.backends list -- so the mutation surfaced as KeyError: 'drpai' instead of the trap's own message. Now asserts result.exit_code == 0 (with the envelope as the assertion message) before indexing into backends. Verified by hand: inserted subprocess.run(["/bin/true"]) at the top of drpai.py::_compiler_version, re-ran the test, confirmed the failure now reads "model doctor failed unexpectedly: AssertionError: model doctor must not spawn a subprocess" instead of a KeyError; reverted the mutation (git diff on drpai.py is empty). 2. _drpai_status gated on the vendor tutorial script under $ALP_DRPAI_TVM_HOME but not on python3, which DrpaiAdapter.compile() also shells (cmd = ["python3", str(script), ...], adapters/drpai.py:207) -- a host with a built toolchain tree and no python3 on PATH still read green, the same false-green class the deepx_dxm1 MAJOR in b671595 closed, one dependency further out. Added a shutil.which("python3") arm with its own caveat reason; python3 stays out of BACKEND_TOOLS (naming an interpreter present on nearly every host tells nobody anything actionable) -- it's checked as a compile() prerequisite, not reported as the row's tool. Updated the three existing tests that build a full drpai tree under _force_all_unavailable (which now also blanks python3) to resolve "python3" explicitly, and added a new test for the built-tree-but-no- python3 case. 3. Filed a stale-doc fix on tan-cli#781 (GitHub issue body, not a repo file): its "available comes from each adapter's own is_available()" line predates b671595's narrower deepx_dxm1/drpai probes and is republished verbatim into envelope-contract.json at release (contract/issue-codes.json:1860 -> release.yml), so a downstream consumer reading the reference would learn superseded semantics. Edited to describe the per-backend probe split. Landmine for the next editor: _run_doctor (model_cmd.py) is exactly 50 lines against the house FUNCTION_CAP of 50, enforced with a strict `>` in tests/gates/_module_size_budget_core.py. It passes with zero headroom -- untouched by this commit, but the next line added there fails the gate. Files changed: - python/tan/commands/model_cmd.py: _drpai_status gains a shutil.which("python3") arm with its own caveat reason; docstring updated. - python/tests/commands/test_model_doctor_command.py: assert exit_code == 0 before indexing into backend rows in the no-spawn test; three existing drpai "available" tests now resolve "python3" explicitly under _force_all_unavailable; new test for the built-tree/no-python3 case. Gate: env -u ALP_SDK_ROOT python -m pytest tests -q (fresh venv, import tan resolves to this worktree's python/) -> 4316 passed, 324 skipped, 1 xfailed, rc=0. tests/gates/test_module_size_budget.py -> 6 passed.
…st release-gate claims, stale path fix Companion to alp-sdk#1471's review-response commit; three review findings closed here: 1. test_vela_yolo_internal.py now resolves the public alp-sdk fixture (tests/fixtures/models/person_detect_int8.tflite, vendored in alp-sdk#1471) via ALP_SDK_ROOT, ahead of the private ALP_SDK_INTERNAL path (kept for any future licensed model). alp-sdk-internal never carried an Ethos-U sample model at all, so this test had literally never executed -- only skipped. Measured against alp-sdk-wt-consol (ab6968e2): both ethos-u85-256 and ethos-u55-256 parametrizations PASS. 2. Both test_vela_yolo_internal.py and test_deepx_yolo_internal.py asserted as established fact that `cutting-a-tan-release`'s checklist requires them reported PASSED, not skipped, before a release is cut. That skill has no such clause -- nothing in it mentions yolo, dxcom, or vela. Docstrings corrected to state this as owed work, not an existing gate. 3. test_adapters.py:600 pointed at tests/scripts/test_deepx_yolo_internal.py, a path that exists in neither repo post-relocation -- corrected to tests/model/test_deepx_yolo_internal.py. changelog.d/782.changed.md updated in full: the Vela-fixture unblock, the cross-repo `.alpmodel`-fixture guard hole this migration opened (mitigated on alp-sdk's side, not here -- see alp-sdk#1471), the "MUST land together" strengthening (this change's own commit message, 91a11b4, said "expected to" -- cannot rewrite a landed commit, corrected in the changelog instead), and the PINNED_SDK_COMMIT / PINNED_SDK_TAG re-pin follow-up (stated, not made -- the bound tree is already 20 commits ahead of both pins). Gates (python/, fresh venv, `import tan` resolved to this checkout): env -u ALP_SDK_ROOT python -m pytest tests -q -> 4290 passed, 327 skipped, 1 xfailed (matches reference) ALP_SDK_ROOT=<alp-sdk-wt-consol> python -m pytest tests/model -q -rs (vela NOT on PATH, matching the literal reference invocation) -> 84 passed, 12 skipped (matches reference exactly; vela test skips here only because vela itself isn't on PATH in this exact run) Same command with vela on PATH (needed for the Vela test to actually run rather than skip): -> 89 passed, 1 failed, 6 skipped. The failure, test_build_model_default_registry_tflite_source_still_uses_cpu_adapter, is pre-existing and unrelated to this change: it feeds a dummy b"TFL3-DUMMY" .tflite through the REAL default adapter registry, which also includes an ethos_u/Vela target for E1M-AEN801: with no vela on PATH the ethos_u attempt short-circuits to a graceful "not installed" coverage skip (test passes); with real vela on PATH, vela genuinely chokes on the garbage bytes and build_model() propagates that RuntimeError uncaught (by design -- "fails loudly" per build.py's own module docstring). Confirmed deterministic and independent of every file this change touches (none of them are build.py, ethos_u.py, or test_build.py) by isolating the same test with/without vela on PATH against the otherwise-identical tree. Left unfixed as out of this change's named scope; flagging for a maintainer follow-up rather than silently patching test_build.py's fixture.
…ble the Vela real-model proof in CI
test_build_model_default_registry_tflite_source_still_uses_cpu_adapter fed
dummy .tflite bytes through the real default adapter registry. With vela
absent the ethos_u adapter skips and only the cpu path runs; with vela on
PATH, VelaAdapter genuinely tries (and fails) to compile the unparseable
dummy bytes, and build_model() does not catch adapter.compile() exceptions
(a real compile failure is meant to fail the build loudly, not vanish into
a coverage skip) -- so the test errored on any host with model-compile
installed (tan-cli#784).
The test's own claim, per its comment, is narrower than "the default
registry compiles end to end": it guards the by_backend grouping against a
naive {a.backend: a for a in registry} dict letting ExecutorchAdapter steal
the "cpu" key from CpuAdapter -- unrelated to ethos_u/Vela. Renamed to
test_build_model_cpu_backend_adapters_tflite_source_uses_cpu_adapter and
pinned its registry to [CpuAdapter(), ExecutorchAdapter()], the same
pattern its neighbours in the file already use, so the outcome is
deterministic on every host. The sibling .pte-source test above it keeps
exercising the real default registry (its own #1260 purpose) unaffected,
since VelaAdapter.accepts() rejects .pte before compile() is reached.
Measured both ways: 84 passed, 12 skipped without vela on PATH; 90 passed,
6 skipped with it; zero failures either way.
This unblocks enabling the Ethos-U real-model proof
(test_vela_yolo_internal.py, reachable since alp-sdk#1471's public
person_detect_int8.tflite fixture) in CI: parity.yml's python-tests-shard
job -- the one that binds ALP_SDK_ROOT and is where tests/model runs --
now installs alp-tan[monitor,model-compile] instead of [monitor,model-io]
(a strict superset: ethos-u-vela, tflite, flatbuffers). ethos-u-vela ships
cp312 wheels for exactly this job's OS matrix (manylinux x86_64, win_amd64,
macosx arm64) at ~2 MB and is Apache-2.0 -- verified before adding it, not
assumed. ci.yml's bare pip install -e ./python (load-bearing for
test_declared_dependencies.py) is untouched.
test_vela_yolo_internal.py and test_deepx_yolo_internal.py are the two
real-model proofs. The Ethos-U one now runs and PASSES in CI
(ethos-u85-256 and ethos-u55-256). The DEEPX one still needs the
license-gated dxcom wheel, which has no PyPI extra, so it still skips
in CI.
Also renumbered changelog.d/782.changed.md to 785.changed.md: it documents
the ADR-0028 Task 6 proof relocation and cites no issue in its body, but its
filename borrowed tan-cli#782, a real, unrelated, open issue ("tan model
check: wire the static NPU-eligibility screen engine into a CLI
subcommand"). Filed tan-cli#785 to track that fragment's two owed
follow-ups (the release-checklist PASSED assertion and the
PINNED_SDK_COMMIT/PINNED_SDK_TAG re-pin) and renamed the fragment to it.
Gate: env -u ALP_SDK_ROOT python -m pytest tests -q -> 4290 passed,
327 skipped, 1 xfailed, rc=0 (fresh isolated venv, import tan resolves to
this worktree's python/). ALP_SDK_ROOT-bound tests/model -q -rs -> 84
passed/12 skipped without vela on PATH, 90 passed/6 skipped with it,
zero failures both ways.
Attribute: alpCaner
…space, close review gaps (ADR-0028 review) The MAJOR fix: test_real_deepx_dxm1_has_no_table_and_is_undetermined built its Conv op with the default (TFLite) op_namespace, so the namespace guard added in ed8dc4f refused any realistic onnx-vocabulary deepx table before scoring and the test read undetermined whether or not a table existed -- verified by planting a real onnx-namespace deepx table into a bound alp-sdk checkout (RED before the fix, GREEN after, table removed and tree confirmed clean). tensorio.OpDesc.op_namespace is now a required kw_only field with no default, so a future extractor or test construction site must state which vocabulary it means instead of silently inheriting "tflite". analyze.BackendReport gains a structured uncosted_cpu_op_count field alongside compute_on_npu_pct_max, so the "100.0 while npu_coverage is partial" caveat survives into an envelope consumer that doesn't render notes -- added ahead of tan-cli#782(CLI wiring) landing, per review. The "fits" regression guard now covers a report carrying _uncosted_macs_note (added a macs=0 SOFTMAX case), and the empty-ops report no longer cites a table whose op_namespace disagrees with the caller's src_format when there is no extracted-op vocabulary to check it against. tensorio.py comment/logic nits: reworded the false "activation input at index 0" claim (TRANSPOSE_CONV's index 0 is kOutputShapeTensor), and moved TRANSPOSE_CONV's branch ahead of the out_elems==0 guard so a zero-element output shape no longer vestigially zeroes an otherwise fully-computable MAC estimate. changelog.d/782.added.md renamed to 786.added.md: it documented the engine itself, not tan-cli#782 (the still-open CLI-wiring follow-on) -- filed tan-cli#786 to give it an accurate number, mirroring the precedent set by #785 for the sibling 782.changed.md fragment. Re-verified all four ADR-0028 non-negotiables red-on-mutation after these changes: format gate before table load, missing-table undetermined-never- cpu-only, "fits" absence, and MAC-weighted None-not-0-not-100 coverage.
…n CLI (tan-cli#782) Adds `tan model check` (Task 4) and its `--exact` opportunistic real-compile upgrade (Task 6) over the already-reviewed `tan.model.analyze` engine. New engine glue: `tan.model.check` (resolve_check_backends, which non-cpu backends a SKU actually declares via resolve_targets; check_model_backends, one BackendReport per backend, ops walked once) and `tan.core.model_check` (pure JSON/text rendering, mirroring model_doctor's split). model_cmd.py itself only resolves board.yaml/SDK-root the same way build does and shapes the envelope -- _run_check stays under the 50-line function cap by sharing _require_sku/_require_models_list/_require_model_entry/_require_metadata_sdk_root/ _resolve_metadata_dir with build (also shrinks _run_build). ok/exit 0 for any completed run regardless of verdict -- partial/cpu-only/ undetermined are the feature, never a failure. A .tflite model against an ONNX-only backend (drpai/deepx_dxm1) reports undetermined + format-not- accepted, never cpu-only. --exact runs the real vela compiler for ethos_u when it's on PATH and returns basis: "compiled" (the only basis allowed to say "fits"); degrades cleanly -- and says so, in a note -- when vela is absent, no accel config resolves, or the compile fails. drpai/deepx_dxm1 stay static-screen-only under --exact (both license-gated); reports that as a reason, never a crash. Two new reserved issue codes: model.check-sku-unresolved (board-level: som.sku's NPU backends unresolved) and model.check-failed (per-model, mirrors model.build-failed's shape so one bad model doesn't abort the batch). model_cmd.py crosses 800 lines (893) wiring a third subcommand's own board.yaml/SDK resolution + envelope shaping; regenerated the module-size budget with a reason. Also fixed in the same regen: function_count_budget's 260->261 pre-existing drift from the model-doctor merge (25443c4), measured before this change and blocking a green gate otherwise. Tests: python/tests/model/test_check.py (the engine glue, including the real V2N/V2M ALP_SDK_ROOT-gated cases) and python/tests/commands/test_model_check_command.py (the CLI/envelope/text contract, incl. the fits-vocabulary guard extended to rendered output). Gate: env -u ALP_SDK_ROOT python -m pytest tests -q -- 4379 passed, 340 skipped, 1 xfailed, 0 failed (baseline 25443c4: 4338 passed, 1 failed -- same pre-existing budget drift now fixed here). ALP_SDK_ROOT-bound tests/model -- 146 passed, 0 failed.
…it code (tan-cli#782 review) BLOCKER: --exact reported basis:"compiled"/npuCoverage:"fits" the moment VelaAdapter.compile() returned without raising -- but vela exits 0 on a full CPU fallback by design (measured: a float32 FULLY_CONNECTED model prints "NPU operators = 0 (0.0%)" and still exits 0), and the old code also threw away the static per-op verdicts (ops: []) that could have flagged it. VelaAdapter now parses vela's own "CPU/NPU operators = N (P%)" summary line (ethos_u._parse_vela_placement) into new Blob.cpu_op_count/npu_op_count fields; tan.model.check._report_from_vela_compile reads them and reports "fits" only at 100% real NPU placement, the real split for partial/zero placement (keeping report.ops rather than discarding it), and degrades to the static screen when the placement summary can't be read at all. Proven against a REAL vela compile of a new float32_fc.tflite fixture (vela accepts FULLY_CONNECTED by name but rejects its Float32 feature-map dtype), not just a monkeypatched compile(). MAJOR 2: a missing tflite reader (the model-io extra) made every .tflite check on every Ethos-U SoM report undetermined with a note pointing at the WRONG extra (model-compile) and no mention of the reader at all -- the exact shape a bare `pip install alp-tan` (ci.yml's own install) hits. check_model_ backends now distinguishes "reader absent" from "model genuinely has no ops" and names the fix (pip install alp-tan[model-io]). MAJOR 3: the text renderer counted placeholder "unknown" verdicts against len(ops) on the format-not-accepted/no-table-for-backend paths, printing "0/1 ops are NPU-eligible" for an undetermined report -- the cpu-only misreading this feature exists to prevent, reintroduced in the renderer even though the JSON was correct. _coverage_line now returns None whenever npuCoverage is "undetermined" or no verdict was actually determined, and also for a basis:"compiled" report (its kept static verdicts can legitimately disagree with vela's real placement, which would read as self-contradictory next to the real percentage). MINOR: changelog.d/779.added.md and 779.changed.md cited tan-cli#779, a merged unrelated PR -- filed #787/#788 and renumbered.
…82 review)
ci.yml's gates job installs `pip install -e ./python` with no extras -- the
five `tan model check` tests this migration added never ran clean under that
shape (test_declared_dependencies.py's own asymmetric-install contract):
* check_model_backends propagated OSError for an unreadable/missing model
source only when `tflite` happened to be importable. tensorio.extract_io/
extract_ops checked `import tflite` before reading the source bytes, so
on a bare install a missing source collapsed into the SAME empty result
as "the reader just isn't installed" -- check never raised, and
model_cmd's `model.check-failed` per-model issue never fired. Both
helpers now read (or take the caller's already-read bytes) BEFORE the
tflite import check, so an unreadable source raises regardless of
whether the reader is on this host. Fixes
test_check_model_backends_propagates_an_unreadable_source and
test_a_model_source_that_cannot_be_read_is_a_per_model_issue_not_a_crash
with no test changes -- both were already testing the right thing.
* test_check_model_backends_reports_format_not_accepted_never_cpu_only
asserted on `reports[0].ops[0]`, which needs a non-empty extracted op
list -- but the format gate it exercises fires before any op extraction
is scored, so it doesn't need a REAL tflite parse to prove its point.
Reworked to monkeypatch extract_ops with a synthetic OpDesc instead of
requiring the model-io extra, since the format gate matters most on
exactly the bare-install shape this test now runs under.
* test_check_model_backends_screens_the_real_tflite_fixture and
test_end_to_end_against_a_synthetic_ethos_u_som both assert a coverage
verdict that depends on the real tflite parser actually finding the
fixture's one FULLY_CONNECTED op -- genuinely need real op extraction, so
both now skip cleanly via pytest.importorskip("tflite", reason=...),
naming the model-io extra instead of leaving a bare "tflite not
installed". The three pre-existing tflite guards in test_check.py now
share the same named reason.
Verified both configurations from a fresh venv (import tan resolved to this
worktree, import tflite confirmed absent/present as expected):
bare (no extras): 4374 passed, 356 skipped, 1 xfailed, 0 failed
with [model-io]: 4389 passed, 341 skipped, 1 xfailed, 0 failed
Bound-SDK (tests/model, ALP_SDK_ROOT=alp-sdk-wt-consol): 153 passed, 11
skipped, 0 failed; with vela on PATH: 160 passed, 4 skipped, 0 failed, and
test_vela_compiles_real_model_for_e8[ethos-u85-256]/[ethos-u55-256] PASS.
Amends changelog.d/782.added.md (unreleased on this branch) rather than
filing a new fragment.
…aps (tan-cli#782 review) BLOCKER (tan-cli#789): _parse_vela_summary treated vela's <mem_area>_ memory_used CSV columns as bytes -- they are already KiB (ethosu/vela/stats_writer.py's memory_used[...] / 1024.0) -- and sourced arena_bytes from arena_cache_size, a config knob, not the model's actual arena requirement. Measured on a real person_detect_int8.tflite compile: reported "arena 384 bytes, SRAM 0 KiB" for a model needing 74480 bytes / 73 KiB. req_sram_kib flows into requires.sram_kib in every .alpmodel package, which alp-sdk's on-device selector gates fit on -- silently zeroed, it let an oversized model pass the fit check unconditionally. Now reads sram_memory_used as KiB, converts properly (round(kib*1024) for bytes, math.ceil(kib) for KiB -- never floor, so the fit gate never under-reports), and never reads arena_cache_size. The hand-typed byte-unit CSV fixture in test_adapters.py is replaced with one captured verbatim from a real vela run. MAJOR 1: tensorio.extract_io/extract_ops read @source before ANY format-dependent short-circuit now, not just before the tflite-importable check -- an unreadable/missing .onnx source used to return [] identically to "readable but not tflite", so model.check-failed never fired for it. MAJOR 2: _maybe_exact_ethos_u's guard checked report.ops[0].reason == "format-not-accepted", but report.ops is always [] for a non-.tflite source (extract_ops only ever extracts from .tflite) -- dead code for exactly the case ethos_u needed it for. --exact against a .onnx source on an Ethos-U SKU could reach VelaAdapter().compile() for real, landing vela's raw multi-line traceback in a note. Gated on VelaAdapter().accepts(...) directly instead; vela-failure notes are now truncated to their first line, word-boundary-capped. MAJOR 3: _cpu_fallback_line lacked the basis != "static-screen" gate _coverage_line already had, so a compiled report keeping its static per-op verdicts (--exact's partial/cpu-only path) could still print a stale "N ops are certain CPU fallback" line contradicting the real placement percentage reported alongside it. MAJOR 4: _report_from_vela_compile wrote vela's real op-count placement split into compute_on_npu_pct_max, a field documented as a MAC-weighted upper bound. Added BackendReport.npu_placement_pct_real (npuPlacementPctReal on the wire) for the real op-count split; compute_on_npu_pct_max now stays None at basis: "compiled". MINORS: tensorio's module/extract_ops docstrings now state the OSError contract instead of "never raise"; uncosted_cpu_op_count now carries through _report_from_vela_compile's kept-ops path instead of defaulting to 0; gen_tiny_model.py/gen_float32_fc_model.py now cite the model-io extra (tflite + flatbuffers) instead of model-compile. Gates: bare venv 4382 passed/357 skipped/1 xfailed (0 failed), model-io venv 4397 passed/342 skipped/1 xfailed (0 failed), tests/model bound to alp-sdk-wt-consol 160 passed/12 skipped without vela on PATH and 168 passed/4 skipped with it (0 failed either way). All four fixes mutation-tested (each corresponding test fails when its fix is reverted). --exact re-confirmed both ways with real vela: float32_fc.tflite stays cpu-only (never "fits"), person_detect_int8.tflite's 44/44-op compile still reports fits/compiled with the corrected arena/SRAM figures.
…board (tan-cli#789 review) 47a8545's `if sram_kib <= 0: return 0, 0` fires on a real, successful, NPU-placing compile whose working set vela put outside SRAM. Measured, real ethos-u-vela 5.1.0, keyword_scrambled_8bit.tflite at ethos-u85-256: 6 of 15 operators on the NPU, exit 0, sram_memory_used = 0.0, dram_memory_used = 5.359375 -- and an .alpmodel carrying arena=0 requires={'sram_kib': 0}, which alp-sdk's selector reads as "fits any envelope" (src/backends/inference/alp_model_select.c:88) before handing the caller arena_bytes = 0. A confirmed NPU placement with no SRAM working set is now a hard error naming the placement achieved, where the working set went, and the profile it went there under. Zero NPU operators still reports a real 0 (float32_fc.tflite reports 0.0 for every memory area). The root cause is one level down: vela is invoked with neither --system-config nor --memory-mode, so it falls back to Ethos_U85_SYS_DRAM_Mid / Dedicated_Sram_384KB -- DRAM-backed, on a module that is MRAM + SRAM. No profile is invented to paper over that: the SoM-authoritative one alp-sdk itself uses (--system-config Ethos_U85_SRAM_Only --memory-mode Sram_Only, examples/aen/aen-npu-inference-alp/CMakeLists.txt) names sections that live only in Alif's proprietary ensemble_vela.ini, which alp-sdk does not redistribute. Instead vela's own "Compilation may be invalid or non-optimal" verdict, naming the defaults it resolved, now rides out on Blob.caveats into every check --exact report and so into the JSON envelope. Also fixed here: - the summary CSV was read by sort order, not by which run wrote it (ethos-u55-256 and ethos-u55-128 both write <stem>_summary_Ethos_U55_High_End_Embedded.csv into one reused out_dir). Each run now compiles into its own vela-<accel_config>/ subdirectory and resolves the CSV by the system-config that run reported; ambiguity yields no figure rather than another compile's. - _short_vela_error kept the traceback banner, the one line with no diagnostic content, discarding "RuntimeError: Compilation failed: No networks defined via GraphAPI". It now prefers the exception line, kept behind the accel-config prefix, with the one-line/200-char guarantee. - two check tests degraded to `assert ([])` on the bare install shape ci.yml installs; both now carry the tflite-reader importorskip guard. Bound behaviour re-measured and unchanged: person_detect_int8.tflite 44/44 -> fits, arena 74480 bytes, SRAM 73 KiB; float32_fc.tflite -> cpu-only, basis compiled, npuPlacementPctReal 0.0, compute_on_npu_pct_max None.
…ackage (tan-cli#789 review) The refusal added at 7fcb73c works and does not over-trigger, but its blast radius was far larger than reported. BLOCKER 1 -- one refusal destroyed the whole .alpmodel. `adapter.compile()` had no per-target guard, so an `ethos-u85-256` refusal propagated out of `build_model`'s loop and aborted the ENTIRE package. Measured with real `ethos-u-vela` 5.1.0 over the committed `tests/fixtures/models/ tiny_int8.tflite`: E1M-AEN401, E1M-AEN601, E1M-AEN801 and E1M-NX9101 all reported `BUILD FAILED RuntimeError` and wrote no package at all, taking down `ethos-u55-256`, `ethos-u55-128` (arena 32, SRAM 1 KiB each) and `cpu`, which had compiled perfectly. A refusal is now ONE target's `coverage` skip carrying the refusal text as its reason -- legibly absent, never silently present with a zero footprint, never fatal to its siblings. Every other `adapter.compile()` exception still fails the build loudly. If every target ends up skipped there is still no package: the zero-blob guard raises with the full coverage detail, each refusal named -- a package with no runnable blob is worse than an error, because nothing fails until the device tries to load it. BLOCKER 2 -- the message prescribed an action tan cannot perform. It ended "Compile against a --system-config/--memory-mode matching this module's memory model instead", but `compile()` receives `opts` and never reads it, nothing under `tan/` passes either flag, and alp-sdk's `board.schema.json` declares `models[].compile` as `additionalProperties: false` over `deepx_dxm1`/`drpai` -- there is no `ethos_u` key to route a profile through. The passthrough is deliberately NOT wired (an alp-sdk schema change, ADR-0028). The message now states only what is true: vela fell back to its own built-in default profile because no module-specific vela configuration was supplied, supplying one is not plumbed through `tan model build` yet, for Alif Ensemble parts the authoritative profile lives in Alif's proprietary ensemble_vela.ini which alp-sdk does not redistribute, and the target is skipped while the SKU's others still build. MAJOR 3 -- the blast radius was misnamed. `ethos-u65-256` on E1M-NX9101 (NXP i.MX 93, default profile `Ethos_U65_Client_Server`, also DRAM-backed) hard-errors identically, so an NXP user read an error blaming an Alif memory model. The profile is now named from the run's own summary block, and the module docstring names U65 alongside U85 plus all four affected SKUs, verified against metadata/e1m_modules/*.yaml + metadata/socs/**. The U55 configs are unaffected: they resolve to the SRAM-backed `Ethos_U55_High_End_Embedded`. MINOR 7 -- the same zero-footprint hole on the branch the refusal exempts. A 0-NPU-op vela compile still wrote an `ethos_u` target with arena 0 / sram_kib 0 (measured on `float32_fc.tflite`: three such targets on E1M-AEN801, one on E1M-NX9101), the exact shape alp-sdk's selector accepts against any arena. An accelerator target with no accelerator placement is dropped to a coverage skip; `npu_op_count is None` is untouched -- unknown is not zero. MINOR 5 + NIT 8 -- the check note. `_VELA_ERR_NOTE_BUDGET = 200` cut the refusal at "... Refusing to report a zero..." (measured inner length 197), so the note carried the diagnosis with none of the fix; and it read "--exact compile with vela failed (...)" when vela exited 0 and it was the footprint that was refused. `VelaFootprintRefused` now has its own branch and its own measured budget (real refusal 591 chars, maximal 659, budget 700), pinned by a test. Foreign vela stderr stays at 200 -- the 750-char, 9-newline traceback guarantee is untouched. The refusal's selector clause was reworded to "accepts req_sram_kib == 0 against ANY arena size" rather than the retired "fits" vocabulary, because it now reaches a `basis: static-screen` note. MINOR 6 -- `_run_dir` was pinned only by a path-string assertion, so mutating it to `return out_dir` left the suite green except that one line. The stale-identical-name case is now covered: two runs sharing one `out_dir` collide on one summary filename, and the second run writing no summary must report its own absent footprint rather than inheriting the first run's 8 KiB. Gate (BARE shape, no extras, no ALP_SDK_ROOT, no vela): 4394 passed, 363 skipped, 1 xfailed, rc=0 Reference at 7fcb73c was 4389/357/1; the delta is the 11 tests added here. With vela + ALP_SDK_ROOT bound, tests/model + tests/commands + tests/core: 3842 passed, 30 skipped. Every fix above is mutation-bound: reverting each guard reddens its own test.
… (tan-cli#789 review) `_refusal_remedy` closed every defaulted-profile vela footprint refusal with "for Alif Ensemble parts it lives in the proprietary ensemble_vela.ini alp-sdk does not redistribute" -- unconditionally, for every part. Measured with real ethos-u-vela 5.1.0 over tests/fixtures/models/tiny_int8.tflite: on E1M-NX9101 (NXP i.MX 93, NOT an Alif part) the ethos-u65-256 refusal derived Ethos_U65_Client_Server / Dedicated_Sram_384KB and dram 0.11 KiB correctly for that run, then sent an NXP customer after an Alif file. alp-sdk's own documented i.MX 93 vela invocation involves no proprietary .ini at all (vendors/nxp-imx93/README.md), so the pointer is wrong there, not merely unhelpful. `_profile_clause` was already per-run (review MAJOR 3); this closes the second half of the same sentence. The clause is now gated on the compile target's silicon_ref -- the SoM preset's own `silicon:` value (alif:ensemble:e8 vs nxp:imx9:imx93) -- threaded from TargetSpec through CompilerAdapter.compile() by both callers: build_model passes spec.silicon_ref, and check.py's --exact now resolves a whole TargetSpec (_headline_ethos_u_target, was _headline_ethos_u_accel_ config) so the vendor and the accel config come off the SAME target. It is deliberately NOT derived from the accel config or from vela's profile name: Ethos_U85_SYS_DRAM_Mid is an Arm/vela built-in that any vendor's U85 part resolves to, so keying a vendor claim off it is semantically wrong and one non-Alif U85 module away from re-breaking. An unresolved silicon_ref (None) behaves like "not Alif", never like "probably Alif". silicon_ref reaches the adapter's diagnostics only -- the vela command line is byte-identical with and without it -- and the two clauses that hold for every part (no module profile was supplied and tan cannot pass one; the target is skipped while the SKU's others still build) are unchanged, so the NXP refusal loses only the sentence that was false for it. Measured refusals: 594 characters on E1M-AEN801 / ethos-u85-256 (with the Alif clause), 491 on E1M-NX9101 / ethos-u65-256 (without it), maximal 662 -- all one line and inside _VELA_REFUSAL_NOTE_BUDGET = 700. E1M-AEN801 still builds ethos-u55-256 + ethos-u55-128 + cpu with ethos-u85-256 recorded skipped, and E1M-NX9101 still builds its cpu target. Mutation-checked both ways: forcing the clause on fails 5 tests (including the real-vela E1M-NX9101 build), forcing it off fails 5 others (including the real-vela E1M-AEN801 build and the note-budget maximal). BARE gate (ci.yml:164 shape, no ALP_SDK_ROOT, PATH=/usr/bin:/bin): 4400 passed, 364 skipped, 1 xfailed, rc=0 -- +6 passed / +1 skipped against 9249842's 4394/363/1, exactly the new tests (the E1M-NX9101 build-path one needs a bound SDK and skips here).
… (tan-cli#789 review)
`tan model check --exact` surfaced `Blob.caveats` into its report and JSON
envelope, but `check` ships nothing. `tan model build` -- the path that writes
the bytes a board loads -- dropped them at the `Blob` -> `Target` hand-off, so
a package could ship a blob compiled for a memory model the module does not
have with nothing in the package saying so. That matters because `arena` and
`requires.sram_kib`, written into the same manifest entry and describing that
same default memory model, are what alp-sdk's on-device selector gates fit on
(`return e->arena_sram_kib == 0u || t->req_sram_kib <= e->arena_sram_kib;`,
src/backends/inference/alp_model_select.c:88).
The manifest now carries a per-target `caveats` list, and `tan model build`
reports each one as a `model.target-caveat` warning read back out of the
WRITTEN FILE (`package.read_manifest_file`, which seeks to the manifest region
instead of copying every blob), so the line describes the artifact rather than
an in-memory object that may not match it. An unreadable package is a
`model.caveat-readback-failed` warning, never silence -- silence is
indistinguishable from "no caveats". `_run_build`'s exit code now keys on
ERROR-severity issues, so a caveated build stays SUCCESS with the package
intact.
CONTAINER_VERSION does NOT move and alp-sdk is NOT touched. It versions the
24-byte binary frame, not the manifest's key set, and the existing on-device
reader already skips keys it does not know: `alp_model_parse`
(src/common/alp_model.c) ends all three of its map-decode loops with
`else { ok = zcbor_any_skip(zs, NULL); }`, and `zcbor_any_skip` recurses
through a nested list on a local state copy, drawing nothing from the
`zcbor_state_t zs[8]` backup budget. Measured, not assumed: that reader
compiled natively against real zcbor and fed a container carrying this exact
per-target `caveats` list returns ALP_OK with every existing field
byte-identical; the same reader fed the same container with the version bumped
to 2 returns ALP_ERR_VERSION (-11). An empty `caveats` is omitted from the
wire entirely, so alp-sdk's three committed C-test fixtures
(tests/fixtures/alpmodel/minimal.alpmodel,
tests/unit/alpmodel_reader/src/fixture.h, tests/yocto/onnx_cpu_fixture.h) are
byte-identical and were not regenerated.
Pinned against real ethos-u-vela 5.1.0: E1M-AEN801 over the committed
tests/fixtures/models/tiny_int8.tflite writes a package whose surviving
ethos-u55-256 and ethos-u55-128 targets each carry one caveat naming the
profile that run resolved -- system-config Ethos_U55_High_End_Embedded,
memory-mode Shared_Sram -- beside the real arena 32 bytes / sram_kib 1 figures
it qualifies, while the cpu passthrough target carries none. A new cross-repo
guard, test_the_on_device_reader_still_skips_manifest_keys_it_does_not_know,
fails from this side if that skip-unknown-keys fallback is ever removed.
added 4 commits
August 16, 2026 11:49
…mory mode
Plan Tasks 2 and 3 (alp-sdk
docs/superpowers/plans/2026-08-16-vela-memory-profile.md), consuming the
`npu_toolchain.vela` block alp-sdk #1470 publishes on every SoC spec that
declares an Ethos-U NPU.
`VelaAdapter.compile()` passed neither `--system-config` nor `--memory-mode`,
so vela fell back to a DRAM-backed built-in profile on parts that have no
DRAM, placed the whole working set where the module has no memory, and
reported `sram_memory_used = 0.0` -- the zero tan-cli#789 had to refuse.
`TargetSpec` now carries `vela_memory_mode` / `vela_system_config` out of the
SoC spec, and both callers (`tan model build`, `tan model check --exact`) hand
them to vela on the same call that carries the accel config. A spec with no
block yields no flag and byte-for-byte the invocation the adapter always
issued -- a profile is never guessed.
The vendor guard is load-bearing: a `system_config` is carried ONLY when the
block does not set `system_config_requires_vendor_config`, since the names
alp-sdk's own examples use live solely in Alif's proprietary
ensemble_vela.ini and passing one without it is a hard vela failure --
ethosu.vela.errors.CliOptionError: 'Error: Incorrect argument to CLI
option --system-config=Ethos_U85_SRAM_Only: Section
System_Config.Ethos_U85_SRAM_Only not found in Vela config file'
Measured end to end with real ethos-u-vela 5.1.0 over the committed
tests/fixtures/models/tiny_int8.tflite, before -> after:
E1M-AEN401 ethos-u85-256 skipped -> arena 32, sram_kib 1 (Sram_Only)
E1M-AEN601 ethos-u85-256 skipped -> arena 32, sram_kib 1 (Sram_Only)
E1M-AEN801 ethos-u85-256 skipped -> arena 32, sram_kib 1 (Sram_Only)
E1M-NX9101 ethos-u65-256 skipped -> arena 32, sram_kib 1 (Shared_Sram)
with vela's own columns on E1M-AEN801 / ethos-u85-256 moving
sram 0.0 / dram 0.265625 / on_chip_flash 0.0 -> sram 0.03125 / dram 0.0 /
on_chip_flash 0.234375. E1M-NX9101 previously shipped the cpu target alone.
On the real 44-op person_detect_int8.tflite the same pair moves
arena 74480 / sram_kib 73 -> arena 73728 / sram_kib 72. `--exact` is unchanged
in verdict: person_detect stays `fits` at 44/44, float32_fc stays `cpu-only`
at 0/1 with npuPlacementPctReal 0.0 and compute_on_npu_pct_max None.
The refusal is kept, not deleted, and the three end-to-end guards that used
to fire against the real SKUs now drive the real presets with only
`npu_toolchain` stripped -- the condition a part whose profile is still TBD
is genuinely in. Its remedy sentence no longer claims tan "cannot pass one
yet", which this change made false; it now says no profile was resolved for
this part, so vela chose its own.
KNOWN AND OPEN: `_footprint` reads `sram_memory_used` alone, so under
`Sram_Only` it reports the arena and omits the const region vela files under
`on_chip_flash` as a bookkeeping rename (`architecture_features.py`: "Changing
const_mem_area from Sram to OnChipFlash. This will use the same
characteristics as Sram."), which on an Alif part is SRAM0-resident all the
same. Measured on person_detect_int8.tflite at ethos-u85-256 that is
`req_sram_kib = 72` against 72.0 + 235.265625 = 307.265625 KiB really
resident. Summing the columns is NOT the fix -- an integration that XIPs
weights from flash would then be over-reported -- and choosing correctly needs
a per-part statement of where the const region lands, which no metadata
carries yet. Recorded in `_footprint`'s docstring and beside every assertion
that reads the figure; no test pins the value as correct.
Also corrects a figure the earlier draft mis-attributed: `dram_memory_used =
5.359375` belongs to keyword_scrambled_8bit.tflite; tiny_int8.tflite with no
profile flags reports 0.265625 (re-measured directly against vela 5.1.0).
Gates:
bare (ci.yml:164 shape, no ALP_SDK_ROOT, PATH=/usr/bin:/bin)
4423 passed, 376 skipped, 1 xfailed, rc=0
collection 4781 -> 4800 items: +4 test_build.py, +3 test_targets.py,
+6 test_adapters.py, +6 test_targets_vela_profile.py
ALP_SDK_ROOT bound + vela on PATH
5 failed, 5419 passed, 68 skipped, 1 xfailed, rc=1 -- all five reproduce
identically on c1f1720 with the same bound tree (relocation-freshness pin
drift, test_new_som_command hw-rev cross-check, three planner-emit parity)
…the caveat to it The adapter, its tests and the changelog all stated a general rule -- placement by --memory-mode, bandwidth by --system-config -- that only holds under Sram_Only. A Memory_Mode assigns const/arena/cache to AXI PORTS; a System_Config maps those ports to memory AREAS. Sram_Only puts all three on Axi0 and all 11 System_Config sections vela 5.1.0 ships set axi0_port=Sram, which is the only reason the two Sram_Only measurements matched. Shared_Sram -- the mode tan passes for E1M-NX9101 -- sets const_mem_area=Axi1. Measured on person_detect_int8.tflite at ethos-u65-256 --memory-mode Shared_Sram, changing only --system-config: Ethos_U65_Embedded sram 72.734375 / dram 0.0 / off_chip_flash 228.265625, Ethos_U65_Mid_End sram 72.734375 / dram 228.3125 / off_chip_flash 0.0, Ethos_U65_Client_Server sram 72.734375 / dram 228.25 / off_chip_flash 0.0. 228 KiB of weights moves on the system config alone. So the caveat shipped inside every .alpmodel now gates its 'arena/SRAM figures are unaffected / bandwidth only' wording on a memory mode whose const area is Axi0. Under an Axi1 const mode it says vela's default system config also chose which memory the weights land in -- a placement, not an estimate. An unrecognised memory mode takes the same shape (under-claiming is the safe direction) and a run reporting none at all gets a narrower sentence that claims neither. Also: - pin the per-flag remedy gate. Reverting _refusal_remedy to 'if not defaulted:' passed clean; the one behavioural point -- a run that DID get its module's memory mode must not be told 'No module vela profile was resolved for this part' -- is now asserted, and mutation-reds. - name the System_Config in the surviving Alif clause. Since alp-sdk #1470 the load-bearing half of that profile is an Arm built-in tan passes with no .ini, so 'it lives in the proprietary ensemble_vela.ini' claimed too much. Not '--system-config': that would put a CLI flag into a message that prescribes nothing tan can pass. - fail the vendor guard CLOSED. An absent system_config_requires_vendor_config now withholds the name, like true; only an explicit False is a promise. The schema that makes the key required is enforced in the other repo, against a checkout tan binds at an arbitrary version. - drop 'arena_bytes == 32' from the caveat-wording test. It was the only assertion in the suite that would red the day a maintainer closed the SRAM under-report (measured: summing sram + on_chip_flash reddened exactly it, arena_bytes 272 != 32). The gap stays recorded in prose, unblessed. - record the ethos-u55 targets in the changelog table's paragraph: on person_detect_int8.tflite for E1M-AEN301, ethos-u55-256 and ethos-u55-128 both move arena 74480 / sram_kib 73 -> 73728 / 72; and the two vela columns nothing reads that also move (arena_cache_size 384.0 -> 1073741824.0, total_npu_encoded_weights 205472 -> 212096). - note where the 'one refusal costs ONE target' guard actually lives: four test_build.py tests behind a module-level ALP_SDK_ROOT skipif, enforced by parity.yml, not by ci.yml's bare gates job.
…opy of it The one assertion on the refusal surface -- "the string `fits` never appears in a `basis: static-screen` output" -- was bound against a hand-copied literal of the refusal, so it compared a copy to the template it was copied from and enforced nothing. Proven by mutation on 08314b0: rewording on-device selector accepts req_sram_kib == 0 against ANY arena size -> on-device selector reads req_sram_kib == 0 as fits any envelope left tests/model/test_check.py, tests/model/test_analyze.py and tests/commands/test_model_check_command.py all green (65 passed, 22 skipped, rc=0) while the phrase demonstrably reached the customer surface -- rendered through _footprint_refused_note, BASIS = static-screen, and "fits" present in the JSON envelope. None of the three tests carrying that guard bound the template: test_analyze's runs never provoke a refusal, and test_model_check_command monkeypatches check_model_backends with fabricated reports whose notes are hand-written literals. test_a_refused_footprint_is_not_reported_as_a_failed_compile now holds the ARGUMENTS of a real Alif Ensemble refusal and calls the real _refuse_zero_sram_footprint with them. Five mutations of the live template that were ALL green at 08314b0 now all red: fits-any-envelope, a "compile with vela failed" headline, dropping the ensemble_vela.ini vendor clause, dropping the "`tan model build` skips this target" tail, and injecting a newline. The comment in ethos_u.py that credited the two static-screen guards for an enforcement they never provided is corrected rather than deleted -- a false claim in shipped code is what stops the next reader adding a real guard -- and the same false credit is corrected in the changelog fragment. Also: - re-measure the two "measured, not guessed" note-budget figures in check.py. A real ethos-u85-256 refusal is 618 characters, not 621, measured by compiling tests/fixtures/models/tiny_int8.tflite with real ethos-u-vela 5.1.0 at silicon_ref alif:ensemble:e8; the deliberately maximal one that test_the_refusal_note_budget_covers_a_maximal_refusal builds is 686, not 688. Both were carried forward with an estimated delta instead of re-run. No gate reads either, so an assumed figure rots silently -- the comment now says to re-run them. The changelog fragment's own 662/594 pair is corrected to the same measurements. - record in MODULE_SIZE_BUDGET_LOG.md that a flat ratchet does not imply a flat file. long_functions measures end_lineno - lineno per def, so prose moved out of a docstring into a block ABOVE the def is invisible to it: measured across 88ef2f1 -> 08314b0, ethos_u.py went 591 -> 725 lines while _refusal_remedy's span SHRANK 50 -> 42 and this file's over-50 count held at 2. That move was right and is not undone; the file is now 742 lines against MODULE_CAP 800. The 17 new comment lines are kept INSIDE the function so this ratchet sees them -- function_count_budget 264 -> 265, with the reason logged.
…ation # Conflicts: # python/tests/gates/MODULE_SIZE_BUDGET_LOG.md # python/tests/gates/module_size_budget.generated.json
added 12 commits
August 16, 2026 15:47
…ot fail them ci.yml's sdk_parity checkout `ref:` and parity.yml's PINNED_SDK_TAG are both 88318e759958529fbbd8fe9d481373681c0fa78d, which predates every artefact ADR-0028 publishes on the alp-sdk side. They land together in alplabai/alp-sdk#1470, still OPEN, so there is no post-merge SHA to move the pin to -- and twelve tests under python/tests/model/ FAILED against the checkout CI clones instead of skipping against it, on every shard of all three OSes. tests/conftest.py gains three capability predicates, the same discipline as pytest.importorskip("tflite"). Each names ONE missing artefact; each fires only when a root IS bound, so the modules' own "ALP_SDK_ROOT is not set" reason still wins when none is; and each tests for the artefact's PRESENCE rather than for "would this assertion fail", so none can fire once the pin moves. A tree carrying an artefact only partially makes them RUN and fail loudly, which is the correct direction. npu_toolchain.vela in metadata/socs/** (alp-sdk fff41087) -- without it resolve_targets() yields vela_memory_mode=None, tan invokes vela flagless, and VelaFootprintRefused fires correctly. Gates 2 in test_targets.py and 5 in test_build.py. metadata/npu_ops/**, the committed op-support tables -- gates 2 in test_analyze.py. scripts/alp_model/ still present, i.e. alp-sdk before ab6968e2 regenerated its committed C fixtures through the relocated generator and moved their banner to `python -m tan.model._gen_fixture`. Gates the C-HEADER halves only: test_committed_fixture_matches_generator is split so its container-BYTE comparison -- the strongest half of that cross-repo guard, and identical either side of the relocation -- keeps running unconditionally. test_vela_yolo_internal.py is a different defect and gets a different fix. _real_int8_models() globbed *_int8.tflite over the bound SDK's fixture dir, and alp-sdk's own 1-operator tiny_int8.tflite matches -- so against an SDK pinned before person_detect_int8.tflite landed (alp-sdk 4fd5fab5) it did not skip, it silently substituted the toy. Measured, flagless VelaAdapter().compile() on ethos-u-vela 5.1.0: tiny_int8.tflite gives ethos-u85-256 REFUSED (0 KiB SRAM) and ethos-u55-256 req_sram_kib=1 / arena_bytes=32, while person_detect_int8.tflite gives req_sram_kib=73 / arena_bytes=74480 at both. One half of the pair went red for the right reason; the other reported PASSED on a 712-byte toy -- and cutting-a-tan-release's checklist reads exactly that word (`grep -c '^PASSED'` MUST be 3, `grep -c '^SKIPPED'` MUST be 0) as its evidence that the real-model proofs ran. The public fixture is now NAMED, so an alp-sdk without it produces a SKIP the checklist counts. Measured with that recipe: old pin PASSED=0 SKIPPED=2, new metadata PASSED=2 SKIPPED=1 (dxcom, license-gated). The private alp-sdk-internal sample-models dir keeps its glob; it holds no toy. The pin is NOT moved -- #1470 is unmerged. All four sites (ci.yml's `ref:`, parity.yml's PINNED_SDK_TAG, PINNED_SDK_COMMIT and HAND_PORT_PINNED_SDK_COMMIT) now record that it must move onto a commit carrying npu_toolchain.vela in the same merge window as #1470, and that these tests skip until it does. Measured on this tree: BARE (env -u ALP_SDK_ROOT, PATH=/usr/bin:/bin, python -m pytest tests -q) 4427 passed, 386 skipped, 1 xfailed rc=0 (was 4427 passed, 385 skipped, 1 xfailed at 550e4df; +1 skip is the split test's second half, which also skips with no SDK bound) ALP_SDK_ROOT=<88318e75>, --ignore=tests/gates --ignore=tests/parity 4189 passed, 67 skipped, 1 xfailed rc=0 (was 12 failed, 4189 passed, 54 skipped, 1 xfailed) ALP_SDK_ROOT=<88318e75>, tests/parity tests/gates 1237 passed, 13 skipped rc=0 ALP_SDK_ROOT=<alp-sdk carrying the metadata>, the five affected modules 84 passed, 0 skipped rc=0 -- every one of the twelve RUNS and PASSES
…e the optional vendor .ini
The zero-SRAM refusal survives -- a part whose profile is still TBD needs it
-- but both of its part-specific clauses now come from that part's own SoC
spec instead of prose in the adapter, resolved once in resolve_targets and
threaded on TargetSpec rather than read from metadata/ inside a compiler
adapter.
* WHICH vendor .ini it names is npu_toolchain.vela.vendor_config_filename.
The gate used to be silicon_ref.startswith("alif:ensemble:") with the
filename hardcoded beside it, so "a non-Alif refusal never names an Alif
file" held only for the parts someone had thought about; it now holds
because no part is ever handed another part's file at all. silicon_ref was
that gate's only reader and leaves CompilerAdapter.compile().
* WHY a DRAM placement is wrong is external_memory_interfaces[]:
alif:ensemble:e8 lists exactly HexSPI and SD/eMMC, so the dram figure is
marked "(no DRAM interface on this SoC)" -- on an explicit False only,
never on a spec that says nothing.
ALP_VELA_CONFIG lets a licensed customer supply the vendor .ini from the
environment, never board.yaml. It is passed as a complete set or not at all:
measured on ethos-u-vela 5.1.0, --config alone is rc=1 ("not allowed when
using a default system configuration"), --config with only --system-config is
rc=1 ("... default memory mode"), and --config REPLACES Arm's vela.ini rather
than merging with it. No SoC spec names a vendor System_Config today, so the
mechanism correctly does nothing and a test pins that state.
tan model doctor reports the .ini under data.optional[] in the same five-key
row shape, worded so an unlicensed customer reads as complete rather than
broken: without it vela uses Arm's built-in system config, which is what the
arena/SRAM figures already describe.
Refs alplabai/alp-sdk#1470
…ation # Conflicts: # python/tests/gates/MODULE_SIZE_BUDGET_LOG.md # python/tests/gates/module_size_budget.generated.json
…nt (tier 2) alp-sdk publishes bench-measured perf points under `metadata/model_perf/` (schema `fe56ff1d`, identity reshaped in `f724d3e4`). `tan.model.perf` reads them, and a matched point finally produces the `basis: "bench"` / `confidence: "certain"` that `tan/model/analyze.py:25-26` reserved from the beginning and nothing had ever emitted. Resolution order is precomputed -> exact-if-toolchain -> static. A point matches on an EXACT agreement of the eight identity fields -- SKU, `hw_rev`, `core`, backend, `accel_config`, model `sha256`, toolchain name and version. No closest model (the slug is a label two byte-sequences can share), no same-family accel config, no other module revision, no prefix match on a truncated digest, and no `_fixture`-bannered synthetic: alp-sdk stamps that banner on placeholder figures, and shipping one at `confidence: "certain"` is the worst output this tier could produce. What tan cannot state, tan does not guess. `hw_rev` comes from `board.yaml`'s `som.hw_rev`, falling back to the SKU preset's `default_hw_rev` exactly as `board.schema.json` says the SDK does -- and when neither answers, NOTHING matches, because serving an r2 measurement to an r1 module is the exactly-measured-wrong-machine failure the identity exists to stop. `core` comes from the SoC spec's own `npus[].paired_core`, now carried on `TargetSpec`; where the spec declares no pairing it is left unstated. The toolchain PROFILE is part of a point's file identity but not of the match key -- a customer with no toolchain cannot state one -- so a lookup can legitimately leave more than one point standing, measured on machines that differ. `find_perf_points()` returns all of them and ranks nothing. `_resolve_perf_point` applies exactly ONE tiebreak, and it is a silicon fact: the profile the SoC spec's `npu_toolchain` block declares for the part, i.e. what `tan model build` compiles it under. Anything else falls through. Absence never degrades. Every non-match path hands back the SAME report object, so an absent, ambiguous, fixture-marked or placement-less point cannot change a report by so much as a note -- the `npu-ops-v1` rule that absence is `undetermined`, never a negative. `--exact` still wins when the customer's own compile disagrees, on the three figures both sides really produce (placement verdict, arena, resident SRAM); a figure either side did not report is never a disagreement. The bench point is named in the note regardless. When the two agree the point wins, since it adds measured wall-clock latency and a traceable capture no compile can give. `basis: "bench"` is the second surface ever permitted to say `fits`, and both now derive it from ONE function (`perf.coverage_from_placement`) instead of two copies of the rule, so the guard binds to the live rule for both. Nine mutations green -> RED -> green over the shipped code, including a partial claiming `fits`, `fits` becoming unreachable, the static screen emitting it, a near-miss on the model digest, a consumed fixture, an unmatched `hw_rev`, an unnarrowed core, a neutered profile tiebreak, and a multi-match resolved by taking the first. Gates, measured on this tree: BARE (`ci.yml:164`'s install shape) 4568 passed / 391 skipped / 1 xfailed, rc=0. Bound to the CI pin 88318e75, where `metadata/model_perf/` and its fixture do not exist, 5537 passed / 115 skipped / 1 xfailed, rc=0 -- the two new SDK-gated tests SKIP there with reasons naming the artefact. Bound to a live alp-sdk worktree, 77 failed / 5487 passed, whose failing node IDs are byte-identical to the same run at HEAD (measured A/B): `test_planner_emit_parity` over every board tree, the relocation-freshness hash and `test_new_som_command`, i.e. the bound tree is not `PINNED_SDK_COMMIT`.
…ation # Conflicts: # python/tests/gates/MODULE_SIZE_BUDGET_LOG.md
…OR/4×MINOR/3×NIT)
BLOCKER: the SoC-profile tiebreak (_profile_matches_the_part) applied only
once >=2 points survived every other filter, so a SINGLE point captured
under a memory profile the part does not declare was consumed unfiltered at
basis: "bench", confidence: "certain" -- exactly the "exactly measured,
describes the wrong machine" hazard docs/bench/model-perf-capture.md names.
tan.model.perf_apply._resolve_perf_point now narrows on the part's declared
profile BEFORE the single-point shortcut, whenever the part states one.
MAJOR: core only narrowed via the SPECIFIC accelerator's own paired_core, so
an unpaired NPU (the E8's Ethos-U85, sharing its die with paired Ethos-U55s)
left core fully unconstrained -- a point measured on a32_cluster (an
application core that drives no Ethos-U NPU) matched outright. A new
_paired_cores_for_backend additionally refuses any core nothing on the die
pairs to ANY npu of the same backend. The changelog's "eight identity
fields, all exact" claim is corrected everywhere it was written (perf.py's
match-rule comment, changelog.d/791.added.md) to six-exact-plus-two-
narrowed-where-constrained.
MINORs:
- the --exact-disagreement path now carries the point's own perf_ref/
latency_ms_mean/latency_ms_p95/latency_runs as report fields, not just
inside the note's prose (check.py:398-401's own reasoning applied to the
disagreement path too).
- _perf_point_report keeps host/toolchain-environment notes ("--exact was
requested, but vela is not on PATH") across a re-base onto a matched bench
point instead of dropping every prior note; only basis-describing notes
are dropped.
- model_cmd._declared_hw_rev now fails CLOSED (model.board-yaml-invalid) on
a present-but-unusable som.hw_rev (e.g. an unquoted YAML int) instead of
silently falling through to the SKU preset's default_hw_rev.
- cpu's absence from tier 2 is now a documented decision (_PERF_TOOLCHAIN's
own comment + resolve_check_backends), not a silent gap: it already has no
caller (resolve_check_backends excludes cpu from what check screens at
all), so cpu points would have nowhere to be applied even if wired.
NITs: _millis now reads a measured 0 as a measured zero (was `> 0`, now
`>= 0`, matching _count's own rule); find_perf_points' dead `hw_rev is None`
early return is deleted (point.hw_rev == hw_rev already refuses on its own).
tan/model/check.py was 789 lines against the house 800-line module cap;
tan/model/perf_apply.py is a new module carrying the tier-2 MATCH/DECIDE
logic that used to live at the bottom of check.py (pure string/decision
logic, no subprocess) -- check.py is 483 lines after the split.
Every fix is mutation-proven: neutered, confirmed the exact test(s)
reddened, restored.
…Rx4/NITx2)
Item 1 (MAJOR, E1M-NX9101/imx93 fail-open): add a level-1 core check --
a bench point's core must exist in the SKU's own SoM preset topology:
map, unconditionally, before the level-2 paired-core union narrowing
that alone left a die with no paired_core anywhere (imx93's lone
ethos-u65) fully open. The two levels are independently mutation-proven.
Item 2 (MINOR): the level-2 paired-core refusal (E1M-AEN801's Ethos-U85
on a32_cluster) is no longer silent -- apply_perf_point now appends a
note naming the refused point and why, on every return path.
Item 3 (documentation): name E1M-NX9101 explicitly where the residual
"core stays unnarrowed" claim previously mis-stated it as a spec that
"predates the field entirely".
Item 4 (MINOR x5): split every --exact degrade note that mixes a HOST
fact with a BASIS clause into two notes, so the basis clause ("reporting
the static screen instead") is dropped -- not kept verbatim -- when a
bench point later re-bases the report to basis: "bench".
Item 5 (MINOR): _perf_identity no longer prints toolchain_system_config/
toolchain_memory_mode for a non-ethos_u point -- nothing sourced exists
to verify a drpai/deepx_dxm1 point's profile fields, so they must not be
printed as if they were part of this point's identity.
Item 6 (NITs): drop check.py's noqa re-export of _PERF_TOOLCHAIN (tests
now import tan.model.perf_apply directly); fix a test docstring that
named the wrong SoC shape for the "die pairs no core" case.
…t like its siblings
…eport-object rule
perf_apply.py's level-1 core check (_topology_core_ids) was fixed to
fail closed on a SoM preset with a missing or empty topology: block,
but no test held that shape in place -- the exact "if allowed_cores:"
fail-open class the level-2 guard was fixed for one round earlier.
Add two tests (missing topology entirely, and an explicit empty
topology: {} block) that assert an otherwise-perfect perf point is
refused, and mutation-prove them: gating the level-1 filter behind
"if topology_cores:" turns both RED, restoring the unconditional
filter turns them back GREEN.
Also correct two comments that had drifted out of true: perf.py's
"whole pipeline's guarantee" block named only the conditional level-2
narrowing and omitted level 1, which is unconditional and the only
level that ever acts on E1M-NX9101 and every drpai/deepx_dxm1 SoM;
and perf_apply.py's _EXACT_HOST_NOTE_PREFIX comment claimed every
note-authoring site in check.py's exact-compile path shares the
"--exact" prefix, which the same path's own basis-clause sites
(check.py:233, :341, :387, :429, :518) deliberately do not.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
tan's half of alp-sdk ADR-0028. The host-side model engine relocates from
alp-sdk into
python/tan/model/,tan model checkandtan model doctorlandon top of it, and the Ethos-U compile path is fixed to describe real silicon.
On top of that,
tan model checknow also answers from Alp Lab's own benchmeasurements (tier 2), for a customer holding neither the NPU toolchain nor
the silicon.
Needs alp-sdk's
feat/model-edge-ai-foundationin the same merge window.alp-sdk deletes
scripts/alp_model/andscripts/alp_cli/model.py; this branchprovides their replacement. Merge one without the other and
tan model buildhas no engine.
Why the engine moved
Runtime forwarding was not reachable:
alp = "alp_cli.main:cli"was deleted inalp-sdk
629aa75f,[project.scripts]registers onlyalp-mcp, and the wheel'sinclude = ["alp_cli*", "alp_mcp*"]omitsalp_model— importing it raisesModuleNotFoundError: No module named 'alp_model'.And the hand-port had already shipped a defect. alp-sdk#1271 restricted
compile-option path resolution to
_PATH_OPT_KEYS = {"config", "calibration", "images", "spec"}; tan's copy neverreceived it, so
tan model buildrewrote DRP-AI'sinput_shape("1,3,224,224"),input_name("images") andproduct("V2N") into absolute filesystem paths.The sha256 pin could not see it:
HAND_PORT_HASHEShashes only the upstreamside, so it proves the SDK file has not moved, never that tan implements what was
pinned. That entry was added already carrying the post-fix hash while tan's copy
was pre-fix, so the gate was green for the whole life of the bug.
The silicon-facing chain this fixes
_parse_vela_summaryread vela's CSV memory columns as bytes. They are alreadyKiB (
ethosu/vela/stats_writer.py). It also sourced the arena fromarena_cache_size— a configured cache capacity, not a measurement; underSram_Onlyit reads1073741824.0(1 GiB). Measured on the real 44-opperson_detect_int8.tflitecompile the adapter reportedarena 384 bytes, SRAM 0 KiB.requires.sram_kib = 0then flowed into every.alpmodel, and alp-sdk'son-device selector is
return e->arena_sram_kib == 0u || t->req_sram_kib <= e->arena_sram_kib;(
src/backends/inference/alp_model_select.c:88) — zero fits any arena. Fourfurther defects sat behind it, each fixed and mutation-bound:
--exactinferred NPU placement from vela's exit code, which is 0 on a fullCPU fallback. It now reads vela's real per-operator placement.
E1M-AEN401/E1M-AEN601/E1M-AEN801/E1M-NX9101produced no.alpmodelatall. It is now a per-target
Coverage(..., "skipped", <reason>)..onnxreturnedok:true/exitCode 0with a verdict-shapednote. It now errors on the bare install shape.
compute_on_npu_pct_maxcarried an op-count ratio in a MAC-weighted field.The op-count ratio moved to
npuPlacementPctReal.With alp-sdk publishing each SoC's
npu_toolchain.vela.memory_mode, tan nowpasses
--memory-mode(and--system-configonly when it needs no vendor.ini).E1M-AEN801'sethos-u85-256went from askippedcoverage row to ashipped target;
E1M-NX9101from shippingcpualone to shipping its NPU.Known open gap, deliberately not closed
_footprint()reads only thesramcolumn. UnderSram_Onlyvela files theconst region under
on_chip_flashas a bookkeeping rename(
ethosu/vela/architecture_features.py, "Changing const_mem_area from Sram toOnChipFlash"), and on an Alif part that region is still SRAM0-resident
(
examples/aen/aen-npu-inference-alp/src/main.cmemcpys the model into__attribute__((section("SRAM0")))). Soperson_detect_int8.tfliteatethos-u85-256reportsreq_sram_kib = 72against72.0 + 235.265625 = 307.265625 KiBactually resident.It was NOT closed by summing the columns — an integration that XIPs weights from
flash would then over-report. The correct fix needs a per-part statement of where
the const region physically lands, which is a maintainer hardware decision. The
gap is recorded in
_footprint's docstring and beside every assertion that readsthe figure, and no test pins the under-reported value as correct.
Tier 2: bench-measured perf points (
metadata/model_perf/)tan model checknow has a THIRD fidelity tier, above the always-offlinestatic screen and the customer's own
--exactcompile: alp-sdk publishesbench-measured points under
metadata/model_perf/(
metadata/schemas/model-perf-v1.schema.json, alp-sdkf724d3e4); when onematches,
checkreportsbasis: "bench"/confidence: "certain"with theMEASURED arena, resident SRAM and inference latency (mean + p95 + run count)
as structured envelope fields —
arenaBytes,reqSramKib,latencyMsMean,latencyMsP95,latencyRuns, plusperfRef(the point'scapture.reference)— so a customer holding neither the NPU toolchain nor the silicon still gets
an exact, traceable number. Resolution order is precomputed →
exact-if-toolchain → static, but the bench point WINS when it exists, except
when the customer's own
--exactcompile disagrees with it (their profile maynot be ours, so their own machine's number is what they can act on).
The match rule is deliberately narrow, in both directions:
hw_rev, backend,accel_config, the model'ssha256, toolchain name);coreand toolchainversion narrow only where the SoC spec actually constrains them, and
coreadditionally refuses any core NOTHING on the die pairs to any NPU of the
same backend, even when the SPECIFIC accelerator being screened (an
unpaired Ethos-U85 sharing a die with paired Ethos-U55s, on the E8) names
no pairing of its own.
consumed, even when it is the only point published — a DRAM-backed default
capture against a
Sram_Onlypart is refused, not reported atconfidence: "certain".hw_revis required-and-nullable: a customer'sboard.yamlsom.hw_revthat is present but unusable (an unquoted YAML int) refuses the whole
checkrun rather than silently falling back to the SKU preset's owndefault_hw_rev, which could serve a different module revision'smeasurement.
no ambiguity resolved by preference (a lookup that leaves more than one
point standing falls through rather than picking one), and no
_fixture-bannered synthetic ever read as real data.basis: "bench"is the second surface (after a real--exactcompile) everpermitted to emit
npu_coverage: "fits", and both derive it from the samefunction (
tan.model.perf.coverage_from_placement).tan.model.perf(the READER: finds points, refuses to rank them) andtan.model.perf_apply(the MATCHER/DECIDER: turns a matched point into areport) split the tier-2 engine in two —
tan/model/check.pywas against thehouse 800-line module cap, and this is pure string/decision logic with no
subprocess in it.
Also in this branch
python/tan/model/— 13 modules relocated verbatim, pluspython/tan/soc_ref.pyso
tan.modeldoes not depend ontan.planner.tan model check— a static NPU-eligibility screen. Its negatives are certainand its positives are capped at "eligible", because vela attaches shape and
quantization constraints a static screen cannot evaluate. It never says
fitswithout a real compile or a matched bench point.
tan model doctor— per-backend toolchain availability with actionablereasons.
.alpmodelpackages now carry each shipped blob's compiler caveat, so a blobcompiled against vela's built-in default profile says so inside the artifact.
Verification
Gate —
ci.yml:164's bare install shape, the one CI runs, frompython/withimport tanverified to resolve into this worktree:Zero failures is the bar; note for anyone reproducing: a stale
alp-taneditable install on a developer box leaks onto
sys.pathand injects phantomfailures — use an isolated venv and check where
import tanresolves.Every non-negotiable in this surface is bound by mutation, verified by reverting
each guard and confirming the specific test goes red:
basis: static-screenoutput never containsfitsundetermined, nevercpu-only(DEEPX ships no table bydecision, and conflating the two would report a false negative on V2M where
DEEPX is the headline feature)
--exactreads vela's real placement, never its exit code[]single standing point, not only as a >=2 tiebreak
for an accelerator that itself pairs to no core
--exacthost/toolchain diagnostic survives a re-base onto amatched bench point
som.hw_revrefuses the run rather than fallingback to the preset's
default_hw_revOne of those was found to be decorative and fixed here: the
fitsguard assertedagainst a hand-copied literal of the refusal rather than the live template, so
mutating the template left the suite green while the word reached the customer
surface.
_refusenow calls the production function; five separate mutations ofthe live template each go red.
Scope
No envelope-contract break beyond the retired
fits | cpu-fallback | no-fitvocabulary, whose lockstep consumer change is open on alp-sdk-vscode. No flash
path touched. No new runtime dependency — the
model-ioandmodel-compileextras stay optional and the bare install is the CI shape.
Refs #789. Refs #782. Refs #777. Refs alplabai/alp-sdk#1470 (
f724d3e4, theperf-point contract; alp-sdk changelog fragment
changelog.d/1520.md).