Skip to content

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
devfrom
feat/model-engine-migration
Open

feat(model): ADR-0028 — relocate the model engine into tan, and compile Ethos-U for the real memory model#791
alpCaner wants to merge 46 commits into
devfrom
feat/model-engine-migration

Conversation

@alpCaner

@alpCaner alpCaner commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What

tan's half of alp-sdk ADR-0028. The host-side model engine relocates from
alp-sdk into python/tan/model/, tan model check and tan model doctor land
on top of it, and the Ethos-U compile path is fixed to describe real silicon.
On top of that, tan model check now also answers from Alp Lab's own bench
measurements (tier 2), for a customer holding neither the NPU toolchain nor
the silicon.

Needs alp-sdk's feat/model-edge-ai-foundation in the same merge window.
alp-sdk deletes scripts/alp_model/ and scripts/alp_cli/model.py; this branch
provides their replacement. Merge one without the other and tan model build
has no engine.

Why the engine moved

Runtime forwarding was not reachable: alp = "alp_cli.main:cli" was deleted in
alp-sdk 629aa75f, [project.scripts] registers only alp-mcp, and the wheel's
include = ["alp_cli*", "alp_mcp*"] omits alp_model — importing it raises
ModuleNotFoundError: 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 never
received it, so tan model build rewrote DRP-AI's input_shape ("1,3,224,224"),
input_name ("images") and product ("V2N") into absolute filesystem paths.
The sha256 pin could not see it: HAND_PORT_HASHES hashes only the upstream
side, 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_summary read vela's CSV memory columns as bytes. They are already
KiB (ethosu/vela/stats_writer.py). It also sourced the arena from
arena_cache_size — a configured cache capacity, not a measurement; under
Sram_Only it reads 1073741824.0 (1 GiB). Measured on the real 44-op
person_detect_int8.tflite compile the adapter reported arena 384 bytes, SRAM 0 KiB.

requires.sram_kib = 0 then flowed into every .alpmodel, and alp-sdk's
on-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. Four
further defects sat behind it, each fixed and mutation-bound:

  • --exact inferred NPU placement from vela's exit code, which is 0 on a full
    CPU fallback. It now reads vela's real per-operator placement.
  • A zero-footprint refusal aborted the entire package rather than one target, so
    E1M-AEN401/E1M-AEN601/E1M-AEN801/E1M-NX9101 produced no .alpmodel at
    all. It is now a per-target Coverage(..., "skipped", <reason>).
  • An unreadable .onnx returned ok:true / exitCode 0 with a verdict-shaped
    note. It now errors on the bare install shape.
  • compute_on_npu_pct_max carried 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 now
passes --memory-mode (and --system-config only when it needs no vendor
.ini). E1M-AEN801's ethos-u85-256 went from a skipped coverage row to a
shipped target; E1M-NX9101 from shipping cpu alone to shipping its NPU.

Known open gap, deliberately not closed

