From 637485f46abe9d6744047db1cbfc54b54e3ac416 Mon Sep 17 00:00:00 2001 From: Adam Page Date: Wed, 7 Oct 2026 13:23:16 -0500 Subject: [PATCH 1/4] build(helia): adopt ns-cmsis-nn 7.41.0 with s16 regression Refs #426 Refs #427 --- .github/workflows/ns_cmsis_nn_canary.yml | 4 +- .github/workflows/smoke_cmake.yml | 2 +- .../content/docs/getting-started/zephyr.md | 2 +- cmake/README.md | 9 +- cmake/helia_rt_core_version.cc | 8 + cmake/helia_rt_sources.cmake | 4 + nsx/CMakeLists.txt | 2 +- nsx/nsx-module.yaml | 2 +- tensorflow/lite/micro/kernels/helia/README.md | 2 +- .../helia/tests/convolution_status_test.cc | 12 +- .../kernels/helia/tests/quantize_s16_test.cc | 140 ++++++++++++++++++ .../lite/micro/tools/make/ext_libs/helia.inc | 4 +- .../micro/tools/make/ext_libs/helia_tests.inc | 10 ++ .../make/ext_libs/ns_cmsis_nn_download.sh | 4 +- 14 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 cmake/helia_rt_core_version.cc create mode 100644 tensorflow/lite/micro/kernels/helia/tests/quantize_s16_test.cc diff --git a/.github/workflows/ns_cmsis_nn_canary.yml b/.github/workflows/ns_cmsis_nn_canary.yml index aa36d8c7a..1392fa8a1 100644 --- a/.github/workflows/ns_cmsis_nn_canary.yml +++ b/.github/workflows/ns_cmsis_nn_canary.yml @@ -43,10 +43,10 @@ on: ns_cmsis_nn_ref: description: "ns-cmsis-nn commit SHA, tag (e.g. v7.35.0) or branch to build against" required: true - # Current repository pin: AmbiqAI/ns-cmsis-nn v7.39.3. Dispatching with + # Current repository pin: AmbiqAI/ns-cmsis-nn v7.41.0. Dispatching with # the default is a no-op control run that should match the normal # matrix; keep this in step with ext_libs/helia.inc. - default: "94bf3b9362874d951f9363bb0ddef72fd162e8f6" + default: "48ddbf7d863745eee108c68726f42fc82dabe0af" permissions: contents: read diff --git a/.github/workflows/smoke_cmake.yml b/.github/workflows/smoke_cmake.yml index 32a9e3d4e..56e7cf4b7 100644 --- a/.github/workflows/smoke_cmake.yml +++ b/.github/workflows/smoke_cmake.yml @@ -33,7 +33,7 @@ permissions: env: # Pin CI to the tested ns-cmsis-nn release, shared by every job that # clones it so the cross-compile legs cannot drift apart. - NS_CMSIS_NN_REF: v7.39.3 + NS_CMSIS_NN_REF: v7.41.0 jobs: manifest: diff --git a/astro-site/src/content/docs/getting-started/zephyr.md b/astro-site/src/content/docs/getting-started/zephyr.md index 5897047ff..4b8e04edc 100644 --- a/astro-site/src/content/docs/getting-started/zephyr.md +++ b/astro-site/src/content/docs/getting-started/zephyr.md @@ -16,7 +16,7 @@ Add these projects to the `manifest.projects` list in your workspace's west mani path: modules/helia-rt - name: ns-cmsis-nn url: https://github.com/AmbiqAI/ns-cmsis-nn - revision: v7.39.3 + revision: v7.41.0 path: modules/ns-cmsis-nn ``` diff --git a/cmake/README.md b/cmake/README.md index 9224877d3..60ddb3b3f 100644 --- a/cmake/README.md +++ b/cmake/README.md @@ -51,7 +51,7 @@ When consuming via the top-level `CMakeLists.txt` (or NSX/Zephyr source-mode she |---|---|---| | `HELIA_RT_BUILD_TYPE` | `release_with_logs` | `debug` / `release_with_logs` (defines `NDEBUG`) / `release` (also `TF_LITE_STRIP_ERROR_STRINGS`) | | `HELIA_RT_ENABLE_CMSIS_NN` | `OFF` | Materialize `helia_rt::cmsis_nn`. Requires consumer-provided `cmsis-nn` target. | -| `HELIA_RT_ENABLE_HELIA` | `OFF` | Materialize `helia_rt::helia`. Requires consumer-provided `ns-cmsis-nn` target. | +| `HELIA_RT_ENABLE_HELIA` | `OFF` | Materialize `helia_rt::helia`. Requires a consumer-provided `ns-cmsis-nn` v7.41.0 or newer target. | | `HELIA_RT_GLOBAL_KERNEL_OPTIMIZE` | `SPEED` | HELIA backend kernel profile: `SPEED` or `SIZE`. | | `HELIA_RT_CONV_OPT` | (empty) | Optional HELIA conv-family override: `SPEED` or `SIZE`; empty inherits `HELIA_RT_GLOBAL_KERNEL_OPTIMIZE`. | | `HELIA_RT_FC_OPT` | (empty) | Optional HELIA fully-connected override: `SPEED` or `SIZE`; empty inherits `HELIA_RT_GLOBAL_KERNEL_OPTIMIZE`. | @@ -72,8 +72,11 @@ add_subdirectory(third_party/helia-rt) target_link_libraries(my_app PRIVATE helia_rt::reference) ``` -For the HELIA backend, select the source-build kernel profile before adding -the repo: +For the HELIA backend, provide a released ns-cmsis-nn v7.41.0 or newer +target before adding the repo. Its exported FP32/FP16 definitions must match +the library. The build checks the canonical header version; the application +remains responsible for supplying the matching library. Select the +source-build kernel profile before adding the repo: ```cmake set(HELIA_RT_ENABLE_HELIA ON CACHE BOOL "" FORCE) diff --git a/cmake/helia_rt_core_version.cc b/cmake/helia_rt_core_version.cc new file mode 100644 index 000000000..c8e247d96 --- /dev/null +++ b/cmake/helia_rt_core_version.cc @@ -0,0 +1,8 @@ +// Copyright 2026 Ambiq +// SPDX-License-Identifier: Apache-2.0 + +#include "Include/arm_nn_types.h" + +#if !defined(NS_CMSIS_NN_VERSION) || NS_CMSIS_NN_VERSION < 7041000 +#error "heliaRT requires ns-cmsis-nn v7.41.0 or newer" +#endif diff --git a/cmake/helia_rt_sources.cmake b/cmake/helia_rt_sources.cmake index faadd0da9..96aff65d3 100644 --- a/cmake/helia_rt_sources.cmake +++ b/cmake/helia_rt_sources.cmake @@ -359,6 +359,10 @@ function(helia_rt_select_kernel_sources OUT_VAR) endif() endforeach() + if(_ARG_BACKEND STREQUAL "helia") + list(APPEND _out "${HELIA_RT_ROOT}/cmake/helia_rt_core_version.cc") + endif() + set(${OUT_VAR} ${_out} PARENT_SCOPE) endfunction() diff --git a/nsx/CMakeLists.txt b/nsx/CMakeLists.txt index 30a000c1d..09c7653a3 100644 --- a/nsx/CMakeLists.txt +++ b/nsx/CMakeLists.txt @@ -26,7 +26,7 @@ # nsx_soc_hal SoC HAL (transitive deps for debug_log glue) # # Backend-specific upstream targets: -# helia: nsx::cmsis_nn (or alias) — ns-cmsis-nn kernels, with Ambiq's +# helia: nsx::cmsis_nn (or alias) — ns-cmsis-nn >= 7.41.0, with Ambiq's # additive weight-sum / fast-path symbols actively referenced. # fp32/fp16 are opt-in: set ARM_NN_ENABLE_F32/F16 before # add_subdirectory(/nsx ...), which is where diff --git a/nsx/nsx-module.yaml b/nsx/nsx-module.yaml index 2891e46df..cd83fa4e1 100644 --- a/nsx/nsx-module.yaml +++ b/nsx/nsx-module.yaml @@ -11,7 +11,7 @@ support: summary: >- Ambiq-optimized TensorFlow Lite for Microcontrollers (TFLM) runtime. - Built from source against the nsx-cmsis-nn (ns-cmsis-nn) kernel module. + Built from source against the nsx-cmsis-nn (ns-cmsis-nn >= 7.41.0) kernel module. capabilities: - inference diff --git a/tensorflow/lite/micro/kernels/helia/README.md b/tensorflow/lite/micro/kernels/helia/README.md index e07189251..48dcf8c6d 100644 --- a/tensorflow/lite/micro/kernels/helia/README.md +++ b/tensorflow/lite/micro/kernels/helia/README.md @@ -9,7 +9,7 @@ For more information about the optimizations, check out [CMSIS-NN documentation](https://github.com/ARM-software/CMSIS-NN/blob/main/README.md), The helia backend's floating-point build support is provided by the pinned -`AmbiqAI/ns-cmsis-nn` v7.39.3 release. The Make build enables the fp32 API for helia +`AmbiqAI/ns-cmsis-nn` v7.41.0 release. The Make build enables the fp32 API for helia targets and enables the fp16 API only for `TARGET_ARCH=cortex-m55`. fp16 requires an ARMv8.1-M MVEF-capable toolchain. diff --git a/tensorflow/lite/micro/kernels/helia/tests/convolution_status_test.cc b/tensorflow/lite/micro/kernels/helia/tests/convolution_status_test.cc index 821a1e7e4..f0a44c511 100644 --- a/tensorflow/lite/micro/kernels/helia/tests/convolution_status_test.cc +++ b/tensorflow/lite/micro/kernels/helia/tests/convolution_status_test.cc @@ -72,6 +72,9 @@ struct LinkState { int32_t compute_context_sizes[static_cast(ComputeRoute::kCount)] = {}; bool compute_context_buffers[static_cast(ComputeRoute::kCount)] = {}; int weight_query_calls = 0; + // heliaCORE's weight-sum precompute may query the size itself; only the + // kernel's own queries are counted. + bool in_precompute = false; int precompute_calls[static_cast(PrecomputeRoute::kCount)] = {}; int32_t weight_context_sizes[static_cast(PrecomputeRoute::kCount)] = {}; bool weight_context_buffers[static_cast(PrecomputeRoute::kCount)] = {}; @@ -187,9 +190,12 @@ int32_t __wrap_arm_depthwise_conv_wrapper_s16_get_buffer_size( int32_t __real_arm_convolve_s8_get_weights_sum_size(const cmsis_nn_dims*); int32_t __wrap_arm_convolve_s8_get_weights_sum_size( const cmsis_nn_dims* output_dims) { - ++g_link_state.weight_query_calls; const int32_t actual = __real_arm_convolve_s8_get_weights_sum_size(output_dims); + if (g_link_state.in_precompute) { + return actual; + } + ++g_link_state.weight_query_calls; int32_t result = actual; if (g_link_state.override_weight_query) { result = g_link_state.weight_query_result < 0 || actual == 0 @@ -213,8 +219,10 @@ arm_cmsis_nn_status __wrap_arm_convolve_weight_sum( if (g_link_state.precompute_error == PrecomputeRoute::kConv) { return ARM_CMSIS_NN_ARG_ERROR; } + g_link_state.in_precompute = true; const arm_cmsis_nn_status status = __real_arm_convolve_weight_sum( sums, filter, input_dims, filter_dims, output_dims, input_offset, bias); + g_link_state.in_precompute = false; if (status == ARM_CMSIS_NN_NO_IMPL_ERROR) { if (sums != nullptr) { sums[0] = input_offset * filter[0] + (bias == nullptr ? 0 : bias[0]); @@ -237,9 +245,11 @@ arm_cmsis_nn_status __wrap_arm_depthwise_convolve_weight_sum( if (g_link_state.precompute_error == PrecomputeRoute::kDepthwise) { return ARM_CMSIS_NN_ARG_ERROR; } + g_link_state.in_precompute = true; const arm_cmsis_nn_status status = __real_arm_depthwise_convolve_weight_sum( sums, scratch, filter, params, input_dims, filter_dims, output_dims, input_offset, bias); + g_link_state.in_precompute = false; if (status == ARM_CMSIS_NN_NO_IMPL_ERROR) { if (sums != nullptr) { sums[0] = input_offset * filter[0] + (bias == nullptr ? 0 : bias[0]); diff --git a/tensorflow/lite/micro/kernels/helia/tests/quantize_s16_test.cc b/tensorflow/lite/micro/kernels/helia/tests/quantize_s16_test.cc new file mode 100644 index 000000000..3a4023a0f --- /dev/null +++ b/tensorflow/lite/micro/kernels/helia/tests/quantize_s16_test.cc @@ -0,0 +1,140 @@ +/* Copyright 2026 The TensorFlow Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "Include/arm_nnfunctions.h" +#include "tensorflow/lite/micro/c/common.h" +#include "tensorflow/lite/micro/kernels/kernel_runner.h" +#include "tensorflow/lite/micro/test_helpers.h" +#include "tensorflow/lite/micro/testing/micro_test_v2.h" + +namespace { + +#if defined(HELIA_QUANTIZE_S16_LINK_WRAP) +struct RequantizeCall { + int calls = 0; + int32_t size = 0; + int32_t multiplier = 0; + int32_t shift = 0; + int32_t input_zero_point = 0; + int32_t output_zero_point = 0; +}; + +RequantizeCall g_requantize_call; +#endif + +} // namespace + +#if defined(HELIA_QUANTIZE_S16_LINK_WRAP) +extern "C" { +arm_cmsis_nn_status __real_arm_requantize_s16_s16(const int16_t*, int16_t*, + int32_t, int32_t, int32_t, + int32_t, int32_t); + +arm_cmsis_nn_status __wrap_arm_requantize_s16_s16( + const int16_t* input, int16_t* output, int32_t size, int32_t multiplier, + int32_t shift, int32_t input_zero_point, int32_t output_zero_point) { + ++g_requantize_call.calls; + g_requantize_call.size = size; + g_requantize_call.multiplier = multiplier; + g_requantize_call.shift = shift; + g_requantize_call.input_zero_point = input_zero_point; + g_requantize_call.output_zero_point = output_zero_point; + return __real_arm_requantize_s16_s16(input, output, size, multiplier, shift, + input_zero_point, output_zero_point); +} +} +#endif + +namespace tflite { +namespace testing { +namespace { + +template +void ExpectS16Requantize(const int16_t (&input)[N], float input_scale, + int32_t expected_shift, const int16_t (&expected)[N]) { +#if !defined(HELIA_QUANTIZE_S16_LINK_WRAP) + (void)expected_shift; +#endif + int dims_data[] = {1, N}; + TfLiteIntArray* dims = IntArrayFromInts(dims_data); + constexpr int16_t kSentinel = 0x5a5a; + int16_t output[N + 1] = {}; + output[N] = kSentinel; + TfLiteTensor tensors[] = { + CreateQuantizedTensor(input, dims, input_scale, 0), + CreateQuantizedTensor(output, dims, 1.0f, 0), + }; + float input_scales[] = {1, input_scale}; + float output_scales[] = {1, 1.0f}; + int zero_points[] = {1, 0}; + TfLiteAffineQuantization quantization[2] = {}; + quantization[0].scale = FloatArrayFromFloats(input_scales); + quantization[1].scale = FloatArrayFromFloats(output_scales); + for (int i = 0; i < 2; ++i) { + quantization[i].zero_point = IntArrayFromInts(zero_points); + tensors[i].quantization = {kTfLiteAffineQuantization, &quantization[i]}; + } + int inputs_data[] = {1, 0}; + int outputs_data[] = {1, 1}; + const TFLMRegistration registration = Register_QUANTIZE(); + micro::KernelRunner runner(registration, tensors, 2, + IntArrayFromInts(inputs_data), + IntArrayFromInts(outputs_data), nullptr); +#if defined(HELIA_QUANTIZE_S16_LINK_WRAP) + g_requantize_call = RequantizeCall{}; +#endif + EXPECT_EQ(kTfLiteOk, runner.InitAndPrepare()); +#if defined(HELIA_QUANTIZE_S16_LINK_WRAP) + EXPECT_EQ(0, g_requantize_call.calls); +#endif + EXPECT_EQ(kTfLiteOk, runner.Invoke()); +#if defined(HELIA_QUANTIZE_S16_LINK_WRAP) + EXPECT_EQ(1, g_requantize_call.calls); + EXPECT_EQ(N, g_requantize_call.size); + EXPECT_EQ(1 << 30, g_requantize_call.multiplier); + EXPECT_EQ(expected_shift, g_requantize_call.shift); + EXPECT_EQ(0, g_requantize_call.input_zero_point); + EXPECT_EQ(0, g_requantize_call.output_zero_point); +#endif + for (int i = 0; i < N; ++i) { + EXPECT_EQ(expected[i], output[i]); + } + EXPECT_EQ(kSentinel, output[N]); +} + +} // namespace +} // namespace testing +} // namespace tflite + +TEST(HeliaQuantizeS16Test, LargeScalePositiveSaturation) { + const int16_t input[] = {1000}; + const int16_t expected[] = {32767}; + tflite::testing::ExpectS16Requantize(input, 2097152.0f, 22, expected); +} + +TEST(HeliaQuantizeS16Test, LargeScaleBodyAndTail) { + const int16_t input[] = {1000, -1000, 0, 1, -1, 32767, -32768, 2, -2}; + // Exact scale ratio 2^21: every nonzero input saturates with its sign. + const int16_t expected[] = {32767, -32768, 0, 32767, -32768, + 32767, -32768, 32767, -32768}; + tflite::testing::ExpectS16Requantize(input, 2097152.0f, 22, expected); +} + +TEST(HeliaQuantizeS16Test, OrdinaryScalePreservesValues) { + const int16_t input[] = {1000, -1000, 0, 32767, -32768}; + tflite::testing::ExpectS16Requantize(input, 1.0f, 1, input); +} + +TF_LITE_MICRO_TESTS_MAIN diff --git a/tensorflow/lite/micro/tools/make/ext_libs/helia.inc b/tensorflow/lite/micro/tools/make/ext_libs/helia.inc index 250b3cad8..2b549b47d 100644 --- a/tensorflow/lite/micro/tools/make/ext_libs/helia.inc +++ b/tensorflow/lite/micro/tools/make/ext_libs/helia.inc @@ -3,9 +3,9 @@ ifneq (,$(filter $(TARGET_ARCH), x86_64)) # NS-CMSIS-NN optimizations not supported endif -# Tracks AmbiqAI/ns-cmsis-nn tag v7.39.3. Consumed by ns_cmsis_nn_download.sh +# Tracks AmbiqAI/ns-cmsis-nn tag v7.41.0. Consumed by ns_cmsis_nn_download.sh # via the environment. -NS_CMSIS_NN_COMMIT ?= 94bf3b9362874d951f9363bb0ddef72fd162e8f6 +NS_CMSIS_NN_COMMIT ?= 48ddbf7d863745eee108c68726f42fc82dabe0af export NS_CMSIS_NN_COMMIT ifneq (,$(NS_CMSIS_NN_LIBS)) diff --git a/tensorflow/lite/micro/tools/make/ext_libs/helia_tests.inc b/tensorflow/lite/micro/tools/make/ext_libs/helia_tests.inc index af5d8f721..c209ea934 100644 --- a/tensorflow/lite/micro/tools/make/ext_libs/helia_tests.inc +++ b/tensorflow/lite/micro/tools/make/ext_libs/helia_tests.inc @@ -15,6 +15,16 @@ MICROLITE_KERNEL_SIMPLE_TEST_SRCS += \ $(TENSORFLOW_ROOT)tensorflow/lite/micro/kernels/helia/tests/prepare_contract_test.cc \ $(TENSORFLOW_ROOT)tensorflow/lite/micro/kernels/helia/tests/fp16_pad_rank_test.cc +$(eval $(call microlite_test,kernel_quantize_s16_test,\ +$(TENSORFLOW_ROOT)tensorflow/lite/micro/kernels/helia/tests/quantize_s16_test.cc)) + +ifneq (,$(filter gcc atfe,$(TOOLCHAIN))) +$(kernel_quantize_s16_test_BINARY): CXXFLAGS += \ + -DHELIA_QUANTIZE_S16_LINK_WRAP=1 +$(kernel_quantize_s16_test_BINARY): LDFLAGS += \ + -Wl,--wrap=arm_requantize_s16_s16 +endif + # Every float golden relies on the near and float-equal checks failing on a # NaN. see AmbiqAI/helia-rt#335 MICROLITE_KERNEL_SIMPLE_TEST_SRCS += \ diff --git a/tensorflow/lite/micro/tools/make/ext_libs/ns_cmsis_nn_download.sh b/tensorflow/lite/micro/tools/make/ext_libs/ns_cmsis_nn_download.sh index d494aeb09..4101fbef1 100755 --- a/tensorflow/lite/micro/tools/make/ext_libs/ns_cmsis_nn_download.sh +++ b/tensorflow/lite/micro/tools/make/ext_libs/ns_cmsis_nn_download.sh @@ -82,7 +82,7 @@ NS_CMSIS_NN_URL="https://github.com/AmbiqAI/ns-cmsis-nn.git" # local-scope rules are not part of the threat model either way. # Set GIT_COMMIT to NS_CMSIS_NN_COMMIT if set, otherwise use default. -# Default tracks AmbiqAI/ns-cmsis-nn tag v7.39.3. Keep in sync with +# Default tracks AmbiqAI/ns-cmsis-nn tag v7.41.0. Keep in sync with # NS_CMSIS_NN_COMMIT in ext_libs/helia.inc, which is what make actually passes; # this fallback only applies when the script is run directly. # @@ -92,7 +92,7 @@ NS_CMSIS_NN_URL="https://github.com/AmbiqAI/ns-cmsis-nn.git" # into extra argv words for `git checkout` (option injection, not shell # injection -- there is no eval here). The canary also validates the input # against ^[A-Za-z0-9._/-]+$ before it gets this far; this is the second layer. -GIT_COMMIT=${NS_CMSIS_NN_COMMIT:-94bf3b9362874d951f9363bb0ddef72fd162e8f6} +GIT_COMMIT=${NS_CMSIS_NN_COMMIT:-48ddbf7d863745eee108c68726f42fc82dabe0af} # clone_ns_cmsis_nn: attempt git clone and surface a clear error on failure. clone_ns_cmsis_nn() { From 0e4d270ac175672d8c3204b3a3ec993bfb0ee1e0 Mon Sep 17 00:00:00 2001 From: Adam Page Date: Wed, 7 Oct 2026 13:55:46 -0500 Subject: [PATCH 2/4] fix(pack): align HELIA dependency floor with compiled sources Refs #426 Refs #427 --- .github/workflows/smoke_cmake.yml | 1 + .../docs/getting-started/cmsis-pack.md | 6 +-- .../content/docs/getting-started/source.md | 2 +- tools/cmsis_pack/build_pack.py | 32 ++++++++---- tools/cmsis_pack/check_pdsc.py | 13 +++++ tools/cmsis_pack/check_pdsc_test.py | 50 +++++++++++++++++++ .../hello_world_helia.cproject.yml | 2 +- 7 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 tools/cmsis_pack/check_pdsc_test.py diff --git a/.github/workflows/smoke_cmake.yml b/.github/workflows/smoke_cmake.yml index 56e7cf4b7..07034a250 100644 --- a/.github/workflows/smoke_cmake.yml +++ b/.github/workflows/smoke_cmake.yml @@ -241,6 +241,7 @@ jobs: packs=(dist/Ambiq.helia-rt.*.pack) PACK="${packs[0]}" test -f "${PACK}" + python3 tools/cmsis_pack/check_pdsc_test.py python3 tools/cmsis_pack/check_pdsc.py "${PACK}" - name: Install consumer toolchains (Arm GNU, ATfE) run: | diff --git a/astro-site/src/content/docs/getting-started/cmsis-pack.md b/astro-site/src/content/docs/getting-started/cmsis-pack.md index e5048b3a3..138a3c141 100644 --- a/astro-site/src/content/docs/getting-started/cmsis-pack.md +++ b/astro-site/src/content/docs/getting-started/cmsis-pack.md @@ -44,7 +44,7 @@ In your project's component selection, use the Ambiq `Machine Learning` / `TFLM |---|---| | `Reference` | Portable runtime kernels without an external NN library. | | `CMSIS-NN` | Upstream Arm CMSIS-NN kernel dependency for your target. | -| `HELIA` | The Ambiq heliaCORE (`ns-cmsis-nn`) source component. The generated PDSC requires version 7.39.2 or newer. | +| `HELIA` | The Ambiq heliaCORE (`ns-cmsis-nn`) source component. The generated PDSC requires version 7.41.0 or newer. | Compile the runtime's C++ sources with `-fno-exceptions`: its arena objects hide `operator delete`, so its placement `new` does not compile with exceptions enabled. The example below sets it for the whole solution. @@ -52,7 +52,7 @@ Keep runtime and kernel feature settings consistent. A HELIA component selection ## Build the example consumer -[`tools/cmsis_pack/examples/hello_world`](https://github.com/AmbiqAI/helia-rt/tree/main/tools/cmsis_pack/examples/hello_world) is a minimal CMSIS-Toolbox solution. It targets the generic `ARM::ARMCM55` device and builds one project per variant: `hello_world` selects `Reference`, and `hello_world_helia` selects `HELIA` with heliaCORE built from the `Ambiq::NS-CMSIS-NN` source pack. Each links a `MicroInterpreter` against the installed packs. Add the heliaRT pack with `cpackget`, plus the ns-cmsis-nn pack (`Ambiq.NS-CMSIS-NN..pack` from the [ns-cmsis-nn release](https://github.com/AmbiqAI/ns-cmsis-nn/releases) that heliaRT pins, 7.39.2 or newer). Then point CMSIS-Toolbox 2.13.0 at each compiler's `bin` directory with a variable named after its version, for example `GCC_TOOLCHAIN_14_3_1` for Arm GNU 14.3.rel1 and `CLANG_TOOLCHAIN_22_1_0` for ATfE 22.1.0. Then build: +[`tools/cmsis_pack/examples/hello_world`](https://github.com/AmbiqAI/helia-rt/tree/main/tools/cmsis_pack/examples/hello_world) is a minimal CMSIS-Toolbox solution. It targets the generic `ARM::ARMCM55` device and builds one project per variant: `hello_world` selects `Reference`, and `hello_world_helia` selects `HELIA` with heliaCORE built from the `Ambiq::NS-CMSIS-NN` source pack. Each links a `MicroInterpreter` against the installed packs. Add the heliaRT pack with `cpackget`, plus the ns-cmsis-nn pack (`Ambiq.NS-CMSIS-NN..pack` from the [ns-cmsis-nn release](https://github.com/AmbiqAI/ns-cmsis-nn/releases) that heliaRT pins, 7.41.0 or newer). Then point CMSIS-Toolbox 2.13.0 at each compiler's `bin` directory with a variable named after its version, for example `GCC_TOOLCHAIN_14_3_1` for Arm GNU 14.3.rel1 and `CLANG_TOOLCHAIN_22_1_0` for ATfE 22.1.0. Then build: ```bash cd tools/cmsis_pack/examples/hello_world @@ -62,7 +62,7 @@ cbuild hello_world.csolution.yml --toolchain CLANG --update-rte --packs `--packs` installs the pinned public `ARM::CMSIS` and `ARM::Cortex_DFP` packs from the public pack index on first use. The example links `--specs=nosys.specs` on GCC and `-lsemihost` on ATfE to satisfy the C library's I/O and exit hooks; a board application supplies its own retarget layer instead. The packaging workflow builds this example on both compilers. It proves the `Reference` and `HELIA` variants compile and link together with their dependencies. It does not build the `CMSIS-NN` variant or run a model. -If a `HELIA` build fails with `Include/arm_nnfunctions.h: No such file or directory`, the installed ns-cmsis-nn pack is older than 7.39.2, the first release that exports its root as an include path. CMSIS-Toolbox reports the unmet PDSC requirement only as a warning. Install ns-cmsis-nn 7.39.2 or newer. +If a `HELIA` build fails with `Include/arm_nnfunctions.h: No such file or directory`, the installed ns-cmsis-nn pack is older than 7.39.2, the first release that exports its root as an include path. CMSIS-Toolbox reports the unmet PDSC requirement only as a warning. Install ns-cmsis-nn 7.41.0 or newer to satisfy the current HELIA source requirement. Your consumer project still provides the device/board support, compiler settings, startup code, memory layout and application sources. Add the model and inference sequence from [First inference](/helia-rt/getting-started/first-inference/), build with your CMSIS toolchain, then verify known inputs and outputs on the board. diff --git a/astro-site/src/content/docs/getting-started/source.md b/astro-site/src/content/docs/getting-started/source.md index 459839917..cdc133aec 100644 --- a/astro-site/src/content/docs/getting-started/source.md +++ b/astro-site/src/content/docs/getting-started/source.md @@ -77,7 +77,7 @@ set(HELIA_RT_NSCMSISNN_TARGET "ns-cmsis-nn" CACHE STRING "Kernel dependency") add_subdirectory(third_party/helia-rt) ``` -Link `rt_app` to `helia_rt::helia` instead of `helia_rt::reference`. Supply the CMSIS Core headers and target compiler flags required by your heliaCORE checkout. Enable float features before adding that dependency when the model needs them. For the adapters in this runtime release, use heliaCORE v7.35.0 or newer. +Link `rt_app` to `helia_rt::helia` instead of `helia_rt::reference`. Supply the CMSIS Core headers and target compiler flags required by your heliaCORE checkout. Enable float features before adding that dependency when the model needs them. For the current HELIA adapters, use heliaCORE v7.41.0 or newer. CMake links heliaCORE transitively into the final application; it does not physically merge two static libraries. For the upstream Arm backend, use `HELIA_RT_ENABLE_CMSIS_NN=ON`, provide its target through `HELIA_RT_CMSISNN_TARGET`, and link `helia_rt::cmsis_nn` instead. Do not substitute heliaCORE for upstream CMSIS-NN. diff --git a/tools/cmsis_pack/build_pack.py b/tools/cmsis_pack/build_pack.py index fc2e33d96..b89b8b51a 100644 --- a/tools/cmsis_pack/build_pack.py +++ b/tools/cmsis_pack/build_pack.py @@ -79,12 +79,8 @@ CSUB = "helia-rt" # Identity of the cross-pack dependency (Ambiq ns-cmsis-nn / heliaCORE). -# Pinned to the ns-cmsis-nn release that aligned its CMake / Zephyr / NSX / -# CMSIS-Pack surfaces with heliaRT. Bump the lower bound when a source adapter -# requires a newer API or a newer release introduces a breaking contract -# change; widen to a range (e.g. "7.25.0:8.0.0") once the next-major -# compatibility window is known. The CI guard at tools/cmsis_pack/check_pdsc.py -# asserts this value. +# The compiled HELIA source guard owns the lower bound. The pack checker +# rejects advertised metadata that admits older source dependencies. # # 7.25.0 introduced a Cvariant split ("Source" vs "Prebuilt") on the # heliaCORE component. heliaRT itself ships as source via CMSIS-Pack, so @@ -92,15 +88,31 @@ # consistent. Consumers who need the prebuilt heliaCORE for binary-size # reasons can override at integration time. # -# 7.39.2 is the floor because the HELIA sources include heliaCORE headers as -# "Include/.h", which resolves only once the ns-cmsis-nn pack exports its -# root as an include path. see AmbiqAI/ns-cmsis-nn#657 +# 7.39.2 first exported the include root needed by "Include/.h". +# see AmbiqAI/ns-cmsis-nn#657 NS_CMSIS_NN_VENDOR = "Ambiq" NS_CMSIS_NN_CCLASS = "Machine Learning" NS_CMSIS_NN_CGROUP = "NN Lib" NS_CMSIS_NN_CSUB = "heliaCORE" NS_CMSIS_NN_CVARIANT = "Source" -NS_CMSIS_NN_MIN_VERSION = "7.39.2" + + +def core_source_min_version() -> str: + """Read the minimum from the HELIA source's compiled version guard.""" + guard = Path(__file__).resolve().parents[2] / "cmake/helia_rt_core_version.cc" + versions = re.findall( + r"^\s*#if[^\n]*NS_CMSIS_NN_VERSION\s*<\s*(\d+)\s*$", + guard.read_text(encoding="utf-8"), + re.M, + ) + if len(versions) != 1: + raise ValueError(f"expected one HELIA version floor in {guard}") + major, remainder = divmod(int(versions[0]), 1000000) + minor, patch = divmod(remainder, 1000) + return f"{major}.{minor}.{patch}" + + +NS_CMSIS_NN_MIN_VERSION = core_source_min_version() # --------------------------------------------------------------------------- diff --git a/tools/cmsis_pack/check_pdsc.py b/tools/cmsis_pack/check_pdsc.py index 113d56a05..997e8e378 100644 --- a/tools/cmsis_pack/check_pdsc.py +++ b/tools/cmsis_pack/check_pdsc.py @@ -9,6 +9,7 @@ - HELIA variant is gated by a on ns-cmsis-nn/heliaCORE - the inside that condition targets the exact identity ns-cmsis-nn ships and pins Cversion to the agreed minimum + - the advertised minimum satisfies the compiled HELIA source floor Run modes: @@ -25,6 +26,7 @@ from __future__ import annotations import argparse +import re import sys import xml.etree.ElementTree as ET import zipfile @@ -46,6 +48,7 @@ NS_CMSIS_NN_VENDOR, PACK_NAME, PACK_VENDOR, + core_source_min_version, ) @@ -136,6 +139,16 @@ def check_contract(pdsc: ET.Element) -> list[str]: got == want, f"heliaCORE : {attr}={got!r}, want {want!r}", ) + advertised = req.get("Cversion", "") + source_floor = core_source_min_version() + _check( + failures, + bool(re.fullmatch(r"\d+\.\d+\.\d+", advertised)) + and tuple(map(int, advertised.split("."))) + >= tuple(map(int, source_floor.split("."))), + f"heliaCORE advertised floor {advertised!r} is below " + f"compiled HELIA source floor {source_floor}", + ) return failures diff --git a/tools/cmsis_pack/check_pdsc_test.py b/tools/cmsis_pack/check_pdsc_test.py new file mode 100644 index 000000000..ffd6f4c56 --- /dev/null +++ b/tools/cmsis_pack/check_pdsc_test.py @@ -0,0 +1,50 @@ +"""Regression checks for the advertised HELIA source dependency floor.""" + +import unittest +from unittest import mock + +import build_pack +import check_pdsc + + +class DependencyFloorTest(unittest.TestCase): + def make_pdsc(self): + manifests = { + backend: build_pack.BackendManifest( + backend=backend, + version="2.0.0", + include_dirs=(), + common_sources=(), + kernel_sources=("cmake/helia_rt_core_version.cc",) + if backend == "helia" + else (), + backend_defines=(), + ) + for backend, _, _ in build_pack.BACKENDS + } + return build_pack.build_pdsc( + version="2.0.0", + manifests=manifests, + sources=set(), + headers=set(), + include_dirs=set(), + ).getroot() + + def test_current_generated_contract(self): + self.assertEqual([], check_pdsc.check_contract(self.make_pdsc())) + + def test_old_floor_rejected_even_when_generator_and_xml_agree(self): + with ( + mock.patch.object(build_pack, "NS_CMSIS_NN_MIN_VERSION", "7.39.2"), + mock.patch.object(check_pdsc, "NS_CMSIS_NN_MIN_VERSION", "7.39.2"), + ): + pdsc = self.make_pdsc() + self.assertEqual( + "7.39.2", pdsc.find("conditions/condition/require").get("Cversion") + ) + failures = check_pdsc.check_contract(pdsc) + self.assertTrue(any("compiled HELIA source floor" in f for f in failures)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/cmsis_pack/examples/hello_world/hello_world_helia.cproject.yml b/tools/cmsis_pack/examples/hello_world/hello_world_helia.cproject.yml index c9d1146ca..a11922989 100644 --- a/tools/cmsis_pack/examples/hello_world/hello_world_helia.cproject.yml +++ b/tools/cmsis_pack/examples/hello_world/hello_world_helia.cproject.yml @@ -3,7 +3,7 @@ project: # The HELIA variant's floor; CI installs the release that helia-rt pins. packs: - - pack: Ambiq::NS-CMSIS-NN@>=7.39.2 + - pack: Ambiq::NS-CMSIS-NN@>=7.41.0 groups: - group: App From b18b3e2a40ea7f79d29ceacf63dc32541f30957b Mon Sep 17 00:00:00 2001 From: Adam Page Date: Wed, 7 Oct 2026 14:17:06 -0500 Subject: [PATCH 3/4] fix(pack): bind dependency floor to selected sources Refs #426 Refs #427 --- tools/cmsis_pack/build_pack.py | 24 ++-- tools/cmsis_pack/check_pdsc.py | 56 +++++++--- tools/cmsis_pack/check_pdsc_test.py | 165 +++++++++++++++++++++++++--- 3 files changed, 208 insertions(+), 37 deletions(-) diff --git a/tools/cmsis_pack/build_pack.py b/tools/cmsis_pack/build_pack.py index b89b8b51a..95ffa5b1d 100644 --- a/tools/cmsis_pack/build_pack.py +++ b/tools/cmsis_pack/build_pack.py @@ -97,22 +97,28 @@ NS_CMSIS_NN_CVARIANT = "Source" -def core_source_min_version() -> str: - """Read the minimum from the HELIA source's compiled version guard.""" - guard = Path(__file__).resolve().parents[2] / "cmake/helia_rt_core_version.cc" +CORE_VERSION_GUARD = "cmake/helia_rt_core_version.cc" + + +def parse_core_source_min_version(guard_text: str) -> str: + """Parse the minimum in a HELIA source's compiled numeric guard.""" versions = re.findall( r"^\s*#if[^\n]*NS_CMSIS_NN_VERSION\s*<\s*(\d+)\s*$", - guard.read_text(encoding="utf-8"), + guard_text, re.M, ) if len(versions) != 1: - raise ValueError(f"expected one HELIA version floor in {guard}") + raise ValueError("expected one compiled numeric HELIA version floor") major, remainder = divmod(int(versions[0]), 1000000) minor, patch = divmod(remainder, 1000) return f"{major}.{minor}.{patch}" -NS_CMSIS_NN_MIN_VERSION = core_source_min_version() +def core_source_min_version(repo_root: Path) -> str: + """Read the compiled HELIA floor from the selected source root.""" + return parse_core_source_min_version( + (repo_root / CORE_VERSION_GUARD).read_text(encoding="utf-8") + ) # --------------------------------------------------------------------------- @@ -362,12 +368,13 @@ def _ind(elem: ET.Element, level: int = 0) -> None: def build_pdsc( *, version: str, + core_min_version: str, manifests: dict[str, BackendManifest], sources: set[str], headers: set[str], include_dirs: set[str], ) -> ET.ElementTree: - """Build the in-memory ```` XML tree for the pack.""" + """Build package XML using the floor read from the selected source root.""" pkg = ET.Element("package", schemaVersion="1.7.36") ET.SubElement(pkg, "vendor").text = PACK_VENDOR @@ -410,7 +417,7 @@ def build_pdsc( Cgroup=NS_CMSIS_NN_CGROUP, Csub=NS_CMSIS_NN_CSUB, Cvariant=NS_CMSIS_NN_CVARIANT, - Cversion=NS_CMSIS_NN_MIN_VERSION, + Cversion=core_min_version, ) # ----- components -------------------------------------------------- @@ -527,6 +534,7 @@ def build_pack( write_define_stubs(stage, manifests) tree = build_pdsc( version=resolved_version, + core_min_version=core_source_min_version(repo_root), manifests=manifests, sources=sources, headers=headers, diff --git a/tools/cmsis_pack/check_pdsc.py b/tools/cmsis_pack/check_pdsc.py index 997e8e378..a9546c448 100644 --- a/tools/cmsis_pack/check_pdsc.py +++ b/tools/cmsis_pack/check_pdsc.py @@ -8,15 +8,16 @@ (Cclass, Cgroup, Csub, Cvariant, Cversion) - HELIA variant is gated by a on ns-cmsis-nn/heliaCORE - the inside that condition targets the exact identity - ns-cmsis-nn ships and pins Cversion to the agreed minimum - - the advertised minimum satisfies the compiled HELIA source floor + ns-cmsis-nn ships + - the advertised minimum satisfies the matching compiled HELIA source floor Run modes: - # Use an existing pdsc file (the workflow's preferred path). + # A staged pdsc uses its sibling source tree, or an explicit source root. python3 tools/cmsis_pack/check_pdsc.py path/to/Ambiq.helia-rt.pdsc + python3 tools/cmsis_pack/check_pdsc.py file.pdsc --repo-root path/to/helia-rt - # Or: read the pdsc out of a built .pack archive. + # A .pack uses its own packaged guard, never the tool checkout's sources. python3 tools/cmsis_pack/check_pdsc.py path/to/Ambiq.helia-rt..pack Exit code is 0 on success, 1 on contract violation (with a diff-style @@ -32,36 +33,44 @@ import zipfile from pathlib import Path -# Single source of truth for the contract — keep imports lazy so the -# script remains usable from a checkout that has not configured anything. +# Shared package identity and guard parser; no source tree is read on import. sys.path.insert(0, str(Path(__file__).resolve().parent)) from build_pack import ( # noqa: E402 BACKENDS, CCLASS, CGROUP, CSUB, + CORE_VERSION_GUARD, NS_CMSIS_NN_CCLASS, NS_CMSIS_NN_CGROUP, NS_CMSIS_NN_CSUB, NS_CMSIS_NN_CVARIANT, - NS_CMSIS_NN_MIN_VERSION, NS_CMSIS_NN_VENDOR, PACK_NAME, PACK_VENDOR, core_source_min_version, + parse_core_source_min_version, ) -def _load_pdsc(path: Path) -> ET.Element: +def _load_pdsc(path: Path, repo_root: Path | None = None) -> tuple[ET.Element, str]: if path.suffix == ".pack": + if repo_root is not None: + raise ValueError( + "--repo-root applies to plain PDSC files; .pack owns its sources" + ) with zipfile.ZipFile(path) as zf: pdsc_name = next( (n for n in zf.namelist() if n.endswith(".pdsc")), None ) if not pdsc_name: raise SystemExit(f"no .pdsc inside {path}") - return ET.fromstring(zf.read(pdsc_name)) - return ET.parse(path).getroot() + source_floor = parse_core_source_min_version( + zf.read(CORE_VERSION_GUARD).decode("utf-8") + ) + return ET.fromstring(zf.read(pdsc_name)), source_floor + source_floor = core_source_min_version(repo_root or path.parent) + return ET.parse(path).getroot(), source_floor def _check(failures: list[str], cond: bool, msg: str) -> None: @@ -69,7 +78,8 @@ def _check(failures: list[str], cond: bool, msg: str) -> None: failures.append(msg) -def check_contract(pdsc: ET.Element) -> list[str]: +def check_contract(pdsc: ET.Element, *, source_floor: str) -> list[str]: + """Check metadata against the floor parsed from its matching source guard.""" failures: list[str] = [] _check(failures, pdsc.tag == "package", f"root tag = {pdsc.tag!r}, want 'package'") @@ -131,7 +141,6 @@ def check_contract(pdsc: ET.Element) -> list[str]: ("Cgroup", NS_CMSIS_NN_CGROUP), ("Csub", NS_CMSIS_NN_CSUB), ("Cvariant", NS_CMSIS_NN_CVARIANT), - ("Cversion", NS_CMSIS_NN_MIN_VERSION), ): got = req.get(attr) _check( @@ -140,7 +149,6 @@ def check_contract(pdsc: ET.Element) -> list[str]: f"heliaCORE : {attr}={got!r}, want {want!r}", ) advertised = req.get("Cversion", "") - source_floor = core_source_min_version() _check( failures, bool(re.fullmatch(r"\d+\.\d+\.\d+", advertised)) @@ -155,6 +163,11 @@ def check_contract(pdsc: ET.Element) -> list[str]: def main() -> int: ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--repo-root", + type=Path, + help="Matching source root for a plain PDSC (default: its directory); .pack uses its own sources", + ) ap.add_argument( "path", type=Path, @@ -166,15 +179,26 @@ def main() -> int: print(f"error: {args.path} not found", file=sys.stderr) return 1 - pdsc = _load_pdsc(args.path) - failures = check_contract(pdsc) + try: + pdsc, source_floor = _load_pdsc(args.path, args.repo_root) + except (OSError, ValueError, KeyError, zipfile.BadZipFile) as exc: + print(f"error: cannot establish matching source floor: {exc}", file=sys.stderr) + return 1 + failures = check_contract(pdsc, source_floor=source_floor) if failures: print("CMSIS-Pack contract check FAILED:", file=sys.stderr) for f in failures: print(f" - {f}", file=sys.stderr) return 1 - print(f"OK: {args.path.name} matches heliaRT pack contract") + context = ( + "packaged sources" if args.path.suffix == ".pack" + else str(args.repo_root or args.path.parent) + ) + print( + f"OK: {args.path.name} matches heliaRT pack contract; " + f"source floor {source_floor} from {context}" + ) return 0 diff --git a/tools/cmsis_pack/check_pdsc_test.py b/tools/cmsis_pack/check_pdsc_test.py index ffd6f4c56..9816a6e6a 100644 --- a/tools/cmsis_pack/check_pdsc_test.py +++ b/tools/cmsis_pack/check_pdsc_test.py @@ -1,49 +1,188 @@ -"""Regression checks for the advertised HELIA source dependency floor.""" +"""Regression checks for selected-source and packaged HELIA dependency floors.""" +import contextlib +import io +import shutil +import sys +import tempfile import unittest +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path from unittest import mock import build_pack import check_pdsc +class MetadataComplete(Exception): + pass + + class DependencyFloorTest(unittest.TestCase): - def make_pdsc(self): - manifests = { + @classmethod + def setUpClass(cls): + # Retain the small metadata fixtures for inspection; no directory deletion. + cls.scratch = Path(tempfile.mkdtemp(prefix="helia-rt-floor-")) + source = Path(__file__).resolve().parents[2] + guard = (source / build_pack.CORE_VERSION_GUARD).read_text() + cls.roots = {} + for floor, numeric in (("7.41.0", "7041000"), ("7.42.0", "7042000")): + root = cls.scratch / floor + (root / "cmake").mkdir(parents=True) + (root / build_pack.CORE_VERSION_GUARD).write_text( + guard.replace("7041000", numeric).replace("7.41.0", floor) + ) + (root / "cmake/helia_rt_sources.cmake").write_text( + "# Metadata fixture only\n" + ) + cls.roots[floor] = root + print(f"Retained metadata fixtures: {cls.scratch}") + + def manifests(self): + return { backend: build_pack.BackendManifest( backend=backend, version="2.0.0", include_dirs=(), common_sources=(), - kernel_sources=("cmake/helia_rt_core_version.cc",) + kernel_sources=(build_pack.CORE_VERSION_GUARD,) if backend == "helia" else (), backend_defines=(), ) for backend, _, _ in build_pack.BACKENDS } + + def make_pdsc(self, floor="7.41.0"): return build_pack.build_pdsc( version="2.0.0", - manifests=manifests, + core_min_version=floor, + manifests=self.manifests(), sources=set(), headers=set(), include_dirs=set(), ).getroot() def test_current_generated_contract(self): - self.assertEqual([], check_pdsc.check_contract(self.make_pdsc())) + floor = build_pack.core_source_min_version( + Path(__file__).resolve().parents[2] + ) + self.assertEqual( + [], check_pdsc.check_contract(self.make_pdsc(floor), source_floor=floor) + ) + + def test_old_matching_advertised_metadata_rejected(self): + pdsc = self.make_pdsc("7.39.2") + self.assertEqual( + "7.39.2", pdsc.find("conditions/condition/require").get("Cversion") + ) + failures = check_pdsc.check_contract(pdsc, source_floor="7.41.0") + self.assertTrue(any("compiled HELIA source floor" in f for f in failures)) + + def test_build_pack_follows_both_selected_source_roots(self): + real_build_pdsc = build_pack.build_pdsc + for floor, root in self.roots.items(): + with self.subTest(floor=floor): + stage = self.scratch / ("stage-" + floor) + stage.mkdir() + captured = [] - def test_old_floor_rejected_even_when_generator_and_xml_agree(self): + def stage_guard(selected_root, manifests, destination): + target = destination / build_pack.CORE_VERSION_GUARD + target.parent.mkdir() + shutil.copyfile(selected_root / build_pack.CORE_VERSION_GUARD, target) + return {build_pack.CORE_VERSION_GUARD}, set(), set() + + def capture_pdsc(**kwargs): + pdsc = real_build_pdsc(**kwargs).getroot() + captured.append(pdsc) + ET.ElementTree(pdsc).write(stage / "probe.pdsc") + raise MetadataComplete + + with ( + mock.patch.object( + build_pack, + "dump_manifest", + side_effect=lambda selected, backend: self.manifests()[backend], + ), + mock.patch.object( + build_pack, "stage_pack_files", side_effect=stage_guard + ), + mock.patch.object( + build_pack.tempfile, + "TemporaryDirectory", + return_value=contextlib.nullcontext(str(stage)), + ), + mock.patch.object(build_pack, "build_pdsc", side_effect=capture_pdsc), + self.assertRaises(MetadataComplete), + ): + build_pack.build_pack(root, self.scratch / ("output-" + floor)) + source_floor = build_pack.core_source_min_version(stage) + self.assertEqual(floor, source_floor) + self.assertEqual( + floor, + captured[0].find("conditions/condition/require").get("Cversion"), + ) + self.assertEqual( + [], check_pdsc.check_contract(captured[0], source_floor=source_floor) + ) + + def check_cli(self, *args): with ( - mock.patch.object(build_pack, "NS_CMSIS_NN_MIN_VERSION", "7.39.2"), - mock.patch.object(check_pdsc, "NS_CMSIS_NN_MIN_VERSION", "7.39.2"), + mock.patch.object(sys, "argv", ["check_pdsc.py", *map(str, args)]), + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + return check_pdsc.main() + + def test_plain_pdsc_uses_explicit_or_staged_source_root(self): + for floor, root in self.roots.items(): + pdsc = root / "probe.pdsc" + ET.ElementTree(self.make_pdsc(floor)).write(pdsc) + self.assertEqual(0, self.check_cli(pdsc)) + self.assertEqual(0, self.check_cli(pdsc, "--repo-root", root)) + wrong = self.scratch / "wrong.pdsc" + ET.ElementTree(self.make_pdsc("7.41.0")).write(wrong) + self.assertEqual( + 1, self.check_cli(wrong, "--repo-root", self.roots["7.42.0"]) + ) + self.assertEqual(1, self.check_cli(wrong)) + + def test_pack_uses_its_own_guard_and_rejects_wrong_tool_floor(self): + for advertised, expected in (("7.42.0", 0), ("7.41.0", 1)): + pack = self.scratch / (advertised + ".pack") + with zipfile.ZipFile(pack, "w") as archive: + archive.writestr( + "Ambiq.helia-rt.pdsc", ET.tostring(self.make_pdsc(advertised)) + ) + archive.write( + self.roots["7.42.0"] / build_pack.CORE_VERSION_GUARD, + build_pack.CORE_VERSION_GUARD, + ) + self.assertEqual(expected, self.check_cli(pack)) + self.assertEqual( + 1, self.check_cli(pack, "--repo-root", self.roots["7.41.0"]) + ) + + def test_generator_cli_preserves_repo_root_selection(self): + result = self.roots["7.42.0"] / build_pack.CORE_VERSION_GUARD + with ( + mock.patch.object(build_pack, "build_pack", return_value=result) as build, + contextlib.redirect_stdout(io.StringIO()), ): - pdsc = self.make_pdsc() self.assertEqual( - "7.39.2", pdsc.find("conditions/condition/require").get("Cversion") + 0, + build_pack.main( + [ + "--repo-root", + str(self.roots["7.42.0"]), + "--output", + str(self.scratch), + ] + ), ) - failures = check_pdsc.check_contract(pdsc) - self.assertTrue(any("compiled HELIA source floor" in f for f in failures)) + self.assertEqual(self.roots["7.42.0"], build.call_args.args[0]) if __name__ == "__main__": From 2fb38ef08574520c781be1bf8197c4c72e2f0d32 Mon Sep 17 00:00:00 2001 From: Adam Page Date: Wed, 7 Oct 2026 14:32:45 -0500 Subject: [PATCH 4/4] fix(pack): reject ambiguous archive metadata Refs #426 Refs #427 --- tools/cmsis_pack/check_pdsc.py | 19 +++++++---- tools/cmsis_pack/check_pdsc_test.py | 50 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/tools/cmsis_pack/check_pdsc.py b/tools/cmsis_pack/check_pdsc.py index a9546c448..365a4e394 100644 --- a/tools/cmsis_pack/check_pdsc.py +++ b/tools/cmsis_pack/check_pdsc.py @@ -60,15 +60,20 @@ def _load_pdsc(path: Path, repo_root: Path | None = None) -> tuple[ET.Element, s "--repo-root applies to plain PDSC files; .pack owns its sources" ) with zipfile.ZipFile(path) as zf: - pdsc_name = next( - (n for n in zf.namelist() if n.endswith(".pdsc")), None - ) - if not pdsc_name: - raise SystemExit(f"no .pdsc inside {path}") + descriptors = [e for e in zf.infolist() if e.filename.endswith(".pdsc")] + guards = [e for e in zf.infolist() if e.filename == CORE_VERSION_GUARD] + if len(descriptors) != 1: + raise ValueError( + f"expected exactly one PDSC entry, found {len(descriptors)}" + ) + if len(guards) != 1: + raise ValueError( + f"expected exactly one canonical guard entry, found {len(guards)}" + ) source_floor = parse_core_source_min_version( - zf.read(CORE_VERSION_GUARD).decode("utf-8") + zf.read(guards[0]).decode("utf-8") ) - return ET.fromstring(zf.read(pdsc_name)), source_floor + return ET.fromstring(zf.read(descriptors[0])), source_floor source_floor = core_source_min_version(repo_root or path.parent) return ET.parse(path).getroot(), source_floor diff --git a/tools/cmsis_pack/check_pdsc_test.py b/tools/cmsis_pack/check_pdsc_test.py index 9816a6e6a..4df86034a 100644 --- a/tools/cmsis_pack/check_pdsc_test.py +++ b/tools/cmsis_pack/check_pdsc_test.py @@ -6,6 +6,7 @@ import sys import tempfile import unittest +import warnings import xml.etree.ElementTree as ET import zipfile from pathlib import Path @@ -165,6 +166,55 @@ def test_pack_uses_its_own_guard_and_rejects_wrong_tool_floor(self): 1, self.check_cli(pack, "--repo-root", self.roots["7.41.0"]) ) + def test_multiple_pdsc_entries_rejected_in_every_order(self): + stale = ("stale.pdsc", "7.42.0") + canonical = ("Ambiq.helia-rt.pdsc", "7.41.0") + entries = ((stale, canonical), (canonical, stale), (stale, stale)) + for index, descriptors in enumerate(entries): + with self.subTest(descriptors=descriptors): + pack = self.scratch / f"multiple-descriptors-{index}.pack" + with warnings.catch_warnings(), zipfile.ZipFile(pack, "w") as archive: + warnings.filterwarnings( + "ignore", message="Duplicate name:.*", category=UserWarning + ) + for name, floor in descriptors: + archive.writestr(name, ET.tostring(self.make_pdsc(floor))) + archive.write( + self.roots["7.42.0"] / build_pack.CORE_VERSION_GUARD, + build_pack.CORE_VERSION_GUARD, + ) + with self.assertRaisesRegex(ValueError, "exactly one PDSC entry"): + check_pdsc._load_pdsc(pack) + self.assertEqual(1, self.check_cli(pack)) + + def test_missing_or_duplicate_canonical_guard_rejected(self): + cases = ( + (), + ("7.41.0", "7.42.0"), + ("7.42.0", "7.41.0"), + ("7.42.0", "7.42.0"), + ) + for index, floors in enumerate(cases): + with self.subTest(guards=floors): + pack = self.scratch / f"ambiguous-guard-{index}.pack" + with warnings.catch_warnings(), zipfile.ZipFile(pack, "w") as archive: + warnings.filterwarnings( + "ignore", message="Duplicate name:.*", category=UserWarning + ) + archive.writestr( + "Ambiq.helia-rt.pdsc", ET.tostring(self.make_pdsc("7.42.0")) + ) + for floor in floors: + archive.write( + self.roots[floor] / build_pack.CORE_VERSION_GUARD, + build_pack.CORE_VERSION_GUARD, + ) + with self.assertRaisesRegex( + ValueError, "exactly one canonical guard entry" + ): + check_pdsc._load_pdsc(pack) + self.assertEqual(1, self.check_cli(pack)) + def test_generator_cli_preserves_repo_root_selection(self): result = self.roots["7.42.0"] / build_pack.CORE_VERSION_GUARD with (