_footprint() reads only the sram column. Under Sram_Only vela files the
const region under on_chip_flash as a bookkeeping rename
(ethosu/vela/architecture_features.py, "Changing const_mem_area from Sram to
OnChipFlash"), and on an Alif part that region is still SRAM0-resident
(examples/aen/aen-npu-inference-alp/src/main.c memcpys the model into
__attribute__((section("SRAM0")))). So person_detect_int8.tflite at
ethos-u85-256 reports req_sram_kib = 72 against 72.0 + 235.265625 = 307.265625 KiB actually 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 reads
the figure, and no test pins the under-reported value as correct.

Tier 2: bench-measured perf points (metadata/model_perf/)

tan model check now has a THIRD fidelity tier, above the always-offline
static screen and the customer's own --exact compile: alp-sdk publishes
bench-measured points under metadata/model_perf/
(metadata/schemas/model-perf-v1.schema.json, alp-sdk f724d3e4); when one
matches, check reports basis: "bench" / confidence: "certain" with the
MEASURED arena, resident SRAM and inference latency (mean + p95 + run count)
as structured envelope fields — arenaBytes, reqSramKib, latencyMsMean,
latencyMsP95, latencyRuns, plus perfRef (the point's capture.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 --exact compile disagrees with it (their profile may
not be ours, so their own machine's number is what they can act on).

The match rule is deliberately narrow, in both directions:

  • SIX identity fields compare exactly (SoM SKU, module hw_rev, backend,
    accel_config, the model's sha256, toolchain name); core and toolchain
    version narrow only where the SoC spec actually constrains them, and core
    additionally 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.
  • A point captured under a memory profile the part does not declare is never
    consumed, even when it is the only point published — a DRAM-backed default
    capture against a Sram_Only part is refused, not reported at
    confidence: "certain".
  • hw_rev is required-and-nullable: a customer's board.yaml som.hw_rev
    that is present but unusable (an unquoted YAML int) refuses the whole
    check run rather than silently falling back to the SKU preset's own
    default_hw_rev, which could serve a different module revision's
    measurement.
  • No "closest model" (the digest is the key, never the human-readable slug),
    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 --exact compile) ever
permitted to emit npu_coverage: "fits", and both derive it from the same
function (tan.model.perf.coverage_from_placement).

tan.model.perf (the READER: finds points, refuses to rank them) and
tan.model.perf_apply (the MATCHER/DECIDER: turns a matched point into a
report) split the tier-2 engine in two — tan/model/check.py was against the
house 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, plus python/tan/soc_ref.py
    so tan.model does not depend on tan.planner.
  • tan model check — a static NPU-eligibility screen. Its negatives are certain
    and its positives are capped at "eligible", because vela attaches shape and
    quantization constraints a static screen cannot evaluate. It never says fits
    without a real compile or a matched bench point.
  • tan model doctor — per-backend toolchain availability with actionable
    reasons.
  • .alpmodel packages now carry each shipped blob's compiler caveat, so a blob
    compiled 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, from python/ with
import tan verified to resolve into this worktree:

env -u ALP_SDK_ROOT -u ALP_SDK_PARITY_ROOT PATH=/usr/bin:/bin .venv-gate/bin/python -m pytest tests -q
4630 passed, 394 skipped, 1 xfailed in 295.11s
rc=0

Zero failures is the bar; note for anyone reproducing: a stale alp-tan
editable install on a developer box leaks onto sys.path and injects phantom
failures — use an isolated venv and check where import tan resolves.

Every non-negotiable in this surface is bound by mutation, verified by reverting
each guard and confirming the specific test goes red:

  • a basis: static-screen output never contains fits
  • a missing op table is undetermined, never cpu-only (DEEPX ships no table by
    decision, and conflating the two would report a false negative on V2M where
    DEEPX is the headline feature)
  • --exact reads vela's real placement, never its exit code
  • an unreadable source errors on the bare install rather than returning []
  • a refusal costs one target, never the package
  • a non-Alif refusal never names an Alif file
  • a tier-2 match narrows on the profile the part declares BEFORE trusting a
    single standing point, not only as a >=2 tiebreak
  • a core NOTHING on the die pairs to any NPU of the backend is refused even
    for an accelerator that itself pairs to no core
  • a customer's --exact host/toolchain diagnostic survives a re-base onto a
    matched bench point
  • a present-but-unusable som.hw_rev refuses the run rather than falling
    back to the preset's default_hw_rev

One of those was found to be decorative and fixed here: the fits guard asserted
against 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. _refuse now calls the production function; five separate mutations of
the live template each go red.

Scope

No envelope-contract break beyond the retired fits | cpu-fallback | no-fit
vocabulary, whose lockstep consumer change is open on alp-sdk-vscode. No flash
path touched. No new runtime dependency — the model-io and model-compile
extras stay optional and the bare install is the CI shape.

Refs #789. Refs #782. Refs #777. Refs alplabai/alp-sdk#1470 (f724d3e4, the
perf-point contract; alp-sdk changelog fragment changelog.d/1520.md).

Caner Alp 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.
Caner Alp 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
@alpCaner alpCaner added enhancement New feature or request python-port Rust-to-Python port of the tan command surface envelope-contract The {command,ok,exitCode,project,data,issues} contract consumed by alp-sdk-vscode safety Can damage hardware or lose data if wrong labels Aug 16, 2026
@alpCaner alpCaner self-assigned this Aug 16, 2026
Caner Alp 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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request envelope-contract The {command,ok,exitCode,project,data,issues} contract consumed by alp-sdk-vscode python-port Rust-to-Python port of the tan command surface safety Can damage hardware or lose data if wrong

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant